diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index 7f9cf35d..7471cc15 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -2,68 +2,67 @@ ## Project Structure & Module Organization +This is an **npm workspaces monorepo**. Packages live under `packages/`. + ``` -src/ -├── cli.tsx # Entry point — parses args (-p, -v), renders Ink App -├── session.ts # SessionManager — LLM loop, compaction, tool orchestration -├── settings.ts # Settings resolution from ~/.deepcode/settings.json -├── prompt.ts # System prompt builder, tool definitions, built-in skills -├── common/ -│ ├── model-capabilities.ts # Model detection and thinking-mode defaults -│ ├── openai-thinking.ts # OpenAI thinking request options builder -│ ├── file-utils.ts # File read/write with encoding and diff preview -│ ├── shell-utils.ts # Shell path resolution (Git Bash, zsh, bash) -│ ├── state.ts # In-memory file state and snippet tracking -│ ├── runtime.ts # Tool validation runtime helpers -│ ├── notify.ts # Desktop notification after LLM turn completion -│ ├── debug-logger.ts # Debug logging for OpenAI API calls -│ └── error-logger.ts # API error logging -├── ui/ -│ ├── App.tsx # Root Ink component — state, routing, session orchestration -│ ├── PromptInput.tsx # Multi-line input with file mentions (@), slash commands, image paste, skills -│ ├── MessageView.tsx # Renders assistant/tool messages with markdown -│ ├── McpStatusList.tsx # MCP server connection status and available tools -│ ├── ProcessStdoutView.tsx # Ctrl+O fullscreen overlay for live process stdout -│ ├── UpdatePrompt.tsx # UpdatePlan task list progress display -│ ├── fileMentions.ts # @-mention file scanning, filtering, and insertion -│ └── ... -├── mcp/ -│ ├── mcp-client.ts # MCP client — JSON-RPC communication with MCP servers -│ └── mcp-manager.ts # MCP manager — lifecycle, tool registration, execution, status -├── tools/ -│ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers (7 built-in) -│ ├── bash-handler.ts # Executes shell commands with live stdout streaming -│ ├── read-handler.ts # Reads files, images, PDFs, and notebooks -│ ├── write-handler.ts # Creates/overwrites files -│ ├── edit-handler.ts # Scoped string replacements with snippet tracking -│ ├── update-plan-handler.ts # Updates the task plan progress display -│ ├── web-search-handler.ts # Web search via natural language queries -│ └── ask-user-question-handler.ts # Interactive user prompts with options -├── tests/ # One *.test.ts per source module, plus run-tests.mjs -templates/ -├── tools/ # Tool descriptions fed to the LLM -├── skills/ # Built-in skill definitions (agent-drift-guard, plan-and-execute) -├── prompts/ # EJS templates (e.g., init_command.md.ejs) -docs/ # User-facing documentation (configuration, MCP, skills) -dist/ # Bundled CLI output (gitignored) +packages/ +├── core/src/ # LLM session, tool execution, shared utilities +│ ├── common/ # File I/O, permissions, telemetry, OpenAI client, shell utils, etc. +│ ├── tools/ # 7 built-in handlers (bash, read, write, edit, web-search, ask-user-question, update-plan) +│ ├── mcp/ # MCP client & manager (JSON-RPC lifecycle) +│ ├── session.ts # SessionManager — LLM loop, compaction, tool orchestration +│ ├── prompt.ts # System prompt builder & tool definitions +│ └── settings.ts # Settings resolution from ~/.deepcode/settings.json +├── cli/src/ # Terminal UI (Ink/React) +│ ├── cli.tsx # Entry point — renders AppContainer +│ ├── cli-args.ts # CLI argument parsing (yargs: -p, -r, -v, -h) +│ ├── common/ # Update checker +│ ├── utils/ # stdio helpers, version, package info +│ ├── generated/ # Build-time git commit info +│ ├── ui/views/ # Top-level screens (App, PromptInput, SessionList, PermissionPrompt, WelcomeScreen, UpdatePrompt, McpStatusList, etc.) +│ ├── ui/components/ # Reusable Ink components (MessageView, DropdownMenu, ModelsDropdown, etc.) +│ ├── ui/core/ # Prompt buffer, slash commands, file mentions, clipboard, undo/redo +│ ├── ui/hooks/ # Custom hooks (cursor, history navigation, paste handling, terminal input, statusline) +│ ├── ui/contexts/ # React contexts (AppContext, RawModeContext) +│ ├── ui/statusline/ # Pluggable statusline providers (command, module) +│ └── tests/ # UI-focused tests with run-tests.mjs runner +├── vscode-ide-companion/ # VSCode extension companion +│ └── src/ # extension.ts, provider.ts, utils.ts +docs/ # User-facing documentation (configuration, MCP, notify, permissions) +scripts/ # Build, release, and packaging scripts +dist/ # Bundled CLI output — single-file dist/cli.js (gitignored) +dist/bundled/ # Bundled skills & references shipped with the CLI ``` +Templates for tool descriptions and prompts are at `packages/cli/dist/templates/` (copied during build from `packages/core/templates/`). Built-in skills are under `packages/cli/dist/bundled/`. + ## Build, Test, and Development Commands -| Command | Purpose | +All commands run from the repo root. + +| Command | What it does | |---|---| -| `npm run typecheck` | TypeScript type checking (`tsc --noEmit`) | -| `npm run lint` | ESLint across `src/` | +| `npm run typecheck` | TypeScript type checking across all workspaces | +| `npm run lint` | ESLint across `packages/*/src/**/*.{ts,tsx}` + `scripts/*.js` | | `npm run lint:fix` | ESLint with auto-fix | -| `npm run format` | Prettier on all `src/**/*.{ts,tsx}` | +| `npm run format` | Prettier on all source files | | `npm run format:check` | Prettier in check-only mode | | `npm run check` | Runs typecheck + lint + format:check together | -| `npm run bundle` | esbuild bundles `src/cli.tsx` → `dist/cli.js` (ESM, Node 18) | -| `npm run build` | `check` + `bundle` + chmod 755 — full CI gate before publish | -| `npm test` | Runs all tests via `tsx --test src/tests/*.test.ts` | -| `npm run test:single -- ` | Run a single test file (e.g., `npm run test:single -- src/tests/session.test.ts`) | +| `npm run build` | Orchestrates full build (scripts/build.js) — compiles core + bundles CLI + copies assets | +| `npm run bundle` | Generates git commit info + esbuild bundle + copies bundled assets | +| `npm run build:vscode` | Builds the VSCode extension companion | +| `npm test` | Runs all workspace tests (`npm run test --workspaces --if-present`) | +| `npm run start` | Runs the locally built CLI (`scripts/start.js`) | +| `npm run build-and-start` | Builds then starts the CLI | +| `npm run clean` | Removes generated files and dist directories | + +To run a **single test file** within a package: +``` +node packages/core/src/tests/run-tests.mjs packages/core/src/tests/session.test.ts +node packages/cli/src/tests/run-tests.mjs packages/cli/src/tests/slash-commands.test.ts +``` -Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundle`). +Run the CLI locally for manual testing: `node packages/cli/dist/cli.js` (after `npm run bundle`). ## Coding Style & Naming Conventions @@ -74,31 +73,34 @@ Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundl - **Line width**: 120 characters max - **Line endings**: LF only -**TypeScript**: Strict mode enabled. Use `import type` for type-only imports (enforced by `@typescript-eslint/consistent-type-imports`). Unused variables prefixed with `_` are allowed. +**TypeScript**: Strict mode enabled (`strict: true`). Use `import type` for type-only imports (`@typescript-eslint/consistent-type-imports`). Unused variables prefixed with `_` are allowed (`argsIgnorePattern: "^_"`). Target ES2022, module ESNext with bundler resolution. JSX is `react-jsx`. -**Formatting/Linting**: Prettier + ESLint (typescript-eslint, react-hooks). Run `npm run check` before pushing. On commit, Husky + lint-staged auto-formats staged `*.{ts,tsx,js,mjs,cjs,ejs,jsx}` and `*.json` files. +**Formatting/Linting**: Prettier (double quotes, 2-space indent, semicolons) + ESLint (typescript-eslint, react-hooks). Run `npm run check` before pushing. On commit, Husky + lint-staged auto-formats staged `*.{ts,tsx,js,mjs,cjs,jsx}` and `*.json` files. -**File naming**: `kebab-case.ts` for modules, `kebab-case.tsx` for React/Ink components. Test files: `*.test.ts`. +**File naming**: `kebab-case.ts` for modules, `kebab-case.tsx` for React/Ink components. Test files: `*.test.ts` (always kebab-case). ## Testing Guidelines - **Framework**: Node.js native test runner (`node:test`) with `tsx` for TypeScript - **Assertions**: `node:assert/strict` -- **Coverage**: Target meaningful unit tests for core logic (session management, tool handlers, settings resolution, prompt buffer). Test files are in `src/tests/` matching the source module name. +- **Coverage**: Target meaningful unit tests for core logic (session management, tool handlers, settings resolution, prompt buffer, permissions, MCP client, telemetry). Test files are in `packages/*/src/tests/` matching the source module name. - **Test naming**: `describe`/`test` blocks with descriptive names. Example: `test("SessionManager preserves structured system content when building OpenAI messages", ...)` - **Relaxed lint rules**: Test files allow `any` and unused vars. -- Run all tests with `npm test` before submitting a PR. A cross-platform test runner is available at `src/tests/run-tests.mjs`. +- Run all tests with `npm test` before submitting a PR. Each package has its own `run-tests.mjs` cross-platform runner. ## Commit & Pull Request Guidelines -**Commit messages** follow conventional commits. From the project history: +**Commit messages** follow conventional commits: - `feat:` — new feature (e.g., `feat: add /model command`) -- `fix:` — bug fix (e.g., `fix(ui): redraw cleanly after terminal resize`) +- `fix:` — bug fix (e.g., `fix(mcp): fix Windows MCP spawn double-quoting`) - `chore:` — tooling, deps, hooks (e.g., `chore: add husky + lint-staged`) - `refactor:` — code restructuring (e.g., `refactor(ui): optimize App hooks`) -- `style:` — formatting-only changes (e.g., `style: adjust the tree structure symbols`) -- `docs:` — documentation (e.g., `docs: add MCP configuration guide`) +- `style:` — formatting-only changes +- `test:` — adding or updating tests +- `docs:` — documentation changes +- `perf:` — performance improvements +- `build:` — build system changes **Pull requests** should include: - A clear description of what changed and why @@ -109,18 +111,23 @@ Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundl ## Architecture Overview -The CLI (`@vegamo/deepcode-cli`) renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). +The CLI (`@vegamo/deepcode-cli`) renders a terminal UI using [Ink](https://github.com/vadimdemedes/ink) (React for terminals). `SessionManager` (in `@vegamo/deepcode-core`) drives the LLM interaction loop: it builds system prompts, sends user messages with optional skills/images, streams responses, executes tool calls via `ToolExecutor`, and compacts context when token thresholds are exceeded (512K for DeepSeek V4 models, 128K for others). OpenAI client connectivity is managed by `createOpenAIClient()` with a 180-second keep-alive timeout. + +Seven built-in tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, `UpdatePlan`, and `WebSearch`. Tool definitions are registered in `packages/core/src/tools/executor.ts` and described to the LLM via `packages/core/src/prompt.ts`. + +A **permission system** (`packages/core/src/common/permissions.ts`) controls tool execution scopes (read/write/delete/network/git-log, etc.) with configurable allow/deny/ask decisions. -Seven built-in tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, `UpdatePlan`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `templates/tools/`. The `UpdatePlan` tool enables the LLM to display and update a structured task list in the terminal. +A **file history system** (`packages/core/src/common/file-history.ts`) provides undo/checkpoint support via lightweight Git branches. -**Slash commands**: `/model`, `/new`, `/init`, `/resume`, `/continue`, `/mcp`, `/exit`, plus dynamic `/skill-name` for each loaded skill. +**Slash commands**: `/skills`, `/model`, `/new`, `/init`, `/resume`, `/continue`, `/undo`, `/mcp`, `/raw`, `/exit`, plus dynamic `/skill-name` for each loaded skill. -**Key UI features**: `@` file mentions in the prompt input (scans project files), `Ctrl+O` to view live process stdout in fullscreen, `Ctrl+V` to paste images, MCP server status display. +**Key UI features**: `@` file mentions in the prompt input, `Ctrl+O` to view live process stdout, `Ctrl+V` to paste images, `Ctrl+X` to clear images, Shift+Enter for newlines, pluggable statusline, MCP server status display, undo selector, and permission prompts. -**CLI flags**: `-p ` / `--prompt` to auto-submit a prompt on launch, `-v` / `--version`, `-h` / `--help`. +**CLI flags**: `-p ` / `--prompt` to auto-submit a prompt on launch, `-r [sessionId]` / `--resume [sessionId]` to resume a session or show the session picker, `-v` / `--version`, `-h` / `--help`. ## Agent-Specific Instructions -- **AGENTS.md loading**: The CLI loads agent instructions from `./AGENTS.md`, `./.deepcode/AGENTS.md`, or `~/.deepcode/AGENTS.md` (first found wins). Write project-specific guidance for the LLM in any of these. +- **AGENTS.md loading**: The CLI loads agent instructions from `./AGENTS.md`, `./.deepcode/AGENTS.md`, or `~/.deepcode/AGENTS.md` (first found wins). - **Skills**: Place skill definitions in `~/.agents/skills//SKILL.md` (user-level) or `./.agents/skills//SKILL.md` (project-level). Legacy path `./.deepcode/skills/` is also supported. Each SKILL.md uses YAML frontmatter with `name` and `description` fields. -- **Built-in skills**: `agent-drift-guard` (detects and corrects execution drift) and `plan-and-execute` (structured task planning with progress tracking). Both are defined in `templates/skills/` and always injected into every session. +- **Built-in skills**: Four bundled skills ship with the CLI — `plan` (task planning workflow), `deepcode-self-refer` (Deep Code CLI documentation), `skill-digester` (digest & install skills), `skill-writer` (create & debug skills). Additionally, `karpathy-guidelines` (behavioral guidelines to reduce LLM coding mistakes) is injected as a default skill template. +- **Prompt file references**: Use `@path/to/file` syntax in prompts to load file contents through the read tool. diff --git a/.deepcode/plugins/cwd.mjs b/.deepcode/plugins/cwd.mjs new file mode 100644 index 00000000..d36099b4 --- /dev/null +++ b/.deepcode/plugins/cwd.mjs @@ -0,0 +1,7 @@ +export default function cwdProvider({ projectRoot }) { + const cwd = process.cwd() || projectRoot || ""; + if (!cwd) return ""; + const home = process.env.HOME || process.env.USERPROFILE || ""; + const display = home && cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd; + return display; +} diff --git a/.deepcode/plugins/git-branch.mjs b/.deepcode/plugins/git-branch.mjs new file mode 100644 index 00000000..b5096073 --- /dev/null +++ b/.deepcode/plugins/git-branch.mjs @@ -0,0 +1,16 @@ +import { execFileSync } from "node:child_process"; + +export default function gitBranchProvider({ projectRoot }) { + try { + const out = execFileSync("git", ["branch", "--show-current"], { + cwd: projectRoot || process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1500, + }).trim(); + if (!out) return ""; + return `git:${out}`; + } catch { + return ""; + } +} diff --git a/.deepcode/plugins/model-info.mjs b/.deepcode/plugins/model-info.mjs new file mode 100644 index 00000000..71a4ec09 --- /dev/null +++ b/.deepcode/plugins/model-info.mjs @@ -0,0 +1,13 @@ +export default function modelInfoProvider({ session }) { + if (!session) return ""; + const parts = []; + if (session.model) { + parts.push(session.model); + } + if (session.thinkingEnabled && session.reasoningEffort) { + parts.push(`thinking:${session.reasoningEffort}`); + } else if (session.thinkingEnabled) { + parts.push("thinking"); + } + return parts.join(" "); +} diff --git a/.deepcode/plugins/session-stats.mjs b/.deepcode/plugins/session-stats.mjs new file mode 100644 index 00000000..79aca645 --- /dev/null +++ b/.deepcode/plugins/session-stats.mjs @@ -0,0 +1,22 @@ +function formatTokens(n) { + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k"; + return String(n); +} + +export default function sessionStatsProvider({ session }) { + if (!session || !session.activeSessionId) { + return "no session"; + } + const parts = []; + parts.push(`msgs:${session.messageCount}`); + if (session.requestCount > 0) { + parts.push(`reqs:${session.requestCount}`); + } + if (session.activeTokens > 0 && session.maxContextTokens > 0) { + const pct = Math.round((session.activeTokens / session.maxContextTokens) * 100); + parts.push(`ctx:${formatTokens(session.activeTokens)}/${formatTokens(session.maxContextTokens)} ${pct}%`); + } else if (session.totalTokens > 0) { + parts.push(`tokens:${formatTokens(session.totalTokens)}`); + } + return parts.join(" "); +} diff --git a/.deepcode/plugins/tool-usage.mjs b/.deepcode/plugins/tool-usage.mjs new file mode 100644 index 00000000..212fe45e --- /dev/null +++ b/.deepcode/plugins/tool-usage.mjs @@ -0,0 +1,23 @@ +const TOOL_ORDER = ["bash", "edit", "read", "write", "AskUserQuestion", "UpdatePlan", "WebSearch"]; + +export default function toolUsageProvider({ session }) { + if (!session || !session.activeSessionId) { + return ""; + } + const usage = session.toolUsage; + if (!usage || Object.keys(usage).length === 0) { + return ""; + } + // Sort: preferred order first, then by count desc + const sorted = Object.entries(usage).sort((a, b) => { + const ai = TOOL_ORDER.indexOf(a[0]); + const bi = TOOL_ORDER.indexOf(b[0]); + if (ai !== -1 && bi !== -1) return ai - bi; + if (ai !== -1) return -1; + if (bi !== -1) return 1; + return b[1] - a[1]; + }); + + const shortNames = sorted.slice(0, 6); + return shortNames.map(([name, count]) => `${name}×${count}`).join(" "); +} diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..aab21d64 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# VS Code Marketplace publish token +# Generate at: https://dev.azure.com/vegamo/_usersSettings/tokens +# Permission required: Marketplace → Publish +VSCE_PAT= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dc891f0..db286bd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: - windows-latest - macos-latest node-version: - - "20" - "22" - "24" @@ -38,8 +37,14 @@ jobs: - name: TypeCheck + Lint + Format Check run: npm run check - - name: Bundle + - name: Build Core + run: npm run build --workspace=@vegamo/deepcode-core + + - name: Bundle CLI run: npm run bundle + - name: Build VSCode Extension + run: npm run build:vscode + - name: Test run: npm test diff --git a/.gitignore b/.gitignore index 11b67ce4..634aea11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,31 @@ +# OS metadata +.DS_Store +Thumbs.db + +# Dependency directory node_modules/ +# Ignore built ts files dist/ -.DS_Store +out/ + + +# Editors .idea/ .vscode/ *.tgz *.log +*.vsix +.deepcode/settings.json + +# TypeScript build info files +*.tsbuildinfo + +# Environment variables +.env +.env.local + +# Generated files +packages/cli/src/generated/ +packages/core/src/generated/ +packages/vscode-ide-companion/*.vsix +packages/vscode-ide-companion/templates/ \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..38f11c64 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 44a3a244..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,35 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -- `src/` contains the TypeScript CLI implementation, with tool handlers in `src/tools/`, MCP integration in `src/mcp/`, UI components in `src/ui/`, and shared helpers in `src/common/`. -- `src/tests/` contains Node test files named `*.test.ts`. -- `templates/` contains runtime prompt assets: `templates/prompts/` for EJS prompt templates and `templates/tools/` for tool instruction Markdown loaded into the system prompt. -- `docs/` is reserved for user-facing documentation such as configuration and MCP guides. -- `resources/` stores static images used by the documentation or UI. - -## Build, Test, and Development Commands - -- `npm test` runs all test files with `tsx --test`. -- `npm run test:single -- src/tests/.test.ts` runs one test file. -- `npm run typecheck` verifies TypeScript types without emitting files. -- `npm run lint` checks ESLint rules for `src/`. -- `npm run build` runs checks, bundles `src/cli.tsx` to `dist/cli.js`, and marks the bundle executable. - -## Coding Style & Naming Conventions - -- Use TypeScript ES modules and keep imports explicit. -- Prefer small, focused functions; keep filesystem path construction centralized when a path is reused. -- Use two-space indentation and Prettier-compatible formatting. -- Respond in standard technical English. Avoid nonstandard phrasing and corporate jargon. - -## Testing Guidelines - -- Add or update tests in `src/tests/` when changing command behavior, prompt rendering, session flow, tools, or settings. -- Prefer Node's built-in `node:test` and `node:assert/strict` APIs, matching the existing tests. -- Keep tests deterministic by using temporary directories and mocked network calls where needed. - -## Commit & Pull Request Guidelines - -- Keep commits focused on a single change and use concise, imperative commit messages. -- In pull requests, describe the behavior change, list verification commands, and note any packaging or template path changes. diff --git a/README_en.md b/README-en.md similarity index 56% rename from README_en.md rename to README-en.md index ee5a1034..453dd64c 100644 --- a/README_en.md +++ b/README-en.md @@ -1,7 +1,24 @@ -# Deep Code CLI +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + +English · [中文](./README.md) + +
+
[Deep Code](https://github.com/lessweb/deepcode-cli) is a terminal AI coding assistant optimized for the `deepseek-v4` model, with support for deep thinking, reasoning effort control, Agent Skills, and MCP (Model Context Protocol) integration. + ## Installation ```bash @@ -37,8 +54,14 @@ For complete configuration details (multi-level priority, environment variables, ### **Skills** Deep Code CLI supports agent skills that allow you to extend the assistant's capabilities: -- **User-level Skills**: discovered and activated from `~/.agents/skills/`. -- **Project-level Skills**: loaded from `./.agents/skills/` for project-specific workflows, with legacy `./.deepcode/skills/` compatibility. +Skills are discovered from these locations, in priority order: + +| Scope | Path | Purpose | +| :------ | :-------------------- | :---------------------------- | +| Project | `./.deepcode/skills/` | Deep Code's native location | +| Project | `./.agents/skills/` | Cross-client interoperability | +| User | `~/.deepcode/skills/` | Deep Code's native location | +| User | `~/.agents/skills/` | Cross-client interoperability | ### **Optimized for DeepSeek** - Specifically tuned for DeepSeek model performance. @@ -47,16 +70,19 @@ Deep Code CLI supports agent skills that allow you to extend the assistant's cap ## Slash Commands & Keyboard Shortcuts -| Slash Command | Action | -|------------------|----------------------------------------------------------| -| `/` | Open the skills / commands menu | -| `/new` | Start a fresh conversation | -| `/resume` | Choose a previous conversation to continue | -| `/model` | Switch model, thinking mode, and reasoning effort | -| `/init` | Initialize an AGENTS.md file (LLM project instructions) | -| `/skills` | List available skills | -| `/mcp` | View MCP server status and available tools | -| `/exit` | Quit (also `Ctrl+D` twice) | +| Slash Command | Action | +|------------------|---------------------------------------------------------| +| `/` | Open the skills / commands menu | +| `/new` | Start a fresh conversation | +| `/resume` | Choose a previous conversation to continue | +| `/continue` | Continue the active conversation or pick one to resume | +| `/model` | Switch model, thinking mode, and reasoning effort | +| `/raw` | Toggle display mode (Normal / Lite / Raw scrollback) | +| `/init` | Initialize an AGENTS.md file (LLM project instructions) | +| `/skills` | List available skills | +| `/mcp` | View MCP server status and available tools | +| `/undo` | Restore code and/or conversation to a previous point | +| `/exit` | Quit (also `Ctrl+D` twice) | | Key | Action | |------------------|----------------------------------------------------------| @@ -84,7 +110,7 @@ Deep Code supports multimodal input — you can paste images from the clipboard ### How to automatically send a Slack message after a task completes? -Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, refer to: https://binfer.net/share/jby5xnc-so6g +Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, see [docs/notify_en.md](docs/notify_en.md). ### How do I enable web search? @@ -111,6 +137,16 @@ Deep Code supports MCP (Model Context Protocol) to connect external services suc For detailed setup instructions, see: [docs/mcp.md](docs/mcp.md) +### How to configure Deep Code to send notifications after a task completes? + +When the AI assistant completes a task, Deep Code can automatically execute a notification script to send the task results to the specified channel (e.g., Slack, system notifications, etc.). + +For detailed configuration instructions, see: [docs/notify_en.md](docs/notify_en.md) + +### Does Deep Code only support YOLO mode? + +No. Deep Code has a built-in fine-grained permission control mechanism that lets you confirm operations before the AI assistant executes shell commands, reads/writes files, accesses the network, and more. You can configure each permission scope's policy — always allow, always ask, or deny — via the `permissions` field in `settings.json`. See [docs/permission.md](docs/permission.md) for details. + ## Contributing Contributions are welcome! Here's how to get started: @@ -151,3 +187,23 @@ If you find this tool helpful, please consider supporting us by: - Giving us a Star on GitHub (https://github.com/lessweb/deepcode-cli) - Submitting feedback and suggestions - Sharing with your friends and colleagues + + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 diff --git a/README-zh_CN.md b/README-zh_CN.md new file mode 100644 index 00000000..cde02314 --- /dev/null +++ b/README-zh_CN.md @@ -0,0 +1,208 @@ +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + +[English](README-en.md) · 中文 + +
+
+ +[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 + +## 安装 + +```bash +npm install -g @vegamo/deepcode-cli +``` + +在任意项目目录下运行 `deepcode` 即可启动。 + +![intro2](resources/intro2.png) + +## 配置 + +创建 `~/.deepcode/settings.json` 文件,内容如下: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 + +完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 + +## 主要功能 + +### **Skills** +Deep Code CLI 支持 agent skills,允许您扩展助手的能力: + +Skills 会按以下优先级扫描: + +| Scope | Path | Purpose | +| :------ | :-------------------- | :---------------------------- | +| Project | `./.deepcode/skills/` | Deep Code 原生位置 | +| Project | `./.agents/skills/` | 跨客户端互操作 | +| User | `~/.deepcode/skills/` | Deep Code 原生位置 | +| User | `~/.agents/skills/` | 跨客户端互操作 | + +### **为 DeepSeek 优化** +- 专门为 DeepSeek 模型性能调优。 +- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 +- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 + +## 斜杠命令与按键功能 + +| 斜杠命令 | 操作 | +|-------------|----------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/undo` | 将代码和/或对话恢复到之前的状态 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|---------------|--------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | + +## 支持的模型 + +- `deepseek-v4-pro`(推荐使用) +- `deepseek-v4-flash` +- 任何其他 OpenAI 兼容模型 + + +## 常见问题 + +### Deep Code 是否有 VSCode 插件? + +有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 + +### Deep Code 是否支持理解图片? + +Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 deepseek-v4 不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的 Doubao-Seed-2.0-pro 模型,适配效果最好。 + +### 怎样在任务完成后自动给 Slack 发消息? + +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 + +### 怎样启用联网搜索功能? + +Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli + +### 如何配置 MCP? + +Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 + +详细配置指南:[docs/mcp.md](docs/mcp.md) + +### 如何配置 Deep Code 任务完成后发送通知? + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +详细配置指南:[docs/notify.md](docs/notify.md) + +### Deep Code 只支持 YOLO 模式吗? + +不是。Deep Code 内置了细粒度的权限控制机制,支持在 AI 助手执行 Shell 命令、读写文件、访问网络等操作前进行确认。你可以通过 `settings.json` 中的 `permissions` 字段按需配置每种权限范围的策略:始终允许、始终询问、或直接拒绝。详见 [docs/permission.md](docs/permission.md)。 + +### 是否支持 Coding Plan? + +支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: + +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` + +## 贡献 + +欢迎贡献代码!以下是参与方式: + +```bash +# 克隆仓库 +git clone https://github.com/lessweb/deepcode-cli.git +cd deepcode-cli + +# 安装依赖 +npm install + +# 本地开发(类型检查 + lint + 格式检查 + 构建) +npm run build + +# 运行测试 +npm test + +# 链接到全局(即本地全局安装) +npm link +``` + +- 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) +- 建议在执行构建前,先执行 `npm run format` 自动格式化代码,避免构建报错 + +## 获取帮助 + +- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) + +## 协议 + +- MIT + +## 支持我们 + +如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: + +- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) +- 向我们提交反馈和建议 +- 分享给你的朋友和同事 + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 diff --git a/README.md b/README.md index 69d28c82..39cd12bd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,20 @@ -# Deep Code CLI +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + +[English](README-en.md) · 中文 + +
+
[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 @@ -37,8 +53,14 @@ npm install -g @vegamo/deepcode-cli ### **Skills** Deep Code CLI 支持 agent skills,允许您扩展助手的能力: -- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 -- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 +Skills 会按以下优先级扫描: + +| Scope | Path | Purpose | +| :------ | :-------------------- | :---------------------------- | +| Project | `./.deepcode/skills/` | Deep Code 原生位置 | +| Project | `./.agents/skills/` | 跨客户端互操作 | +| User | `~/.deepcode/skills/` | Deep Code 原生位置 | +| User | `~/.agents/skills/` | 跨客户端互操作 | ### **为 DeepSeek 优化** - 专门为 DeepSeek 模型性能调优。 @@ -47,24 +69,27 @@ Deep Code CLI 支持 agent skills,允许您扩展助手的能力: ## 斜杠命令与按键功能 -| 斜杠命令 | 操作 | -|-----------------|---------------------------------------------| -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/model` | 切换模型、思考模式和推理强度 | -| `/init` | 初始化 AGENTS.md 文件 | -| `/skills` | 列出可用 skills | -| `/mcp` | 查看 MCP 服务器状态和可用工具 | -| `/exit` | 退出(也可用连续 `Ctrl+D`) | - -| 按键 | 操作 | -|-----------------|---------------------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| 连续 `Ctrl+D` | 退出 | +| 斜杠命令 | 操作 | +|-------------|----------------------------------| +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/undo` | 将代码和/或对话恢复到之前的状态 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +|---------------|--------------------| +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | ## 支持的模型 @@ -85,7 +110,7 @@ Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 ### 怎样在任务完成后自动给 Slack 发消息? -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 ### 怎样启用联网搜索功能? @@ -97,6 +122,15 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 详细配置指南:[docs/mcp.md](docs/mcp.md) +### 如何配置 Deep Code 任务完成后发送通知? + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +详细配置指南:[docs/notify.md](docs/notify.md) + +### Deep Code 只支持 YOLO 模式吗? + +不是。Deep Code 内置了细粒度的权限控制机制,支持在 AI 助手执行 Shell 命令、读写文件、访问网络等操作前进行确认。你可以通过 `settings.json` 中的 `permissions` 字段按需配置每种权限范围的策略:始终允许、始终询问、或直接拒绝。详见 [docs/permission.md](docs/permission.md)。 ### 是否支持 Coding Plan? @@ -112,6 +146,7 @@ Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览 "thinkingEnabled": true } ``` + ## 贡献 欢迎贡献代码!以下是参与方式: @@ -124,14 +159,18 @@ cd deepcode-cli # 安装依赖 npm install -# 本地开发(类型检查 + lint + 格式检查 + 构建) -npm run build - # 运行测试 npm test -# 链接到全局(即本地全局安装) +# CLI本地开发(类型检查 + lint + 格式检查 + 构建) +npm run build + +# CLI链接到全局(即本地全局安装) npm link + +# VSCode插件本地开发 +npm run build:vscode + ``` - 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) @@ -152,3 +191,22 @@ npm link - 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) - 向我们提交反馈和建议 - 分享给你的朋友和同事 + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 diff --git a/README_cn.md b/README_cn.md deleted file mode 100644 index 69d28c82..00000000 --- a/README_cn.md +++ /dev/null @@ -1,154 +0,0 @@ -# Deep Code CLI - -[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 - -## 安装 - -```bash -npm install -g @vegamo/deepcode-cli -``` - -在任意项目目录下运行 `deepcode` 即可启动。 - -![intro2](resources/intro2.png) - -## 配置 - -创建 `~/.deepcode/settings.json` 文件,内容如下: - -```json -{ - "env": { - "MODEL": "deepseek-v4-pro", - "BASE_URL": "https://api.deepseek.com", - "API_KEY": "sk-..." - }, - "thinkingEnabled": true, - "reasoningEffort": "max" -} -``` - -配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 - -完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 - -## 主要功能 - -### **Skills** -Deep Code CLI 支持 agent skills,允许您扩展助手的能力: - -- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 -- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 - -### **为 DeepSeek 优化** -- 专门为 DeepSeek 模型性能调优。 -- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 -- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 - -## 斜杠命令与按键功能 - -| 斜杠命令 | 操作 | -|-----------------|---------------------------------------------| -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/model` | 切换模型、思考模式和推理强度 | -| `/init` | 初始化 AGENTS.md 文件 | -| `/skills` | 列出可用 skills | -| `/mcp` | 查看 MCP 服务器状态和可用工具 | -| `/exit` | 退出(也可用连续 `Ctrl+D`) | - -| 按键 | 操作 | -|-----------------|---------------------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| 连续 `Ctrl+D` | 退出 | - -## 支持的模型 - -- `deepseek-v4-pro`(推荐使用) -- `deepseek-v4-flash` -- 任何其他 OpenAI 兼容模型 - - -## 常见问题 - -### Deep Code 是否有 VSCode 插件? - -有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 - -### Deep Code 是否支持理解图片? - -Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 deepseek-v4 不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的 Doubao-Seed-2.0-pro 模型,适配效果最好。 - -### 怎样在任务完成后自动给 Slack 发消息? - -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g - -### 怎样启用联网搜索功能? - -Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli - -### 如何配置 MCP? - -Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 - -详细配置指南:[docs/mcp.md](docs/mcp.md) - - -### 是否支持 Coding Plan? - -支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: - -```json -{ - "env": { - "MODEL": "ark-code-latest", - "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", - "API_KEY": "**************" - }, - "thinkingEnabled": true -} -``` -## 贡献 - -欢迎贡献代码!以下是参与方式: - -```bash -# 克隆仓库 -git clone https://github.com/lessweb/deepcode-cli.git -cd deepcode-cli - -# 安装依赖 -npm install - -# 本地开发(类型检查 + lint + 格式检查 + 构建) -npm run build - -# 运行测试 -npm test - -# 链接到全局(即本地全局安装) -npm link -``` - -- 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) -- 建议在执行构建前,先执行 `npm run format` 自动格式化代码,避免构建报错 - -## 获取帮助 - -- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) - -## 协议 - -- MIT - -## 支持我们 - -如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: - -- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) -- 向我们提交反馈和建议 -- 分享给你的朋友和同事 diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..b6479f30 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,291 @@ +# 版本发布 + +Deep Code 使用三个脚本管理 monorepo 的版本发布流程: + +| 脚本 | 命令 | 用途 | +|------|------|------| +| `scripts/version.js` | `npm run release:version` | 升级所有 workspace 包的版本号 + 重新生成 lockfile | +| `scripts/prepare-package.js` | `npm run prepare:package` | 构建 CLI + 质量检查 + 发布到 npm + git commit & tag | +| `scripts/prepare-vscode.js` | `npm run prepare:vscode` | 构建 VSCode 扩展 + 质量检查 + 发布到 VS Code 市场 + git commit & tag | + +发布流程:先升版本号,再分别发布 CLI 和 VSCode 扩展。 + +--- + +## release:version — 版本号升级 + +用法与 `npm version` 一致,支持所有标准 bump 类型。 + +### 基本用法 + +```bash +npm run release:version -- [options] +``` + +> 注意:npm scripts 传参需要 `--` 分隔符。 + +### 支持的 bump 类型 + +| 类型 | 当前版本 | 结果 | 说明 | +|------|---------|------|------| +| `patch` | `0.1.31` | `0.1.32` | 补丁版本 +1 | +| `minor` | `0.1.31` | `0.2.0` | 次版本 +1,patch 归零 | +| `major` | `0.1.31` | `1.0.0` | 主版本 +1,minor/patch 归零 | +| `prepatch` | `0.1.31` | `0.1.32-0` | 预发布补丁 | +| `preminor` | `0.1.31` | `0.2.0-0` | 预发布次版本 | +| `premajor` | `0.1.31` | `1.0.0-0` | 预发布主版本 | +| `prerelease` | `0.1.31` | `0.1.32-0` | 递增预发布号 | +| `from-git` | — | 从最新 git tag 读取 | 适用于已有 tag 但未更新 package.json 的情况 | + +也可以直接指定版本号: + +```bash +npm run release:version -- 0.2.0 +``` + +### 预发布链 + +`prerelease` 支持链式递增: + +``` +0.1.31 + → prerelease → 0.1.32-beta.0 + → prerelease → 0.1.32-beta.1 + → prerelease → 0.1.32-beta.2 + → patch → 0.1.32 (去掉 prerelease 后缀) +``` + +### --preid 选项 + +预发布标识符,默认为 `"0"`,可自定义: + +```bash +npm run release:version -- prerelease --preid beta +# 0.1.31 → 0.1.32-beta.0 + +npm run release:version -- premajor --preid alpha +# 0.1.31 → 1.0.0-alpha.0 +``` + +### 实际执行的操作 + +1. 读取 `packages/core/package.json` 中的当前版本 +2. 根据 bump 类型计算目标版本 +3. 更新 **所有** `packages/*/package.json` 的 `version` 字段(core、cli、vscode-ide-companion) +4. 删除旧的 `package-lock.json`,执行 `npm install --package-lock-only` 重新生成 + +### 完整示例 + +```bash +# 升级 patch 版本 +npm run release:version -- patch + +# 升级 minor 版本 +npm run release:version -- minor + +# 发布 beta 预发布版 +npm run release:version -- prerelease --preid beta + +# 直接指定版本 +npm run release:version -- 0.2.0 + +# 从 git tag 获取版本 +npm run release:version -- from-git +``` + +升级后检查变更,确认无误后提交: + +```bash +git diff +git add -A +git commit -m "chore(release): v0.1.32" +git tag v0.1.32 +``` + +--- + +## prepare:package — 构建并发布到 npm + +完成质量检查、构建、发布 CLI 到 npm,并自动创建 git commit 和 tag。 + +### 基本用法 + +```bash +npm run prepare:package -- [options] +``` + +### 参数 + +| 参数 | 说明 | +|------|------| +| `` | **必填**,要发布的 semver 版本号 | +| `--tag ` | npm dist-tag,默认 `"latest`",常用于 `beta`、`next` | +| `--dry-run` | 预演模式,不实际执行任何写操作 | +| `--force` | 跳过 main 分支检查,允许从其他分支发布 | + +### 执行流程(8 步) + +| 步骤 | 操作 | 说明 | +|------|------|------| +| 1 | Git 检查 | 工作区必须 clean,必须在 main 分支(`--force` 可跳过分支检查) | +| 2 | npm 认证 | 检查 `npm whoami`,未登录则中止 | +| 3 | 更新版本号 | 同时更新 `packages/core` 和 `packages/cli` 的 version | +| 4 | 质量检查 | `npm run check`(typecheck + eslint + prettier) | +| 5 | 测试 | `npm run test --workspaces` | +| 6 | 构建 | `npm run build`(core tsc + esbuild 将 core 及所有依赖内联到 `dist/cli.js`) | +| 7 | 发布 CLI | 往 `dist/` 写入 `dependencies: {}` 的 package.json,从 `dist/` 目录执行 `npm publish` | +| 8 | Git commit & tag | `chore(release): v` + `git tag v` | + +### 完整示例 + +```bash +# 发布正式版 +npm run prepare:package -- 0.1.32 + +# 发布 beta 版 +npm run prepare:package -- 0.1.32-beta.1 --tag beta + +# 预演(不实际发布,用于检查流程) +npm run prepare:package -- 0.1.32 --dry-run + +# 从非 main 分支发布 +npm run prepare:package -- 0.1.32 --force +``` + +### 关于 Core 打包策略 + +CLI 的 `package.json` 中保留 `"@vegamo/deepcode-core": "file:../core"` 用于本地开发(IDE 类型检查、monorepo 工作区解析)。构建时 esbuild 使用 `packages: "bundle"` 将 core 的全部代码及其运行时依赖(`openai`、`ejs`、`zod` 等)内联到单个 `dist/cli.js` 文件中。发布时脚本往 `dist/` 写入 `dependencies: {}` 的 `package.json`,从 `dist/` 目录发布,因此发布的 CLI 包零运行时依赖。`@vegamo/deepcode-core` 不再作为独立 npm 包发布。 + +### 发布后 + +脚本完成后会提示手动推送到 remote: + +```bash +git push && git push --tags +``` + +验证发布结果: + +```bash +npm view @vegamo/deepcode-cli version +npx @vegamo/deepcode-cli --version +``` + +--- + +## prepare:vscode — 构建并发布 VSCode 扩展到市场 + +完成质量检查、构建、发布 VSCode 扩展到 VS Code Marketplace,并自动创建 git commit 和 tag。 + +### 前置条件 + +需要 Azure DevOps Personal Access Token(PAT)用于市场认证: + +1. 访问 https://dev.azure.com/vegamo/_usersSettings/tokens 生成 token +2. 设置环境变量 `VSCE_PAT=` + +### 基本用法 + +```bash +VSCE_PAT= npm run prepare:vscode -- [options] +``` + +### 参数 + +| 参数 | 说明 | +|------|------| +| `` | **必填**,要发布的 semver 版本号 | +| `--dry-run` | 预演模式,不实际执行任何写操作 | +| `--force` | 跳过 main 分支检查,允许从其他分支发布 | + +### 执行流程(7 步) + +| 步骤 | 操作 | 说明 | +|------|------|------| +| 1 | Git 检查 | 工作区必须 clean,必须在 main 分支 | +| 2 | VSCE_PAT 检查 | 环境变量必须已设置 | +| 3 | 更新版本号 | 同时更新 `packages/core`、`packages/cli`、`packages/vscode-ide-companion` 的 version | +| 4 | 质量检查 | `npm run check`(typecheck + eslint + prettier) | +| 5 | 测试 | `npm run test --workspaces` | +| 6 | 构建 | `npm run build:vscode`(core tsc + esbuild 打包扩展 + 拷贝模板 + vsce package) | +| 7 | 发布 | `vsce publish --no-dependencies` 发布到 VS Code Marketplace | + +### 完整示例 + +```bash +# 发布正式版 +VSCE_PAT=xxx npm run prepare:vscode -- 0.1.32 + +# 发布预发布版 +VSCE_PAT=xxx npm run prepare:vscode -- 0.1.32-beta.1 + +# 预演(不实际发布) +npm run prepare:vscode -- 0.1.32 --dry-run +``` + +--- + +## 典型发布流程 + +一个完整的版本发布通常按以下步骤进行: + +```bash +# 1. 确保工作区干净 +git status + +# 2. 升级版本号 +npm run release:version -- patch + +# 3. 检查变更 +git diff + +# 4. 提交版本变更 +git add -A +git commit -m "chore(release): v0.1.32" + +# 5. 构建 + 质量检查 + 发布 CLI +npm run prepare:package -- 0.1.32 + +# 6. 发布 VSCode 扩展 +VSCE_PAT=xxx npm run prepare:vscode -- 0.1.32 + +# 7. 推送到 remote +git push && git push --tags +``` + +也可以简化为三步(`prepare:package` 和 `prepare:vscode` 各自自动 commit 和 tag): + +```bash +npm run release:version -- patch +npm run prepare:package -- 0.1.32 +VSCE_PAT=xxx npm run prepare:vscode -- 0.1.32 +git push && git push --tags +``` + +--- + +## 预发布版本流程 + +```bash +# 第一个 beta +npm run release:version -- prerelease --preid beta +# → 0.1.32-beta.0 + +git add -A && git commit -m "chore(release): v0.1.32-beta.0" +npm run prepare:package -- 0.1.32-beta.0 --tag beta + +# 后续 beta +npm run release:version -- prerelease --preid beta +# → 0.1.32-beta.1 + +git add -A && git commit -m "chore(release): v0.1.32-beta.1" +npm run prepare:package -- 0.1.32-beta.1 --tag beta + +# 正式发布 +npm run release:version -- patch +# → 0.1.32 + +git add -A && git commit -m "chore(release): v0.1.32" +npm run prepare:package -- 0.1.32 +git push && git push --tags +``` diff --git a/RELEASE_en.md b/RELEASE_en.md new file mode 100644 index 00000000..2f0fbcca --- /dev/null +++ b/RELEASE_en.md @@ -0,0 +1,291 @@ +# Release + +Deep Code uses three scripts to manage version releases in the monorepo: + +| Script | Command | Purpose | +|--------|---------|---------| +| `scripts/version.js` | `npm run release:version` | Bump all workspace package versions + regenerate lockfile | +| `scripts/prepare-package.js` | `npm run prepare:package` | Build CLI + quality checks + publish to npm + git commit & tag | +| `scripts/prepare-vscode.js` | `npm run prepare:vscode` | Build VSCode extension + quality checks + publish to VS Code Marketplace + git commit & tag | + +Release flow: bump version first, then publish CLI and VSCode extension separately. + +--- + +## release:version — Version Bump + +Works like `npm version`, supporting all standard bump types. + +### Basic Usage + +```bash +npm run release:version -- [options] +``` + +> Note: npm scripts require the `--` separator to pass arguments. + +### Supported Bump Types + +| Type | Current | Result | Description | +|------|---------|--------|-------------| +| `patch` | `0.1.31` | `0.1.32` | Patch version +1 | +| `minor` | `0.1.31` | `0.2.0` | Minor version +1, patch reset | +| `major` | `0.1.31` | `1.0.0` | Major version +1, minor/patch reset | +| `prepatch` | `0.1.31` | `0.1.32-0` | Pre-release patch | +| `preminor` | `0.1.31` | `0.2.0-0` | Pre-release minor | +| `premajor` | `0.1.31` | `1.0.0-0` | Pre-release major | +| `prerelease` | `0.1.31` | `0.1.32-0` | Increment pre-release number | +| `from-git` | — | Read from latest git tag | For cases where tag exists but package.json not updated | + +You can also specify an exact version: + +```bash +npm run release:version -- 0.2.0 +``` + +### Pre-release Chain + +`prerelease` supports chained increments: + +``` +0.1.31 + → prerelease → 0.1.32-beta.0 + → prerelease → 0.1.32-beta.1 + → prerelease → 0.1.32-beta.2 + → patch → 0.1.32 (drops prerelease suffix) +``` + +### --preid Option + +Pre-release identifier, defaults to `"0"`, customizable: + +```bash +npm run release:version -- prerelease --preid beta +# 0.1.31 → 0.1.32-beta.0 + +npm run release:version -- premajor --preid alpha +# 0.1.31 → 1.0.0-alpha.0 +``` + +### What It Does + +1. Reads current version from `packages/core/package.json` +2. Calculates target version based on bump type +3. Updates `version` field in **all** `packages/*/package.json` (core, cli, vscode-ide-companion) +4. Deletes old `package-lock.json` and regenerates via `npm install --package-lock-only` + +### Examples + +```bash +# Bump patch +npm run release:version -- patch + +# Bump minor +npm run release:version -- minor + +# Beta pre-release +npm run release:version -- prerelease --preid beta + +# Exact version +npm run release:version -- 0.2.0 + +# From git tag +npm run release:version -- from-git +``` + +After bumping, review changes and commit: + +```bash +git diff +git add -A +git commit -m "chore(release): v0.1.32" +git tag v0.1.32 +``` + +--- + +## prepare:package — Build and Publish to npm + +Runs quality checks, builds, publishes the CLI to npm, and automatically creates a git commit with tag. + +### Basic Usage + +```bash +npm run prepare:package -- [options] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `` | **Required**. Semver version to publish | +| `--tag ` | npm dist-tag, default `"latest"`, commonly `beta` or `next` | +| `--dry-run` | Preview mode, no actual writes | +| `--force` | Skip main branch check, allow publishing from other branches | + +### Execution Flow (8 Steps) + +| Step | Action | Description | +|------|--------|-------------| +| 1 | Git check | Working tree must be clean, must be on main branch (`--force` skips branch check) | +| 2 | npm auth | Checks `npm whoami`, aborts if not logged in | +| 3 | Update versions | Updates `packages/core` and `packages/cli` version fields | +| 4 | Quality checks | `npm run check` (typecheck + eslint + prettier) | +| 5 | Tests | `npm run test --workspaces` | +| 6 | Build | `npm run build` (core tsc + esbuild inlines core and all deps into `dist/cli.js`) | +| 7 | Publish CLI | Writes `dist/package.json` with `dependencies: {}`, runs `npm publish` from `dist/` | +| 8 | Git commit & tag | `chore(release): v` + `git tag v` | + +### Examples + +```bash +# Publish stable release +npm run prepare:package -- 0.1.32 + +# Publish beta +npm run prepare:package -- 0.1.32-beta.1 --tag beta + +# Dry run (no actual publish) +npm run prepare:package -- 0.1.32 --dry-run + +# Publish from non-main branch +npm run prepare:package -- 0.1.32 --force +``` + +### About the Core Bundling Strategy + +The CLI's `package.json` keeps `"@vegamo/deepcode-core": "file:../core"` for local development (IDE type checking, monorepo workspace resolution). At build time, esbuild uses `packages: "bundle"` to inline all of core's code and its runtime dependencies (`openai`, `ejs`, `zod`, etc.) into a single `dist/cli.js` file. At publish time, the script writes a `dist/package.json` with `dependencies: {}` and publishes from the `dist/` directory, so the published CLI package has zero runtime dependencies. `@vegamo/deepcode-core` is no longer published as a separate npm package. + +### After Publishing + +The script prompts you to push to remote: + +```bash +git push && git push --tags +``` + +Verify the release: + +```bash +npm view @vegamo/deepcode-cli version +npx @vegamo/deepcode-cli --version +``` + +--- + +## prepare:vscode — Build and Publish VSCode Extension to Marketplace + +Runs quality checks, builds, publishes the VSCode extension to the VS Code Marketplace, and automatically creates a git commit with tag. + +### Prerequisites + +Requires an Azure DevOps Personal Access Token (PAT) for marketplace authentication: + +1. Generate a token at https://dev.azure.com/vegamo/_usersSettings/tokens +2. Set the environment variable `VSCE_PAT=` + +### Basic Usage + +```bash +VSCE_PAT= npm run prepare:vscode -- [options] +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `` | **Required**. Semver version to publish | +| `--dry-run` | Preview mode, no actual writes | +| `--force` | Skip main branch check, allow publishing from other branches | + +### Execution Flow (7 Steps) + +| Step | Action | Description | +|------|--------|-------------| +| 1 | Git check | Working tree must be clean, must be on main branch | +| 2 | VSCE_PAT check | Environment variable must be set | +| 3 | Update versions | Updates `packages/core`, `packages/cli`, and `packages/vscode-ide-companion` version fields | +| 4 | Quality checks | `npm run check` (typecheck + eslint + prettier) | +| 5 | Tests | `npm run test --workspaces` | +| 6 | Build | `npm run build:vscode` (core tsc + esbuild bundle extension + copy templates + vsce package) | +| 7 | Publish | `vsce publish --no-dependencies` to VS Code Marketplace | + +### Examples + +```bash +# Publish stable release +VSCE_PAT=xxx npm run prepare:vscode -- 0.1.32 + +# Publish pre-release +VSCE_PAT=xxx npm run prepare:vscode -- 0.1.32-beta.1 + +# Dry run (no actual publish) +npm run prepare:vscode -- 0.1.32 --dry-run +``` + +--- + +## Typical Release Flow + +A complete version release follows these steps: + +```bash +# 1. Ensure clean working tree +git status + +# 2. Bump version +npm run release:version -- patch + +# 3. Review changes +git diff + +# 4. Commit version change +git add -A +git commit -m "chore(release): v0.1.32" + +# 5. Build + quality check + publish CLI +npm run prepare:package -- 0.1.32 + +# 6. Publish VSCode extension +VSCE_PAT=xxx npm run prepare:vscode -- 0.1.32 + +# 7. Push to remote +git push && git push --tags +``` + +Or simplified to three steps (`prepare:package` and `prepare:vscode` each auto-commit and tag): + +```bash +npm run release:version -- patch +npm run prepare:package -- 0.1.32 +VSCE_PAT=xxx npm run prepare:vscode -- 0.1.32 +git push && git push --tags +``` + +--- + +## Pre-release Flow + +```bash +# First beta +npm run release:version -- prerelease --preid beta +# → 0.1.32-beta.0 + +git add -A && git commit -m "chore(release): v0.1.32-beta.0" +npm run prepare:package -- 0.1.32-beta.0 --tag beta + +# Subsequent betas +npm run release:version -- prerelease --preid beta +# → 0.1.32-beta.1 + +git add -A && git commit -m "chore(release): v0.1.32-beta.1" +npm run prepare:package -- 0.1.32-beta.1 --tag beta + +# Stable release +npm run release:version -- patch +# → 0.1.32 + +git add -A && git commit -m "chore(release): v0.1.32" +npm run prepare:package -- 0.1.32 +git push && git push --tags +``` diff --git a/docs/SKILL_new.md b/docs/SKILL_new.md deleted file mode 100644 index 9fc8bd2d..00000000 --- a/docs/SKILL_new.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: plan-and-execute -description: Automatically plan and execute requirements. Creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with task planning or when you need to break down and execute complex multi-step requirements. ---- - -# Plan and Execute - -This Skill helps you automatically plan and execute requirements. It creates a structured markdown task list with the UpdatePlan tool and systematically works through each task while keeping progress visible. - -## Quick Start - -When you need to work through a multi-step request: - -1. Analyze the requirements and explore enough project context -2. Clarify unclear or ambiguous requirements with AskUserQuestion -3. Create a markdown task list by calling the UpdatePlan tool -4. Execute tasks one by one, updating the tool plan in real time -5. Revise the remaining plan as new context appears - -## Instructions - -### Step 1: Analyze the requirements - -Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. - -If the original requirements are unclear, incomplete, or ambiguous, call the AskUserQuestion tool before creating the task list. Ask only the questions needed to avoid implementing the wrong behavior, and keep each question specific to the decision that affects the plan or acceptance criteria. - -If a required referenced file path is missing, ask for it with AskUserQuestion: - -``` -What is the path to the referenced file? -``` - -Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. - -- What are the main requirements? -- What tasks need to be completed? -- Are there dependencies between tasks? -- What is the complexity level? -- Which files, modules, commands, or tests are relevant? -- What ambiguity would change the implementation or acceptance criteria? - -### Step 2: Create the task list - -Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: - -```json -{ - "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description" -} -``` - -Use this markdown format for the `plan` content: - -```markdown -## Task List - -- [ ] Task 1 description -- [ ] Task 2 description -- [ ] Task 3 description -``` - -Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. - -### Step 3: Execute tasks systematically - -For each task in the list: - -1. **Refresh the plan**: Before starting the first task and after completing each task, re-evaluate the latest conversation and project context. Update the remaining tasks when scope, order, blockers, or follow-up work changes. -2. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` -3. **Execute the task**: Use appropriate tools to complete the work -4. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished -5. **Move to next task**: Only ONE task should be in progress at a time - -Important rules: -- Always keep the plan aligned with the latest context before executing the next task -- Always call UpdatePlan BEFORE starting work on a task -- Always call UpdatePlan IMMEDIATELY after completing a task -- Always pass the complete current markdown task list, not a partial diff -- Never work on multiple tasks simultaneously -- Remove tasks that are no longer relevant, and add newly discovered tasks before working on them -- If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers - -### Step 4: Handle task breakdown - -If during execution you discover a task is more complex than expected: - -1. Keep the current task as `[>]` -2. Call UpdatePlan with new sub-tasks below it with indentation: - ```markdown - - [>] Main task - - [ ] Sub-task 1 - - [ ] Sub-task 2 - ``` -3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan - -### Step 5: Final verification - -After all tasks are completed (`[x]`): - -1. Review the original requirements to ensure everything is addressed -2. Run any final checks (tests, builds, linting) -3. Call UpdatePlan with every task marked `[x]` -4. Provide a concise completion summary in the final response - -## Task State Symbols - -- `[ ]` - Pending -- `[>]` - In progress -- `[x]` - Completed -- `[!]` - Blocked - -## Examples - -### Example 1: Simple feature request - -**Example requirements:** -```markdown -# 新功能:添加深色模式切换 - -用户应该能够在浅色和深色主题之间切换。 -切换开关应放在设置页面中。 -``` - -**分析后的 UpdatePlan 调用:** -```markdown -## Task List - -- [ ] 在设置页面创建深色模式切换组件 -- [ ] 添加深色模式状态管理(context/store) -- [ ] 实现深色主题的 CSS-in-JS 样式 -- [ ] 更新现有组件以支持主题切换 -- [ ] 运行测试并验证功能 -``` - -**UpdatePlan call during execution:** -```markdown -## Task List - -- [x] 在设置页面创建深色模式切换组件 -- [>] 添加深色模式状态管理(context/store) -- [ ] 实现深色主题的 CSS-in-JS 样式 -- [ ] 更新现有组件以支持主题切换 -- [ ] 运行测试并验证功能 -``` - -### Example 2: Bug fix with investigation - -**Example requirements:** -```markdown -# Fix bug:登录表单提交时崩溃 - -当用户点击提交时,应用崩溃。 -错误信息:"Cannot read property 'email' of undefined" -``` - -**UpdatePlan call after analysis:** -```markdown -## Task List - -- [ ] 在本地复现缺陷 -- [ ] 调查登录表单组件中的错误 -- [ ] 定位 undefined email 属性的根本原因 -- [ ] 实施修复 -- [ ] 添加验证以防止类似问题 -- [ ] 使用各种输入测试修复 -- [ ] 更新错误处理 -``` - -## When to Use This Skill - -Use this Skill when: - -1. **Complex multi-step tasks** - Request requires 3+ distinct steps -2. **Feature implementation** - Building new functionality from requirements -3. **Bug fixing** - Need to investigate, fix, and verify -4. **Refactoring** - Multiple files or components need changes -5. **Detailed requirements** - Specifications need to be translated into concrete tasks -6. **Need progress tracking** - Want visible progress without editing source files - -## When NOT to Use This Skill - -Skip this Skill when: - -1. **Single simple task** - Just one straightforward action needed -2. **Trivial changes** - Quick fixes that don't need planning -3. **Informational requests** - User just wants explanation, not execution -4. **No execution requested** - User only wants brainstorming or a high-level explanation - -## Best Practices - -1. **Be specific with tasks**: "Add login button to navbar" not "Update UI" -2. **Keep tasks atomic**: Each task should be independently completable -3. **Update immediately**: Don't batch status updates, do them in real-time -4. **One task at a time**: Never mark multiple tasks as `[>]` -5. **Handle blockers**: If stuck, create new tasks to resolve the blocker -6. **Verify completion**: Only mark `[x]` when task is fully done - -## Advanced Usage - -### Handling dependencies - -When tasks have dependencies, order them properly: - -```markdown -- [ ] Create database schema -- [ ] Implement API endpoints (depends on schema) -- [ ] Build frontend forms (depends on API) -``` - -### Using sub-tasks - -For complex tasks, break them down: - -```markdown -- [>] Implement authentication system - - [x] Set up JWT library - - [>] Create login endpoint - - [ ] Create logout endpoint - - [ ] Add token refresh logic -``` - -### Adding notes - -Add implementation notes or findings: - -```markdown -- [x] Investigate performance issue - - Note: Found N+1 query in user loader - - Solution: Added dataloader batching -``` - -## Workflow Summary - -1. Analyze the requirements and relevant project context -2. Call AskUserQuestion if the original requirements are unclear or ambiguous -3. Call UpdatePlan with the structured markdown task list -4. Refresh the remaining plan before the first task -5. For each task: - - Update to `[>]` with UpdatePlan - - Execute the task - - Update to `[x]` with UpdatePlan - - Re-evaluate and revise remaining tasks before moving on -6. Call UpdatePlan with all tasks completed and summarize the result - -This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. diff --git a/docs/agent-skills.md b/docs/agent-skills.md new file mode 100644 index 00000000..9ba77ab5 --- /dev/null +++ b/docs/agent-skills.md @@ -0,0 +1,322 @@ +# Agent Skills + +## 概述 + +适合写成 skill 的内容通常具备以下特点: + +- 会重复使用,例如固定的代码审查流程、发布流程或文档生成流程 +- 需要较长的说明,不适合每次都粘贴到对话中 +- 需要配套资源,例如模板、脚本、schema、示例或参考文档 +- 需要明确触发条件,例如“处理 PDF 表单”或“为本项目生成数据库迁移” + +不适合写成 skill 的内容: + +- 一次性的任务要求 +- 当前仓库的短规则,此类内容更适合写入 `AGENTS.md` +- 需要实时连接外部系统的能力,此类能力更适合通过 MCP 提供工具 + +## 扫描位置 + +Deep Code CLI 会按以下顺序扫描 skills。相同 `name` 的 skill 只保留优先级最高的一个。 + +| 优先级 | Scope | Path | 用途 | +| ------ | ------- | --------------------- | ---- | +| 1 | Project | `./.deepcode/skills/` | Deep Code 项目级原生位置 | +| 2 | Project | `./.agents/skills/` | 项目级跨客户端互操作位置 | +| 3 | User | `~/.deepcode/skills/` | Deep Code 用户级原生位置 | +| 4 | User | `~/.agents/skills/` | 用户级跨客户端互操作位置 | +| 5 | Global | `built-in` | Deep Code 内置 skills | + +目录结构示例: + +```text +.deepcode/ +└── skills/ + └── code-review/ + ├── SKILL.md + ├── checklist.md + └── scripts/ + └── collect-diff.sh +``` + +## 最小 Skill + +每个 skill 必须放在独立目录中,并包含 `SKILL.md`。 + +```markdown +--- +name: code-review +description: Review code changes for correctness, regressions, security risks, and missing tests. Use when the user asks for a review, PR review, diff review, or pre-merge check. +--- + +# Code Review + +Use a code review mindset. Prioritize bugs, behavioral regressions, security issues, +and missing tests over style comments. + +## Workflow + +1. Inspect the diff and relevant surrounding code. +2. List findings first, ordered by severity. +3. Include file and line references for every finding. +4. If there are no findings, say so and mention residual risks or test gaps. +``` + +## `SKILL.md` Frontmatter + +Deep Code CLI reads the YAML frontmatter at the top of `SKILL.md`. + +| 字段 | 必填 | Deep Code 行为 | 建议 | +| ---- | ---- | -------------- | ---- | +| `name` | 建议必填 | 作为 skill 的唯一名称。缺失时使用目录名,并把 `_` 转成 `-`。 | 使用小写字母、数字和连字符。保持与目录名一致。 | +| `description` | 建议必填 | 用于自动匹配任务,也显示在 `/skills` 和斜杠菜单中。 | 写清楚 skill 做什么、何时使用、常见触发词。 | +| `metadata.allow-implicit-invocation` | 可选 | 设置为 `false` 时,不参与自动匹配;仍可手动选择。 | 用于只想手动调用的 skill。 | + +示例: + +```yaml +--- +name: db-migration +description: Create and review database migrations for this project. Use when the user asks to add columns, change schema, write migrations, or validate rollback behavior. +metadata: + allow-implicit-invocation: false +--- +``` + +> Deep Code CLI 当前只解释上表中的字段。其他 frontmatter 字段可用于跨客户端互操作或文档说明,但不会自动限制 Deep Code 的工具权限。 + +## 写好 `description` + +`description` 是最重要的发现信号。Deep Code 会在自动匹配阶段只把 skill 的 `name` 和 `description` 交给模型判断,因此描述越具体,匹配越可靠。 + +推荐结构: + +```text +<这个 skill 做什么>. Use when <任务类型、文件类型、领域、用户常见说法或触发词>. +``` + +好的示例: + +```yaml +description: Extract tables from PDF files, fill PDF forms, and merge documents. Use when working with PDFs, forms, invoices, statements, or document extraction. +``` + +```yaml +description: Generate Lessweb routes, services, and Pydantic request models. Use when editing Lessweb projects, adding @Get/@Post endpoints, configuring IOC modules, or updating OpenAPI output. +``` + +避免: + +```yaml +description: Helps with documents +description: Useful project skill +description: Tooling instructions +``` + +检查清单: + +- 是否说明了具体能力,而不是只写主题名 +- 是否说明了何时使用,而不是只写结果 +- 是否包含用户可能输入的关键词 +- 是否包含相关文件类型、框架名、命令名或领域名 +- 是否避免覆盖过宽,导致无关任务也触发 + +## Skill 正文结构 + +`SKILL.md` 的正文应面向 agent,而不是面向普通读者。写法要直接、可执行、可验证。 + +推荐结构: + +```markdown +# Skill Name + +Briefly state what this skill is for. + +## When to use + +- Use when ... +- Do not use when ... + +## Workflow + +1. Read ... +2. Run ... +3. Edit ... +4. Verify ... + +## Rules + +- Preserve ... +- Never ... +- Ask the user when ... + +## Examples + +... +``` + +写作原则: + +- 使用命令式步骤,例如“Read the schema first”或“Run tests after editing” +- 把必须遵守的约束写成明确规则 +- 对高风险操作写清楚边界,例如删除文件、迁移数据、发送请求 +- 对常见分支写出决策规则,例如“如果没有配置文件,先搜索默认路径” +- 避免把大量参考资料全部塞进 `SKILL.md` + +## 附加资源 + +一个 skill 可以包含 `SKILL.md` 之外的文件: + +```text +my-skill/ +├── SKILL.md +├── references/ +│ └── api.md +├── examples/ +│ └── request.json +├── scripts/ +│ └── validate.py +└── templates/ + └── report.md +``` + +如果某些内容会让 `SKILL.md` 过长或难以阅读,可以放到附加文件中: + +- `references/` 放长文档、规范、API 说明 +- `examples/` 放输入输出样例 +- `scripts/` 放可复用脚本 +- `templates/` 放文档或代码模板 +- 在 `SKILL.md` 中说明什么时候需要使用这些附加文件 + +示例: + +```markdown +## Workflow + +1. Read `references/schema.md` before changing generated types. +2. Use `templates/migration.sql` when creating a new migration. +3. Run `python scripts/check_migration.py ` before reporting completion. +``` + +## 调用方式 + +Deep Code CLI 支持自动和手动两种调用方式。 + +### 自动调用 + +每次用户输入后,Deep Code 会根据可用 skills 的 `name` 和 `description` 判断哪些 skill 与任务匹配。匹配到的 skill 会被加载到当前会话中。 + +自动调用规则: + +- 已加载的 skill 不会在同一会话中重复加载 +- `metadata.allow-implicit-invocation: false` 的 skill 不会自动加载 +- 自动匹配会结合当前 `AGENTS.md` 指令 +- 如果没有匹配项,则不加载 skill + +### 手动调用 + +你可以在输入框中使用 `/` 打开 skills / 命令菜单,选择某个 skill;也可以使用 `/skills` 查看可用 skills。 + +常用命令: + +| 命令 | 作用 | +| ---- | ---- | +| `/` | 打开 skills / 命令菜单 | +| `/skills` | 列出可用 skills | +| `/` | 从菜单中选择对应 skill | + +## 启用和禁用 + +使用 `settings.json` 的 `enabledSkills` 可以按 skill 名称启用或禁用 skill。 + +```json +{ + "enabledSkills": { + "code-review": true, + "db-migration": false + } +} +``` + +规则: + +- 未配置的 skill 默认启用 +- 设置为 `false` 会隐藏所有扫描位置中同名的 skill +- 项目设置会按 skill 覆盖用户设置 + +更多配置说明请参考 [configuration.md](configuration.md)。 + +## 与 `AGENTS.md`、MCP 的区别 + +| 机制 | 适合放什么 | 不适合放什么 | +| ---- | ---------- | ------------ | +| `AGENTS.md` | 当前仓库的长期规则、代码风格、测试命令、协作约定 | 可复用的复杂工作流或跨项目工具说明 | +| Agent Skill | 可复用工作流、领域知识、模板、脚本、参考资料 | 只对当前一次任务生效的临时要求 | +| MCP | 外部系统能力、实时数据、浏览器、数据库、GitHub 等工具调用 | 纯文本流程说明 | + +常见组合: + +- 把项目规则写进 `AGENTS.md` +- 把可复用流程写成 skill +- 把需要执行外部动作的能力接入 MCP + +## 编写示例:项目发布 Skill + +```markdown +--- +name: release-check +description: Prepare and verify a project release. Use when the user asks to release, publish, bump version, update changelog, or run pre-release checks. +--- + +# Release Check + +Use this skill to prepare a safe release for this repository. + +## Workflow + +1. Read `package.json` and the existing changelog. +2. Inspect commits or diffs since the previous release tag. +3. Update version and changelog only when the user explicitly asks. +4. Run the project test and build commands. +5. Report the version, changed files, verification results, and remaining risks. + +## Rules + +- Do not publish packages unless the user explicitly asks. +- Do not create or push git tags without explicit approval. +- Preserve existing changelog style. +``` + +## 故障排查 + +### `/skills` 中看不到 skill + +检查: + +1. 目录是否位于 Deep Code 扫描位置之一 +2. 文件名是否为 `SKILL.md` +3. `SKILL.md` 是否在独立 skill 目录中,例如 `.deepcode/skills/my-skill/SKILL.md` +4. `enabledSkills` 是否把该 skill 设置为 `false` +5. 是否存在同名 skill 被更高优先级位置覆盖 + +### 自动调用不稳定 + +检查: + +1. `description` 是否包含清晰的使用场景和触发词 +2. skill 是否过宽,导致模型难以判断边界 +3. 是否设置了 `metadata.allow-implicit-invocation: false` +4. 用户请求是否需要更明确地提到该 skill 的领域或文件类型 + +### Skill 内容过长 + +建议: + +1. 保留 `SKILL.md` 中的核心流程和规则 +2. 把长文档移到 `references/` +3. 把重复命令移到 `scripts/` +4. 在 `SKILL.md` 中说明何时读取相关文件 + +## 参考 + +- [Agent Skills Specification](https://agentskills.io/specification) diff --git a/docs/agent-skills_en.md b/docs/agent-skills_en.md new file mode 100644 index 00000000..13d2ad7b --- /dev/null +++ b/docs/agent-skills_en.md @@ -0,0 +1,322 @@ +# Agent Skills + +## Overview + +A good skill is useful when the instruction set is: + +- Reused across tasks, such as code review, release preparation, or report generation +- Too long or detailed to paste into every prompt +- Backed by resources, such as templates, scripts, schemas, examples, or reference docs +- Triggered by a clear situation, such as "process a PDF form" or "create a database migration for this project" + +Do not use a skill for: + +- One-off task requirements +- Short repository rules, which usually belong in `AGENTS.md` +- Live external actions, which usually belong in MCP tools + +## Scan Locations + +Deep Code CLI scans skills in the following order. If multiple skills resolve to the same `name`, only the highest-priority one is kept. + +| Priority | Scope | Path | Purpose | +| -------- | ------- | --------------------- | ------- | +| 1 | Project | `./.deepcode/skills/` | Native Deep Code project skills | +| 2 | Project | `./.agents/skills/` | Project skills shared with other agent clients | +| 3 | User | `~/.deepcode/skills/` | Native Deep Code user skills | +| 4 | User | `~/.agents/skills/` | User skills shared with other agent clients | +| 5 | Global | `built-in` | Skills bundled with Deep Code | + +Example structure: + +```text +.deepcode/ +└── skills/ + └── code-review/ + ├── SKILL.md + ├── checklist.md + └── scripts/ + └── collect-diff.sh +``` + +## Minimal Skill + +Each skill must live in its own directory and contain `SKILL.md`. + +```markdown +--- +name: code-review +description: Review code changes for correctness, regressions, security risks, and missing tests. Use when the user asks for a review, PR review, diff review, or pre-merge check. +--- + +# Code Review + +Use a code review mindset. Prioritize bugs, behavioral regressions, security issues, +and missing tests over style comments. + +## Workflow + +1. Inspect the diff and relevant surrounding code. +2. List findings first, ordered by severity. +3. Include file and line references for every finding. +4. If there are no findings, say so and mention residual risks or test gaps. +``` + +## `SKILL.md` Frontmatter + +Deep Code CLI reads YAML frontmatter at the top of `SKILL.md`. + +| Field | Required | Deep Code behavior | Recommendation | +| ----- | -------- | ------------------ | -------------- | +| `name` | Recommended | Used as the unique skill name. If missing, Deep Code uses the directory name and converts `_` to `-`. | Use lowercase letters, numbers, and hyphens. Keep it aligned with the directory name. | +| `description` | Recommended | Used for automatic matching and shown in `/skills` and the slash menu. | Describe what the skill does, when to use it, and common trigger terms. | +| `metadata.allow-implicit-invocation` | Optional | When set to `false`, the skill is excluded from automatic matching but can still be selected manually. | Use for manual-only skills. | + +Example: + +```yaml +--- +name: db-migration +description: Create and review database migrations for this project. Use when the user asks to add columns, change schema, write migrations, or validate rollback behavior. +metadata: + allow-implicit-invocation: false +--- +``` + +> Deep Code CLI currently interprets only the fields listed above. Other frontmatter fields may be useful for cross-client compatibility or documentation, but they do not automatically restrict Deep Code tool permissions. + +## Write a Strong `description` + +The `description` is the most important discovery signal. During automatic matching, Deep Code gives the model only each skill's `name` and `description`, so specific descriptions match more reliably. + +Recommended pattern: + +```text +. Use when . +``` + +Good examples: + +```yaml +description: Extract tables from PDF files, fill PDF forms, and merge documents. Use when working with PDFs, forms, invoices, statements, or document extraction. +``` + +```yaml +description: Generate Lessweb routes, services, and Pydantic request models. Use when editing Lessweb projects, adding @Get/@Post endpoints, configuring IOC modules, or updating OpenAPI output. +``` + +Avoid: + +```yaml +description: Helps with documents +description: Useful project skill +description: Tooling instructions +``` + +Checklist: + +- State the concrete capability, not only the topic +- State when to use it, not only the expected result +- Include terms users are likely to type +- Include relevant file types, framework names, command names, or domain names +- Avoid overbroad wording that triggers on unrelated tasks + +## Skill Body Structure + +The body of `SKILL.md` should be written for an agent, not for a general reader. Keep it direct, actionable, and verifiable. + +Recommended structure: + +```markdown +# Skill Name + +Briefly state what this skill is for. + +## When to use + +- Use when ... +- Do not use when ... + +## Workflow + +1. Read ... +2. Run ... +3. Edit ... +4. Verify ... + +## Rules + +- Preserve ... +- Never ... +- Ask the user when ... + +## Examples + +... +``` + +Writing principles: + +- Use imperative steps, such as "Read the schema first" or "Run tests after editing" +- Write mandatory constraints as explicit rules +- Define boundaries for high-risk operations, such as deleting files, migrating data, or sending requests +- Document common branches, such as "if no config file exists, search the default paths first" +- Move long reference material out of `SKILL.md` + +## Supporting Resources + +A skill can include files next to `SKILL.md`: + +```text +my-skill/ +├── SKILL.md +├── references/ +│ └── api.md +├── examples/ +│ └── request.json +├── scripts/ +│ └── validate.py +└── templates/ + └── report.md +``` + +Use supporting files for material that would make `SKILL.md` too long or too hard to scan: + +- Put long docs, specs, and API notes in `references/` +- Put input and output samples in `examples/` +- Put reusable commands in `scripts/` +- Put document or code skeletons in `templates/` +- Explain in `SKILL.md` when each supporting file is relevant + +Example: + +```markdown +## Workflow + +1. Read `references/schema.md` before changing generated types. +2. Use `templates/migration.sql` when creating a new migration. +3. Run `python scripts/check_migration.py ` before reporting completion. +``` + +## Invocation + +Deep Code CLI supports automatic and manual skill invocation. + +### Automatic Invocation + +After each user message, Deep Code checks the available skills' `name` and `description` fields and selects the skills that match the task. Matching skills are loaded into the current session. + +Automatic invocation rules: + +- A loaded skill is not loaded again in the same session +- A skill with `metadata.allow-implicit-invocation: false` is not loaded automatically +- Matching considers the current `AGENTS.md` instructions +- If no skill matches, no skill is loaded + +### Manual Invocation + +Type `/` in the input box to open the skills and commands menu, then select a skill. Use `/skills` to list available skills. + +Common commands: + +| Command | Behavior | +| ------- | -------- | +| `/` | Open the skills and commands menu | +| `/skills` | List available skills | +| `/` | Select the matching skill from the menu | + +## Enable and Disable Skills + +Use `enabledSkills` in `settings.json` to enable or disable skills by name. + +```json +{ + "enabledSkills": { + "code-review": true, + "db-migration": false + } +} +``` + +Rules: + +- Skills not listed are enabled by default +- Setting a skill to `false` hides every scanned skill with that resolved name +- Project settings override user settings per skill + +For more details, see [configuration_en.md](configuration_en.md). + +## Skills vs. `AGENTS.md` vs. MCP + +| Mechanism | Best for | Not best for | +| --------- | -------- | ------------ | +| `AGENTS.md` | Long-lived repository rules, coding style, test commands, collaboration conventions | Reusable complex workflows or cross-project tool instructions | +| Agent Skill | Reusable workflows, domain knowledge, templates, scripts, reference docs | Temporary requirements for a single task | +| MCP | External systems, live data, browser control, databases, GitHub, and other tool calls | Pure text workflow instructions | + +Common pattern: + +- Put repository rules in `AGENTS.md` +- Put reusable workflows in skills +- Put external actions behind MCP tools + +## Example: Project Release Skill + +```markdown +--- +name: release-check +description: Prepare and verify a project release. Use when the user asks to release, publish, bump version, update changelog, or run pre-release checks. +--- + +# Release Check + +Use this skill to prepare a safe release for this repository. + +## Workflow + +1. Read `package.json` and the existing changelog. +2. Inspect commits or diffs since the previous release tag. +3. Update version and changelog only when the user explicitly asks. +4. Run the project test and build commands. +5. Report the version, changed files, verification results, and remaining risks. + +## Rules + +- Do not publish packages unless the user explicitly asks. +- Do not create or push git tags without explicit approval. +- Preserve existing changelog style. +``` + +## Troubleshooting + +### The skill does not appear in `/skills` + +Check: + +1. The directory is under one of the Deep Code scan locations +2. The file is named `SKILL.md` +3. `SKILL.md` is inside its own skill directory, such as `.deepcode/skills/my-skill/SKILL.md` +4. `enabledSkills` has not set the skill to `false` +5. A higher-priority skill with the same name is not shadowing it + +### Automatic invocation is unreliable + +Check: + +1. The `description` contains clear use cases and trigger terms +2. The skill is not so broad that the model cannot infer its boundary +3. `metadata.allow-implicit-invocation` is not set to `false` +4. The user request mentions the relevant domain or file type clearly enough + +### The skill is too long + +Recommendations: + +1. Keep the core workflow and rules in `SKILL.md` +2. Move long documentation into `references/` +3. Move repeatable commands into `scripts/` +4. Explain when the agent should read each supporting file + +## References + +- [Agent Skills Specification](https://agentskills.io/specification) diff --git a/docs/agents-md.md b/docs/agents-md.md new file mode 100644 index 00000000..9888e7c9 --- /dev/null +++ b/docs/agents-md.md @@ -0,0 +1,220 @@ +# AGENTS.md + +`AGENTS.md` 是写给 AI 编码助手的项目说明文件。它用于记录长期有效的项目规则,让 Deep Code 在这个仓库中工作时知道如何安装依赖、运行测试、修改代码、提交变更,以及遵守哪些团队约定。 + +如果你经常在提示词里重复说明“先运行哪个测试”“不要修改哪个目录”“PR 描述要包含什么”,就适合把这些内容写进 `AGENTS.md`。 + +## 适合写什么 + +`AGENTS.md` 适合保存当前项目的稳定规则: + +- 项目结构和重要目录 +- 安装、开发、构建、测试命令 +- 代码风格、命名约定、格式化要求 +- 测试要求和验证步骤 +- 提交、PR、发布流程 +- 安全、配置、凭据处理注意事项 +- 只对本仓库有效的 AI 协作规则 + +不适合写入 `AGENTS.md` 的内容: + +- 一次性任务要求,例如“这次只改登录页” +- 复杂可复用工作流,此类内容更适合写成 Agent Skill +- 外部系统连接信息,此类能力更适合通过 MCP 配置 +- API Key、密码、Token 等敏感信息 + +## 创建文件 + +在项目中运行: + +```text +/init +``` + +Deep Code 会帮助你创建或更新 `AGENTS.md`。你也可以手动创建: + +```bash +touch AGENTS.md +``` + +如果你希望把说明放在 Deep Code 专用目录中,也可以使用: + +```bash +mkdir -p .deepcode +touch .deepcode/AGENTS.md +``` + +常见选择: + +| 文件 | 适合场景 | +| ---- | -------- | +| `AGENTS.md` | 希望项目中的不同 AI 编码工具都能读取 | +| `.deepcode/AGENTS.md` | 希望规则只面向 Deep Code | +| `~/.deepcode/AGENTS.md` | 个人默认偏好,适用于没有项目说明的仓库 | + +## 推荐结构 + +保持简短、清晰、可执行。推荐从下面这些章节开始: + +```markdown +# 仓库指南 + +## 项目结构 + +说明主要目录,以及新增代码应该放在哪里。 + +## 开发命令 + +- `npm install` — 安装依赖。 +- `npm test` — 运行测试套件。 +- `npm run build` — 构建项目。 + +## 代码风格 + +说明格式化、命名和框架约定。 + +## 测试 + +说明什么时候需要添加测试,以及需要运行哪些命令。 + +## Pull Request + +说明提交风格、PR 检查项、截图或发布说明要求。 + +## AI 助手注意事项 + +列出 AI 助手需要遵守的规则,例如不要修改哪些文件,或完成前要执行哪些检查。 +``` + +你不需要保留所有章节。只写对当前项目有帮助的内容。 + +## 写作原则 + +### 写具体命令 + +推荐: + +```markdown +## 开发命令 + +- `npm install` — 安装依赖。 +- `npm test` — 运行全部测试。 +- `npm run build` — 执行类型检查并构建 CLI。 +``` + +避免: + +```markdown +完成前运行常用命令。 +``` + +### 写明确规则 + +推荐: + +```markdown +## 测试 + +修改行为时需要新增或更新测试。完成前,测试相关修改运行 +`npm test`,代码修改运行 `npm run build`。 +``` + +避免: + +```markdown +确保一切正常。 +``` + +### 写项目事实 + +推荐: + +```markdown +## 项目结构 + +- `src/` 存放应用代码。 +- `tests/` 存放自动化测试。 +- `docs/` 存放面向用户的文档。 +``` + +避免: + +```markdown +这是一个普通的 TypeScript 项目。 +``` + +### 写安全边界 + +推荐: + +```markdown +## 安全 + +不要提交 API Key 或 Token。本地凭据放在 `~/.deepcode/settings.json`, +项目示例中的敏感信息需要脱敏。 +``` + +避免: + +```markdown +注意保护密钥。 +``` + +## 示例 + +下面是一个较完整的 `AGENTS.md` 示例: + +```markdown +# 仓库指南 + +## 项目结构 + +- `src/` 存放应用代码。 +- `src/tests/` 存放自动化测试。 +- `docs/` 存放面向用户的文档。 +- `config/` 存放项目配置示例。 + +## 开发命令 + +- `npm install` — 安装依赖。 +- `npm test` — 运行自动化测试。 +- `npm run build` — 运行检查并构建 CLI。 + +## 代码风格 + +使用 TypeScript。保持代码清晰,优先使用明确命名,并遵循现有 +格式风格。不要引入无关重构。 + +## 测试 + +修改行为时需要添加测试。优先运行范围最小的相关测试;条件允许时, +完成前运行 `npm test` 或 `npm run build`。 + +## AI 助手注意事项 + +- 不要提交密钥或本地生成文件。 +- 保留用户已有修改。 +- 说明无法执行的验证步骤。 +``` + +## 与 Skills、MCP 的区别 + +| 工具 | 适合放什么 | +| ---- | ---------- | +| `AGENTS.md` | 当前仓库的长期规则、命令、风格、验证步骤 | +| Agent Skill | 可复用工作流、领域知识、模板、脚本、参考资料 | +| MCP | GitHub、浏览器、数据库等外部工具和实时数据 | + +常见组合: + +- 在 `AGENTS.md` 中写“这个项目怎么做” +- 在 Agent Skill 中写“这类任务怎么做” +- 用 MCP 提供“需要连接外部服务才能做”的能力 + +## 维护建议 + +- 项目命令变化后及时更新 +- 删除已经过期的规则 +- 避免写太长,优先保留高频、重要、容易出错的约定 +- 不要写敏感信息 +- 如果规则只适用于某次任务,直接写在当前对话中即可 diff --git a/docs/agents-md_en.md b/docs/agents-md_en.md new file mode 100644 index 00000000..e63047b1 --- /dev/null +++ b/docs/agents-md_en.md @@ -0,0 +1,220 @@ +# AGENTS.md + +`AGENTS.md` is a project instruction file for AI coding assistants. Use it to record long-lived repository rules so Deep Code knows how to install dependencies, run tests, edit code, prepare changes, and follow team conventions. + +If you often repeat instructions such as "run this test first", "do not edit that directory", or "include these details in the PR summary", put them in `AGENTS.md`. + +## What to Include + +Use `AGENTS.md` for stable project rules: + +- Project structure and important directories +- Install, development, build, and test commands +- Coding style, naming conventions, and formatting rules +- Testing expectations and verification steps +- Commit, pull request, and release conventions +- Security, configuration, and credential handling notes +- AI collaboration rules that apply only to this repository + +Do not use `AGENTS.md` for: + +- One-off task requirements, such as "only edit the login page this time" +- Complex reusable workflows, which are better as Agent Skills +- External service connections, which are better configured with MCP +- API keys, passwords, tokens, or other secrets + +## Create the File + +Run this inside a project: + +```text +/init +``` + +Deep Code helps create or update `AGENTS.md`. You can also create it manually: + +```bash +touch AGENTS.md +``` + +If you want Deep Code-specific project instructions, you can use: + +```bash +mkdir -p .deepcode +touch .deepcode/AGENTS.md +``` + +Common choices: + +| File | Best for | +| ---- | -------- | +| `AGENTS.md` | Rules that should be visible to multiple AI coding tools | +| `.deepcode/AGENTS.md` | Rules intended only for Deep Code | +| `~/.deepcode/AGENTS.md` | Personal defaults for repositories without project instructions | + +## Recommended Structure + +Keep it short, clear, and actionable. Start with sections like these: + +```markdown +# Repository Guidelines + +## Project Structure + +Describe the main directories and where new code should go. + +## Development Commands + +- `npm install` — Install dependencies. +- `npm test` — Run the test suite. +- `npm run build` — Build the project. + +## Coding Style + +Describe formatting, naming, and framework conventions. + +## Testing + +Explain when to add tests and which commands to run. + +## Pull Requests + +Describe commit style, PR checklist, screenshots, or release notes. + +## Agent Notes + +List rules for AI assistants, such as files to avoid or checks to run before finishing. +``` + +You do not need every section. Keep only what helps in this repository. + +## Writing Principles + +### Write Concrete Commands + +Good: + +```markdown +## Development Commands + +- `npm install` — Install dependencies. +- `npm test` — Run all tests. +- `npm run build` — Type-check and build the CLI. +``` + +Avoid: + +```markdown +Run the usual commands before finishing. +``` + +### Write Explicit Rules + +Good: + +```markdown +## Testing + +Add or update tests when changing behavior. Before reporting completion, run +`npm test` for test-only changes and `npm run build` for code changes. +``` + +Avoid: + +```markdown +Make sure everything works. +``` + +### Write Repository Facts + +Good: + +```markdown +## Project Structure + +- `src/` contains application code. +- `tests/` contains automated tests. +- `docs/` contains user-facing documentation. +``` + +Avoid: + +```markdown +This is a normal TypeScript project. +``` + +### Write Safety Boundaries + +Good: + +```markdown +## Security + +Do not commit API keys or tokens. Use `~/.deepcode/settings.json` for local +credentials and keep project examples redacted. +``` + +Avoid: + +```markdown +Be careful with secrets. +``` + +## Example + +Here is a complete `AGENTS.md` example: + +```markdown +# Repository Guidelines + +## Project Structure + +- `src/` contains application code. +- `src/tests/` contains automated tests. +- `docs/` contains user-facing documentation. +- `config/` contains project configuration examples. + +## Development Commands + +- `npm install` — Install dependencies. +- `npm test` — Run automated tests. +- `npm run build` — Run checks and build the CLI. + +## Coding Style + +Use TypeScript. Keep code readable, prefer clear names, and follow the existing +formatting style. Do not introduce unrelated refactors. + +## Testing + +Add tests when changing behavior. Run the narrowest relevant test first, then +run `npm test` or `npm run build` before reporting completion when practical. + +## Agent Notes + +- Do not commit secrets or generated local files. +- Preserve existing user changes. +- Explain any verification step that could not be run. +``` + +## AGENTS.md vs. Skills vs. MCP + +| Mechanism | Best for | +| --------- | -------- | +| `AGENTS.md` | Long-lived repository rules, commands, style, and verification steps | +| Agent Skill | Reusable workflows, domain knowledge, templates, scripts, and reference docs | +| MCP | External tools and live data, such as GitHub, browsers, or databases | + +Common pattern: + +- Put "how this project works" in `AGENTS.md` +- Put "how this type of task works" in an Agent Skill +- Use MCP for work that requires external services + +## Maintenance Tips + +- Update commands when the project changes +- Remove outdated rules +- Keep it concise; prioritize frequent, important, and easy-to-miss conventions +- Do not include secrets +- If a rule applies only to the current task, write it in the current conversation instead diff --git a/docs/configuration.md b/docs/configuration.md index f8e52c3a..d942dc04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,9 +31,13 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) | | `reasoningEffort` | string | 推理强度,可选 `"high"` 或 `"max"`(默认 `"max"`) | | `debugLogEnabled` | boolean | 是否启用调试日志输出(默认 `false`) | +| `telemetryEnabled` | boolean | 是否启用匿名使用数据上报(默认 `true`) | | `notify` | string | 任务完成通知脚本的完整路径(如 Slack 通知脚本) | | `webSearchTool` | string | 自定义联网搜索脚本的完整路径 | | `mcpServers` | object | MCP 服务器配置(键为服务名,值为 McpServerConfig 对象) | +| `temperature` | number | 模型采样温度,范围 `0` 到 `2` | +| `enabledSkills` | object | 按 skill 名称启用或禁用 skill 的配置 | +| `statusline` | object | 状态栏插件配置(参见 [statusline.md](./statusline.md)) | #### `env` 子字段 @@ -42,9 +46,11 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 | `MODEL` | string | 模型名称。例如 `"deepseek-v4-pro"`、`"deepseek-v4-flash"` | | `BASE_URL` | string | API 请求的基础 URL。例如 `"https://api.deepseek.com"` | | `API_KEY` | string | API 密钥 | +| `TEMPERATURE` | string | Chat Completions 采样温度,范围 `"0"` 到 `"2"` | | `THINKING_ENABLED` | string | 是否启用思考模式 | | `REASONING_EFFORT` | string | 推理强度 | | `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | +| `TELEMETRY_ENABLED` | string | 是否启用匿名使用数据上报 | | `<其他任意KEY>` | string | 自定义环境变量 | #### `thinkingEnabled` — 思考模式 @@ -67,12 +73,24 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两 设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 +通知脚本执行时,会通过环境变量注入以下上下文信息: + +| 环境变量 | 说明 | +|----------|------| +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + ```json { - "notify": "/path/to/slack-notify.sh" + "notify": "/path/to/notify-script.sh" } ``` +> 详细的 Slack、飞书、终端通知、系统通知等配置示例,请参阅 [notify.md](notify.md)。 + #### `webSearchTool` — 自定义联网搜索 Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: @@ -85,6 +103,23 @@ Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索 脚本接收一个搜索查询参数,输出 JSON 格式的结果供 AI 使用。 +#### `enabledSkills` — Skill 启用配置 + +控制 skill 扫描时是否包含指定 skill。键是解析后的 skill 名称,值必须是布尔值: + +```json +{ + "enabledSkills": { + "skill-writer": false, + "code-review": true + } +} +``` + +- 未配置的 skill 默认启用。 +- 将某个 skill 设置为 `false` 后,所有项目级和用户级目录中解析名称相同的 skill 都会被隐藏。 +- 项目设置会按 skill 覆盖用户设置。如果项目设置没有配置某个 skill,则使用用户设置。 + #### `mcpServers` — MCP 服务器 MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。 @@ -118,6 +153,16 @@ MCP(Model Context Protocol)服务器配置。值是键值对,键为服务 设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 +#### `telemetryEnabled` — 匿名使用数据上报 + +设为 `false` 可关闭匿名使用数据上报(默认 `true`)。上报仅包含匿名的机器标识,不包含对话内容、代码或 API 密钥。 + +也可以通过环境变量关闭: + +```bash +DEEPCODE_TELEMETRY_ENABLED=0 deepcode +``` + ## 环境变量优先级 环境变量是配置应用程序的常用方式,尤其适用于敏感信息(如 api-key)或可能在不同环境之间更改的设置。 diff --git a/docs/configuration_en.md b/docs/configuration_en.md index 369f8e47..a078c428 100644 --- a/docs/configuration_en.md +++ b/docs/configuration_en.md @@ -31,9 +31,13 @@ The following are all the top-level fields supported in `settings.json`, along w | `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series)| | `reasoningEffort` | string | Reasoning intensity, either `"high"` or `"max"` (default `"max"`) | | `debugLogEnabled` | boolean | Enable debug log output (default `false`) | +| `telemetryEnabled` | boolean | Enable anonymous usage reporting (default `true`) | | `notify` | string | Full path to a task-completion notification script (e.g., Slack notification script) | | `webSearchTool` | string | Full path to a custom web search script | | `mcpServers` | object | MCP server configurations (keys are service names, values are McpServerConfig objects) | +| `temperature` | number | Sampling temperature for LLM, from `0` to `2` | +| `enabledSkills` | object | Per-skill enable/disable map, keyed by skill name | +| `statusline` | object | Status line plugins (see [statusline_en.md](./statusline_en.md)) | #### `env` Sub-fields @@ -42,9 +46,11 @@ The following are all the top-level fields supported in `settings.json`, along w | `MODEL` | string | Model name, e.g. `"deepseek-v4-pro"`, `"deepseek-v4-flash"` | | `BASE_URL` | string | Base URL for API requests, e.g. `"https://api.deepseek.com"` | | `API_KEY` | string | API key | +| `TEMPERATURE` | string | Sampling temperature for chat completions, from `"0"` to `"2"` | | `THINKING_ENABLED`| string | Enable thinking mode | | `REASONING_EFFORT`| string | Reasoning intensity | | `DEBUG_LOG_ENABLED`| string| Enable debug log output | +| `TELEMETRY_ENABLED`| string| Enable anonymous usage reporting | | `` | string | Custom environment variable | #### `thinkingEnabled` — Thinking Mode @@ -67,12 +73,24 @@ When thinking mode is enabled, controls the depth of the model’s reasoning: Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). +The following context is injected as environment variables when the notify script runs: + +| Variable | Description | +|----------|-------------| +| `DURATION` | Session duration in seconds (integer) | +| `STATUS` | Session status: `"completed"` or `"failed"` | +| `FAIL_REASON` | Failure reason (only set on failure) | +| `BODY` | The text content of the last AI assistant reply | +| `TITLE` | Session title (matches the resume list title) | + ```json { - "notify": "/path/to/slack-notify.sh" + "notify": "/path/to/notify-script.sh" } ``` +> For detailed configuration examples (Slack, Feishu, terminal notifications, system notifications, etc.), see [notify_en.md](notify_en.md). + #### `webSearchTool` — Custom Web Search Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script: @@ -85,6 +103,23 @@ Deep Code has a built-in, free-to-use Web Search tool. If you need custom search The script receives a search query as an argument and outputs results in JSON format for the AI. +#### `enabledSkills` — Skill Enablement + +Controls whether skills are included during skill scanning. Keys are resolved skill names, and values must be booleans: + +```json +{ + "enabledSkills": { + "skill-writer": false, + "code-review": true + } +} +``` + +- Missing entries are enabled by default. +- Setting a skill to `false` hides every skill with that resolved `name`, across project and user skill roots. +- Project settings override user settings per skill. If the project setting omits a skill, the user setting is used. + #### `mcpServers` — MCP Servers Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object. @@ -117,6 +152,16 @@ For detailed MCP usage instructions, refer to [mcp.md](mcp.md). Set to `true` to enable detailed debug logging (default `false`), useful for troubleshooting API calls and tool execution. +#### `telemetryEnabled` — Anonymous Usage Reporting + +Set to `false` to disable anonymous usage reporting (default `true`). The report only includes an anonymous machine identifier and does not contain conversation content, code, or API keys. + +You can also disable it via environment variable: + +```bash +DEEPCODE_TELEMETRY_ENABLED=0 deepcode +``` + ## Environment Variable Priority Environment variables are a common way to configure applications, especially for sensitive information (such as api-key) or settings that may change between environments. @@ -169,4 +214,4 @@ Applied in the following priority order (lower-numbered overridden by higher-num 2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` 3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` 4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` -5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` \ No newline at end of file +5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` diff --git a/docs/notify.md b/docs/notify.md new file mode 100644 index 00000000..d73eef45 --- /dev/null +++ b/docs/notify.md @@ -0,0 +1,211 @@ +# Deep Code 任务完成通知 + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +## 工作原理 + +在 `settings.json` 中配置 `notify` 字段,指向一个可执行脚本的完整路径。每次 AI 助手完成任务应答后,Deep Code 会执行该脚本,并通过环境变量注入上下文信息。 + +## 注入的环境变量 + +| 环境变量 | 说明 | +|----------|------| +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + +## 配置方法 + +编辑 `~/.deepcode/settings.json`,添加 `notify` 字段: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "notify": "/path/to/your-notify-script.sh" +} +``` + +你也可以在 `env` 中配置通知脚本所需的自定义环境变量,例如 Slack Webhook URL: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-...", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +这些 `env` 中的变量会被注入到脚本的执行环境中。 + +## Slack 通知 + +### 1. 获取 Slack Webhook URL + +1. 创建 [Slack App](https://api.slack.com/apps) +2. 在 App 页面点击 **Incoming Webhooks** → **Add New Webhook to Workspace**,生成 Webhook URL + +### 2. 创建通知脚本 + +创建 `~/.deepcode/notify-slack.sh`: + +```bash +#!/usr/bin/env bash +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" +CURRENT_DIR=$(pwd) +BRANCH=$(git branch --show-current 2>/dev/null) +curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-type: application/json" \ + --data "{ + \"text\": \"✅ Deep Code 任务已完成\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION 秒\" + }" +``` + +给脚本添加可执行权限: + +```bash +chmod +x ~/.deepcode/notify-slack.sh +``` + +### 3. 配置 settings.json + +```json +{ + "env": { + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +> Python 版本的脚本同样支持,你可以在 `env` 中传入并引用任意自定义环境变量。 + +## 飞书 / 企业微信等 Webhook 通知 + +以下示例使用 `node` 构建 JSON(自动转义特殊字符),`curl` 发送。通过 `env` 传入 `WEBHOOK_URL`: + +```bash +#!/bin/bash +WEBHOOK_URL="${WEBHOOK_URL:-}" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") + +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +```json +{ + "env": { + "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + }, + "notify": "/Users/you/.deepcode/notify-feishu.sh" +} +``` + +将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。此模式同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。 + +## 终端通知(iTerm2 / Windows Terminal) + +如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。 + +创建 `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# iTerm2 / Windows Terminal OSC 9 通知 +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本: + +```batch +@echo off +REM Windows Terminal OSC 9 通知 +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + +## macOS 系统通知 + +```bash +#!/bin/bash +# macOS 系统通知 +osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\"" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +## Linux 系统通知 + +需要安装 `libnotify-bin`: + +```bash +sudo apt install libnotify-bin # Debian/Ubuntu +``` + +创建 `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# Linux notify-send 通知 +notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s" +``` + +```json +{ + "notify": "/home/you/.deepcode/notify.sh" +} +``` + +## Windows msg 弹窗通知 + +```batch +@echo off +REM Windows msg 弹窗通知 +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + +```json +{ + "notify": "C:\\Users\\you\\.deepcode\\notify.bat" +} +``` + +## 自定义通知脚本 + +你可以根据通知脚本注入的环境变量自行编写任意逻辑的通知脚本(Python、Node.js、Ruby 等均可),只要脚本可执行即可。脚本中可通过 `env` 字段传入额外需要的配置变量。 diff --git a/docs/notify_en.md b/docs/notify_en.md new file mode 100644 index 00000000..b949161c --- /dev/null +++ b/docs/notify_en.md @@ -0,0 +1,211 @@ +# Deep Code Task Completion Notification + +When the AI assistant finishes a round of tasks, Deep Code can automatically execute a notification script to send task results to your chosen channel (Slack, system notifications, etc.). + +## How It Works + +Configure the `notify` field in `settings.json` with the full path to an executable script. Every time the AI assistant completes a task response, Deep Code executes that script and injects context as environment variables. + +## Injected Environment Variables + +| Variable | Description | +|----------|-------------| +| `DURATION` | Session duration in seconds (integer) | +| `STATUS` | Session status: `"completed"` or `"failed"` | +| `FAIL_REASON` | Failure reason (only set on failure) | +| `BODY` | The text content of the last AI assistant reply | +| `TITLE` | Session title (matches the resume list title) | + +## Configuration + +Edit `~/.deepcode/settings.json` and add the `notify` field: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "notify": "/path/to/your-notify-script.sh" +} +``` + +You can also configure custom environment variables for the notify script in `env`, such as a Slack Webhook URL: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-...", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +These `env` variables are injected into the script's execution environment. + +## Slack Notification + +### 1. Get a Slack Webhook URL + +1. Create a [Slack App](https://api.slack.com/apps) +2. In the App page, go to **Incoming Webhooks** → **Add New Webhook to Workspace** to generate a Webhook URL + +### 2. Create the Notification Script + +Create `~/.deepcode/notify-slack.sh`: + +```bash +#!/usr/bin/env bash +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" +CURRENT_DIR=$(pwd) +BRANCH=$(git branch --show-current 2>/dev/null) +curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-type: application/json" \ + --data "{ + \"text\": \"✅ Deep Code task completed\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION s\" + }" +``` + +Make the script executable: + +```bash +chmod +x ~/.deepcode/notify-slack.sh +``` + +### 3. Configure settings.json + +```json +{ + "env": { + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +> A Python version is also supported; you can pass and reference any custom environment variables via `env`. + +## Feishu / WeCom Webhook Notification + +Use `node` to build JSON (auto-escapes special characters) and `curl` to send. Pass `WEBHOOK_URL` via `env`: + +```bash +#!/bin/bash +WEBHOOK_URL="${WEBHOOK_URL:-}" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") + +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +```json +{ + "env": { + "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + }, + "notify": "/Users/you/.deepcode/notify-feishu.sh" +} +``` + +Replace `WEBHOOK_URL` with your Feishu bot webhook URL. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format. + +## Terminal Notification (iTerm2 / Windows Terminal) + +On iTerm2 or Windows Terminal, you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. + +Create `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# iTerm2 / Windows Terminal OSC 9 notification +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +Windows users on Git Bash can use the same script; alternatively, create a `.bat` script: + +```batch +@echo off +REM Windows Terminal OSC 9 notification +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + +## macOS System Notification + +```bash +#!/bin/bash +# macOS system notification +osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\"" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +## Linux System Notification + +Requires `libnotify-bin`: + +```bash +sudo apt install libnotify-bin # Debian/Ubuntu +``` + +Create `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# Linux notify-send notification +notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s" +``` + +```json +{ + "notify": "/home/you/.deepcode/notify.sh" +} +``` + +## Windows msg Popup Notification + +```batch +@echo off +REM Windows msg popup notification +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + +```json +{ + "notify": "C:\\Users\\you\\.deepcode\\notify.bat" +} +``` + +## Custom Notification Scripts + +You can write your own notification scripts in any language (Python, Node.js, Ruby, etc.) using the injected environment variables and any additional variables passed via `env`. diff --git a/docs/permission.md b/docs/permission.md new file mode 100644 index 00000000..91c19c6f --- /dev/null +++ b/docs/permission.md @@ -0,0 +1,101 @@ +# Deep Code 权限机制 + +Deep Code 内置了一套细粒度的权限控制机制,在 AI 助手执行工具调用(如执行 Shell 命令、读写文件、访问网络等)前,根据用户配置的策略决定是自动放行、直接拒绝、还是弹出交互式确认。 + +## 概述 + +每次 AI 助手调用工具时,系统会自动分析该操作涉及的**权限范围(Permission Scope)**,然后根据 `settings.json` 中的权限配置做出决策。对于需要用户确认的操作,会在终端中弹出交互式选择界面,用户可以选择: + +- **Yes** — 仅本次放行 +- **Yes, and always allow** — 本次放行,并将该权限范围写入项目配置文件,后续同类操作不再询问 +- **No** — 拒绝本次操作 + +## 权限范围 + +Deep Code 定义了以下 10 种权限范围,覆盖了工具调用的各类风险场景: + +| 权限范围 | 说明 | +| -------- | ---- | +| `read-in-cwd` | 读取当前工作区内的文件 | +| `read-out-cwd` | 读取当前工作区外的文件 | +| `write-in-cwd` | 在当前工作区内创建或覆写文件 | +| `write-out-cwd` | 在当前工作区外创建或覆写文件 | +| `delete-in-cwd` | 删除当前工作区内的文件 | +| `delete-out-cwd` | 删除当前工作区外的文件 | +| `query-git-log` | 查询 Git 历史(如 `git log`、`git show`、`git blame`) | +| `mutate-git-log` | 修改 Git 历史(如 `git commit`、`git rebase`、`git tag`) | +| `network` | 访问网络(如 `curl`、`npm install` 等联网操作) | +| `mcp` | 调用 MCP 外部工具 | + +此外还有一个特殊的 `unknown` 范围,当 LLM 无法准确分类命令的副作用时使用,**`unknown` 总是触发询问**。 + +## 权限配置 + +在 `~/.deepcode/settings.json`(用户级)或 `.deepcode/settings.json`(项目级)中通过 `permissions` 字段配置: + +```json +{ + "permissions": { + "allow": [], + "deny": [], + "ask": [], + "defaultMode": "allowAll" + } +} +``` + +### 配置项说明 + +| 字段 | 类型 | 说明 | +| ---- | ---- | ---- | +| `allow` | `string[]` | 始终自动放行的权限范围列表 | +| `deny` | `string[]` | 始终自动拒绝的权限范围列表 | +| `ask` | `string[]` | 始终弹出询问的权限范围列表 | +| `defaultMode` | `"allowAll"` \| `"askAll"` | 未在 `allow`/`deny`/`ask` 中明确列出的权限范围的默认处理方式。默认为 `"allowAll"` | + +### 优先级规则 + +当一个工具调用涉及多个权限范围时,决策按以下优先级进行: + +1. 若任一范围命中 `deny` → **拒绝** +2. 若任一范围命中 `ask` → **询问** +3. 若所有范围均在 `allow` 中 → **自动放行** +4. 否则 → 按 `defaultMode` 处理 + +### 示例:宽松模式(默认) + +```json +{ + "permissions": { + "defaultMode": "allowAll" + } +} +``` + +默认行为:所有操作自动放行,无需确认。 + +### 示例:严格模式 + +```json +{ + "permissions": { + "allow": ["read-in-cwd", "write-in-cwd", "query-git-log"], + "defaultMode": "askAll" + } +} +``` + +此配置的效果: +- 工作区内读写、Git 查询 → 自动放行 +- 其他操作都需要用户确认。 + + +## 持久化机制 + +当用户在权限提示中选择 "Yes, and always allow" 后,对应的权限范围会被写入当前项目的 `.deepcode/settings.json` 文件中: + +- 新增范围会追加到 `permissions.allow` 列表 +- 如果该范围之前存在于 `deny` 或 `ask` 中,会被自动移除 +- 不会重复写入已存在的范围 + +这样后续同类操作就不再询问。 diff --git a/docs/permission_en.md b/docs/permission_en.md new file mode 100644 index 00000000..dae739c0 --- /dev/null +++ b/docs/permission_en.md @@ -0,0 +1,100 @@ +# Deep Code Permission Mechanism + +Deep Code includes a fine-grained permission control mechanism. Before the AI assistant executes a tool call (such as running a shell command, reading/writing files, accessing the network, etc.), the system determines whether to auto-allow, auto-deny, or prompt for interactive confirmation based on your configured policy. + +## Overview + +Each time the AI assistant invokes a tool, the system automatically analyzes the **permission scopes** involved and makes a decision based on the permission configuration in `settings.json`. For operations requiring user confirmation, an interactive prompt appears in the terminal with the following choices: + +- **Yes** — Allow this one time only +- **Yes, and always allow** — Allow this time and persistently save the scope to the project configuration so future calls skip the prompt +- **No** — Deny this operation + +## Permission Scopes + +Deep Code defines the following 10 permission scopes, covering various risk scenarios for tool calls: + +| Permission Scope | Description | +| ---------------- | ----------- | +| `read-in-cwd` | Read files inside the current workspace | +| `read-out-cwd` | Read files outside the current workspace | +| `write-in-cwd` | Create or overwrite files inside the current workspace | +| `write-out-cwd` | Create or overwrite files outside the current workspace | +| `delete-in-cwd` | Delete files inside the current workspace | +| `delete-out-cwd` | Delete files outside the current workspace | +| `query-git-log` | Query Git history (e.g., `git log`, `git show`, `git blame`) | +| `mutate-git-log` | Mutate Git history (e.g., `git commit`, `git rebase`, `git tag`) | +| `network` | Access the network (e.g., `curl`, `npm install`) | +| `mcp` | Invoke MCP external tools | + +There is also a special `unknown` scope used when the LLM cannot classify a command's side effects — **`unknown` always triggers a prompt**. + +## Permission Configuration + +Configure permissions in `~/.deepcode/settings.json` (user-level) or `.deepcode/settings.json` (project-level) via the `permissions` field: + +```json +{ + "permissions": { + "allow": [], + "deny": [], + "ask": [], + "defaultMode": "allowAll" + } +} +``` + +### Configuration Fields + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `allow` | `string[]` | Permission scopes that are always auto-allowed | +| `deny` | `string[]` | Permission scopes that are always auto-denied | +| `ask` | `string[]` | Permission scopes that always trigger a confirmation prompt | +| `defaultMode` | `"allowAll"` \| `"askAll"` | Default behavior for scopes not explicitly listed in `allow`/`deny`/`ask`. Defaults to `"allowAll"` | + +### Priority Rules + +When a tool call involves multiple permission scopes, the decision follows this priority: + +1. If any scope matches `deny` → **Deny** +2. If any scope matches `ask` → **Prompt** +3. If all scopes are in `allow` → **Auto-allow** +4. Otherwise → use `defaultMode` + +### Example: Relaxed Mode (default) + +```json +{ + "permissions": { + "defaultMode": "allowAll" + } +} +``` + +Default behavior: all operations are auto-allowed with no confirmation required. + +### Example: Strict Mode + +```json +{ + "permissions": { + "allow": ["read-in-cwd", "write-in-cwd", "query-git-log"], + "defaultMode": "askAll" + } +} +``` + +With this configuration: +- Reading/writing inside the workspace and querying Git history → auto-allowed +- All other operations → require user confirmation + +## Persistence + +When you select "Yes, and always allow" in a permission prompt, the corresponding scope is written to the project's `.deepcode/settings.json`: + +- The scope is appended to the `permissions.allow` list +- If the scope was previously in `deny` or `ask`, it is automatically removed +- Duplicate scopes are not written again + +This means subsequent calls involving the same scope will no longer prompt for confirmation. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 00000000..8de1f3ce --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,197 @@ +# 快速开始 + +Deep Code 是一款开源的终端 AI 编程助手,专为 DeepSeek-V4 系列模型适配,支持深度思考、推理强度控制,并通过 Skills 和 MCP 扩展更多能力。 + +## 前置要求 + +使用前请确认本机已安装: + +- Node.js `22` 或更高版本 +- 一个可用的 DeepSeek API Key + +## 安装 + +使用 npm 全局安装: + +```bash +npm install -g @vegamo/deepcode-cli +``` + +安装后检查版本: + +```bash +deepcode --version +``` + +## 配置 DeepSeek-V4 + +Deep Code 推荐使用 `deepseek-v4-pro`,也支持 `deepseek-v4-flash`。创建 `~/.deepcode/settings.json`,写入你的 DeepSeek 模型配置: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +把 `API_KEY` 替换成你的 DeepSeek API Key。 + +常用字段: + +| 字段 | 说明 | +| ---- | ---- | +| `env.MODEL` | DeepSeek 模型名称,推荐 `deepseek-v4-pro` | +| `env.BASE_URL` | DeepSeek API 地址,默认 `https://api.deepseek.com` | +| `env.API_KEY` | DeepSeek API Key | +| `thinkingEnabled` | 是否启用思考模式 | +| `reasoningEffort` | 推理强度,常用 `"high"` 或 `"max"` | + +也可以在项目目录中创建 `.deepcode/settings.json`,为当前项目单独设置模型、权限或 MCP。 + +更多 DeepSeek 官方配置说明可参考 [Deep Code 集成指南](https://api-docs.deepseek.com/zh-cn/quick_start/agent_integrations/deepcode)。 + +更多配置项请参考 [configuration.md](configuration.md)。 + +## 启动 + +进入你的项目目录: + +```bash +cd path/to/your/project +deepcode +``` + +Deep Code 会在当前目录中启动交互式界面。你可以直接输入任务,然后按 `Enter` 发送。 + +如果想带着初始问题启动: + +```bash +deepcode -p "总结这个项目" +``` + +## 第一次可以这样问 + +可以先从只读任务开始: + +```text +总结这个仓库,并说明如何运行它。 +``` + +```text +找出主要入口文件,并解释请求流程。 +``` + +然后尝试让 Deep Code 修改代码: + +```text +为登录校验逻辑添加一个单元测试。 +``` + +```text +运行测试用例,并修复失败的测试。 +``` + +也可以让它先给出计划: + +```text +在修改文件前,先给出一个为用户列表添加分页的计划。 +``` + +## 常用操作 + +| 操作 | 用法 | +| ---- | ---- | +| 发送消息 | `Enter` | +| 输入多行 | `Shift+Enter` 或 `Ctrl+J` | +| 中断当前回复 | `Esc` | +| 粘贴图片 | `Ctrl+V` | +| 退出 | 连续按两次 `Ctrl+D`,或使用 `/exit` | + +## 斜杠命令 + +在输入框中输入 `/` 可以打开命令菜单。 + +| 命令 | 作用 | +| ---- | ---- | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/continue` | 继续当前对话,或恢复最近的对话 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/init` | 为当前项目生成 `AGENTS.md` 指令文件 | +| `/skills` | 查看可用 Agent Skills | +| `/mcp` | 查看 MCP 服务状态和可用工具 | +| `/undo` | 将代码和/或对话恢复到之前的状态 | +| `/raw` | 切换显示模式 | +| `/exit` | 退出 Deep Code | + +## 为项目添加说明 + +在项目中运行: + +```text +/init +``` + +Deep Code 会帮助你创建 `AGENTS.md`。这个文件适合记录项目约定,例如: + +- 项目如何安装依赖和运行测试 +- 代码风格和提交要求 +- 重要目录说明 +- 修改代码前后需要执行的检查 + +之后 Deep Code 在该项目中工作时会自动参考这些说明。 + +## 使用 Skills + +Agent Skills 适合保存可复用工作流,例如代码审查、发布检查、文档生成或某个框架的固定开发流程。 + +查看可用 skills: + +```text +/skills +``` + +也可以输入 `/`,在菜单中选择某个 skill。 + +更多说明请参考 [agent-skills.md](agent-skills.md)。 + +## 连接外部工具 + +如果你想让 Deep Code 连接 GitHub、浏览器、数据库或其他服务,可以配置 MCP。 + +配置后,在 Deep Code 中运行: + +```text +/mcp +``` + +即可查看已连接的 MCP 服务和可用工具。 + +更多说明请参考 [mcp.md](mcp.md)。 + +## 权限与安全 + +Deep Code 可能会读取文件、修改代码或运行命令。你可以通过权限配置控制哪些操作自动允许、哪些操作需要确认、哪些操作直接拒绝。 + +Deep Code 默认支持 YOLO 模式,可以更流畅地执行读写文件、运行命令等操作。如果你希望更谨慎,可以使用严格模式,让 Deep Code 在执行较高风险操作前询问你。 + +更多说明请参考 [permission.md](permission.md)。 + +## 任务完成通知 + +如果希望 Deep Code 完成任务后通知你,可以配置通知脚本,例如发送 Slack、飞书、系统通知或终端提示。 + +更多说明请参考 [notify.md](notify.md)。 + +## 下一步 + +- 阅读完整配置说明:[configuration.md](configuration.md) +- 配置权限策略:[permission.md](permission.md) +- 编写 Agent Skills:[agent-skills.md](agent-skills.md) +- 配置 MCP 外部工具:[mcp.md](mcp.md) +- 配置任务完成通知:[notify.md](notify.md) diff --git a/docs/quickstart_en.md b/docs/quickstart_en.md new file mode 100644 index 00000000..44f62692 --- /dev/null +++ b/docs/quickstart_en.md @@ -0,0 +1,197 @@ +# Quickstart + +Deep Code is an open-source terminal AI coding assistant for the DeepSeek-V4 model, supporting deep thinking, reasoning effort control, and extend its capabilities with Skills and MCP. + +## Prerequisites + +Before you start, make sure you have: + +- Node.js `22` or later +- A DeepSeek API key + +## Install + +Install Deep Code globally with npm: + +```bash +npm install -g @vegamo/deepcode-cli +``` + +Check the installed version: + +```bash +deepcode --version +``` + +## Configure DeepSeek-V4 + +Deep Code recommends `deepseek-v4-pro` and also supports `deepseek-v4-flash`. Create `~/.deepcode/settings.json` and add your DeepSeek model configuration: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +Replace `API_KEY` with your DeepSeek API key. + +Common fields: + +| Field | Description | +| ----- | ----------- | +| `env.MODEL` | DeepSeek model name, recommended `deepseek-v4-pro` | +| `env.BASE_URL` | DeepSeek API endpoint, default `https://api.deepseek.com` | +| `env.API_KEY` | DeepSeek API key | +| `thinkingEnabled` | Whether to enable thinking mode | +| `reasoningEffort` | Reasoning effort, commonly `"high"` or `"max"` | + +You can also create `.deepcode/settings.json` inside a project to customize the model, permissions, or MCP settings for that project only. + +For DeepSeek's official setup notes, see the [Deep Code integration guide](https://api-docs.deepseek.com/zh-cn/quick_start/agent_integrations/deepcode). + +For all configuration options, see [configuration_en.md](configuration_en.md). + +## Start + +Open your project directory: + +```bash +cd path/to/your/project +deepcode +``` + +Deep Code starts an interactive terminal UI in the current directory. Type a task and press `Enter`. + +To start with an initial prompt: + +```bash +deepcode -p "Summarize this project" +``` + +## Try These First + +Start with a read-only task: + +```text +Summarize this repository and explain how to run it. +``` + +```text +Find the main entry points and explain the request flow. +``` + +Then try a coding task: + +```text +Add a unit test for the login validation logic. +``` + +```text +Run the test suite and fix the failing tests. +``` + +You can also ask for a plan first: + +```text +Before editing files, propose a plan for adding pagination to the user list. +``` + +## Basic Controls + +| Action | Key | +| ------ | --- | +| Send message | `Enter` | +| Insert a newline | `Shift+Enter` or `Ctrl+J` | +| Interrupt the current response | `Esc` | +| Paste an image | `Ctrl+V` | +| Quit | Press `Ctrl+D` twice, or use `/exit` | + +## Slash Commands + +Type `/` in the input box to open the command menu. + +| Command | Action | +| ------- | ------ | +| `/new` | Start a new conversation | +| `/resume` | Choose a previous conversation to continue | +| `/continue` | Continue the current conversation or resume the latest one | +| `/model` | Switch model, thinking mode, and reasoning effort | +| `/init` | Create an `AGENTS.md` instruction file for the current project | +| `/skills` | Show available Agent Skills | +| `/mcp` | Show MCP server status and available tools | +| `/undo` | Restore code and/or conversation to an earlier point | +| `/raw` | Change the display mode | +| `/exit` | Quit Deep Code | + +## Add Project Instructions + +Run this inside a project: + +```text +/init +``` + +Deep Code helps create `AGENTS.md`. Use it to record project conventions, such as: + +- How to install dependencies and run tests +- Code style and contribution expectations +- Important directory notes +- Checks to run before or after editing code + +Deep Code automatically uses these instructions when working in the project. + +## Use Skills + +Agent Skills are reusable workflows, such as code review, release checks, documentation generation, or framework-specific development steps. + +List available skills: + +```text +/skills +``` + +You can also type `/` and choose a skill from the menu. + +For more details, see [agent-skills_en.md](agent-skills_en.md). + +## Connect External Tools + +Use MCP to connect Deep Code to GitHub, browsers, databases, or other services. + +After configuring MCP, run: + +```text +/mcp +``` + +This shows connected MCP servers and available tools. + +For setup instructions, see [mcp_en.md](mcp_en.md). + +## Permissions and Safety + +Deep Code may read files, edit code, or run commands. You can configure which actions are allowed automatically, which require confirmation, and which are denied. + +Deep Code supports YOLO mode by default, so it can smoothly read and write files, run commands, and continue common coding tasks. If you prefer a more cautious setup, use strict permissions so Deep Code asks before higher-risk actions. + +For details, see [permission_en.md](permission_en.md). + +## Task Completion Notifications + +Deep Code can run a notification script when a task finishes, such as sending a Slack message, Feishu message, system notification, or terminal alert. + +For examples, see [notify_en.md](notify_en.md). + +## Next Steps + +- Read the full configuration guide: [configuration_en.md](configuration_en.md) +- Configure permissions: [permission_en.md](permission_en.md) +- Write Agent Skills: [agent-skills_en.md](agent-skills_en.md) +- Configure MCP tools: [mcp_en.md](mcp_en.md) +- Configure task completion notifications: [notify_en.md](notify_en.md) diff --git a/docs/session-persistence.md b/docs/session-persistence.md new file mode 100644 index 00000000..835d2881 --- /dev/null +++ b/docs/session-persistence.md @@ -0,0 +1,139 @@ +# 会话持久化机制 + +Deep Code 会把每个项目的会话记录保存在本机用户目录中。会话历史用于 `/resume`、`/continue` 和 `/undo`,不依赖当前终端进程是否仍在运行。 + +## 存储位置 + +每个项目都有独立的存储目录: + +```text +~/.deepcode/projects// +``` + +`` 由项目根目录路径生成。普通路径会转换为安全的目录名;路径过长时,Deep Code 会保留项目名的一部分,并追加稳定哈希,以避免存储路径过长。 + +项目存储目录包含以下主要文件和目录: + +| 路径 | 说明 | +| ---- | ---- | +| `sessions-index.json` | 当前项目的会话索引,保存会话列表和每个会话的概要信息。 | +| `.jsonl` | 单个会话的消息记录。每一行是一条 JSON 格式的消息。 | +| `file-history/.git` | 用于代码快照的内部 Git 仓库,供 `/undo` 恢复文件内容。 | + +## 持久化内容 + +### 会话索引 + +`sessions-index.json` 保存最近的会话条目。每个条目包含: + +- 会话 ID、标题、创建时间和更新时间。 +- 会话状态,例如 `pending`、`processing`、`completed`、`failed`、`interrupted`、`ask_permission`、`waiting_for_user`。 +- 最近一次 assistant 回复、思考内容、拒绝原因和失败原因。 +- 最近一次工具调用信息、token 用量和活跃 token 数。 +- 当前会话中仍被跟踪的子进程信息。 + +会话标题默认来自首次用户输入的前 100 个字符。使用会话列表中的重命名功能会更新索引里的标题。 + +### 消息文件 + +每个会话有一个独立的 JSONL 消息文件,文件名是 `.jsonl`。消息按追加顺序写入,常见字段包括: + +| 字段 | 说明 | +| ---- | ---- | +| `id` | 消息 ID。 | +| `sessionId` | 所属会话 ID。 | +| `role` | 消息角色:`system`、`user`、`assistant` 或 `tool`。 | +| `content` | 文本内容。 | +| `contentParams` | 结构化内容,例如图片输入。 | +| `messageParams` | 模型消息参数,例如 tool call ID、tool calls、reasoning content。 | +| `visible` | 是否在界面中显示。 | +| `compacted` | 是否已经被长会话压缩替代。 | +| `checkpointHash` | 与 `/undo` 关联的代码快照哈希。 | +| `meta` | 工具展示、skill、权限、摘要等附加信息。 | + +读取消息文件时,Deep Code 会逐行解析 JSON;无法解析的行会被忽略,以便尽量保留其余可用历史。 + +### 代码快照 + +Deep Code 使用 `file-history/.git` 保存代码快照。这个仓库只作为内部文件历史使用,不是项目仓库本身。 + +- 新会话会初始化一条以会话 ID 命名的内部分支。 +- 每次用户输入前,会记录已跟踪文件的状态。 +- 工具修改文件前后,会按需记录相关文件的状态。 +- 用户消息上的 `checkpointHash` 用来把某次对话位置和对应的代码状态关联起来。 + +快照只覆盖 Deep Code 已跟踪到的文件;无关文件不会因为 `/undo` 被任意改写。 + +## 会话生命周期 + +### 创建会话 + +创建新会话时,Deep Code 会: + +1. 生成新的会话 ID。 +2. 初始化该会话的代码快照分支。 +3. 在 `sessions-index.json` 中添加会话条目。 +4. 写入系统提示、运行时上下文、项目指令和用户消息。 +5. 启动模型请求,并在 assistant 回复和工具执行过程中持续更新索引和消息文件。 + +项目级会话列表最多保留最近 50 条记录。超过上限时,较旧会话会从索引中移除,其消息文件和相关运行时资源也会被清理。 + +### 继续会话 + +`/resume` 会显示当前项目的历史会话列表,并选择一个会话继续。 + +`/continue` 会优先继续当前活动会话;如果没有可继续的活动会话,则进入历史会话选择流程。 + +继续会话时,Deep Code 会读取会话消息文件,过滤已压缩的旧消息,修复未完成的工具调用上下文,并把可用历史转换为模型请求消息。 + +### 长会话压缩 + +当会话上下文过长时,Deep Code 会触发压缩流程: + +- 选取较早的一段非系统消息生成摘要。 +- 将这段旧消息标记为 `compacted: true`。 +- 在消息序列中插入一条不可见的系统摘要消息。 + +后续请求只会使用未压缩消息和摘要消息。原始消息仍保留在 JSONL 文件中,用于审计和界面历史展示。 + +### 中断、失败和权限等待 + +会话状态会随运行过程更新: + +- 用户中断后,状态会变为 `interrupted`,并清理当前会话控制器和被跟踪的子进程。 +- 请求失败时,状态会变为 `failed`,失败原因写入索引。 +- 工具调用需要确认时,状态会变为 `ask_permission`。 +- 工具需要用户输入时,状态会变为 `waiting_for_user`。 + +这些状态都会持久化到 `sessions-index.json`,因此重新打开 CLI 后仍能在会话列表中看到。 + +## `/undo` 如何使用持久化数据 + +`/undo` 的候选项来自可见且未压缩的用户消息。每个候选项会检查是否有关联的 `checkpointHash`,并确认对应快照是否可恢复。 + +根据选择,Deep Code 可以执行以下操作: + +| 操作 | 行为 | +| ---- | ---- | +| 恢复对话 | 截断所选用户消息之前的消息历史,并更新索引中的最新 assistant 信息。 | +| 恢复代码 | 从 `file-history/.git` 中读取所选快照,并还原被跟踪文件。 | +| 同时恢复 | 先恢复代码,再截断对话历史。 | + +恢复对话会重写该会话的 JSONL 文件;恢复代码会修改工作区中被快照跟踪的文件。 + +## 删除和重命名 + +在会话列表中删除会话会: + +- 从 `sessions-index.json` 移除该条目。 +- 删除对应的 `.jsonl` 文件。 +- 清理该会话的内存状态、临时工作目录状态、控制器和仍被跟踪的进程控制信息。 + +重命名会话只更新索引中的 `summary` 字段,不会改动消息文件或代码快照。 + +## 注意事项 + +- 会话数据保存在本机用户目录下,并按项目分隔。 +- 移动项目目录后,新的项目根路径会生成新的 ``;旧路径对应的历史不会自动迁移。 +- `file-history/.git` 是 Deep Code 的内部快照仓库,不应手动修改。 +- 会话删除不会清理内部 Git 仓库中的所有历史对象;它主要删除会话索引、消息文件和运行时资源。 diff --git a/docs/session-persistence_en.md b/docs/session-persistence_en.md new file mode 100644 index 00000000..071a5353 --- /dev/null +++ b/docs/session-persistence_en.md @@ -0,0 +1,139 @@ +# Session Persistence + +Deep Code stores per-project session history in the local user directory. This history powers `/resume`, `/continue`, and `/undo`, and it remains available after the current terminal process exits. + +## Storage Location + +Each project has its own storage directory: + +```text +~/.deepcode/projects// +``` + +`` is generated from the project root path. Normal paths are converted into safe directory names. When the path would be too long, Deep Code keeps part of the project name and appends a stable hash so the storage path stays safe. + +The project storage directory contains these main files and directories: + +| Path | Description | +| ---- | ----------- | +| `sessions-index.json` | Session index for the current project, including the session list and summary metadata. | +| `.jsonl` | Message log for one session. Each line is one JSON message. | +| `file-history/.git` | Internal Git repository used for code checkpoints restored by `/undo`. | + +## Persisted Data + +### Session Index + +`sessions-index.json` stores recent session entries. Each entry includes: + +- Session ID, title, creation time, and update time. +- Session status, such as `pending`, `processing`, `completed`, `failed`, `interrupted`, `ask_permission`, or `waiting_for_user`. +- Latest assistant reply, thinking content, refusal reason, and failure reason. +- Latest tool-call data, token usage, and active token count. +- Metadata for subprocesses still tracked by the session. + +The default session title comes from the first 100 characters of the first user prompt. Renaming a session from the session list updates the title in the index. + +### Message Files + +Each session has a separate JSONL message file named `.jsonl`. Messages are appended in order. Common fields include: + +| Field | Description | +| ----- | ----------- | +| `id` | Message ID. | +| `sessionId` | Owning session ID. | +| `role` | Message role: `system`, `user`, `assistant`, or `tool`. | +| `content` | Text content. | +| `contentParams` | Structured content, such as image input. | +| `messageParams` | Model message parameters, such as tool call IDs, tool calls, and reasoning content. | +| `visible` | Whether the message is shown in the UI. | +| `compacted` | Whether the message has been replaced by long-session compaction. | +| `checkpointHash` | Code checkpoint hash associated with `/undo`. | +| `meta` | Extra metadata for tool display, skills, permissions, summaries, and related features. | + +When loading a message file, Deep Code parses JSON one line at a time. Malformed lines are ignored so the remaining usable history can still be loaded. + +### Code Checkpoints + +Deep Code stores code checkpoints in `file-history/.git`. This repository is only internal file history; it is not the project Git repository. + +- A new session initializes an internal branch named after the session ID. +- Before each user prompt, Deep Code records the state of files it already tracks. +- Before and after tool-based file mutations, Deep Code records the relevant file state as needed. +- `checkpointHash` on user messages links a conversation position to a code state. + +Checkpoints only cover files Deep Code has tracked. Unrelated files are not arbitrarily rewritten by `/undo`. + +## Session Lifecycle + +### Creating A Session + +When creating a new session, Deep Code: + +1. Generates a new session ID. +2. Initializes the code checkpoint branch for that session. +3. Adds an entry to `sessions-index.json`. +4. Writes system prompts, runtime context, project instructions, and the user message. +5. Starts the model request and keeps updating the index and message file as assistant replies and tool executions complete. + +The per-project session list keeps the 50 most recent entries. When the limit is exceeded, older sessions are removed from the index, and their message files and related runtime resources are cleaned up. + +### Continuing A Session + +`/resume` shows the current project's session history and lets you select a session to continue. + +`/continue` first continues the active session. If there is no active session to continue, it opens the session selection flow. + +When continuing a session, Deep Code reads the message file, filters compacted old messages, repairs incomplete tool-call context, and converts the usable history into model request messages. + +### Long-Session Compaction + +When the conversation context grows too large, Deep Code can compact earlier messages: + +- It summarizes an older range of non-system messages. +- It marks those old messages as `compacted: true`. +- It inserts an invisible system summary message into the message sequence. + +Future requests use the remaining active messages and the summary message. The original messages stay in the JSONL file for auditability and UI history. + +### Interruptions, Failures, And Permission Waits + +Session status changes during execution: + +- After a user interruption, status becomes `interrupted`, and Deep Code clears the current session controller and tracked subprocesses. +- After a request failure, status becomes `failed`, and the failure reason is written to the index. +- When a tool call needs confirmation, status becomes `ask_permission`. +- When a tool needs user input, status becomes `waiting_for_user`. + +These states are persisted in `sessions-index.json`, so they remain visible in the session list after reopening the CLI. + +## How `/undo` Uses Persistent Data + +`/undo` candidates come from visible, non-compacted user messages. Each candidate is checked for an associated `checkpointHash`, and Deep Code verifies whether the checkpoint can be restored. + +Depending on the selected mode, Deep Code can perform these operations: + +| Operation | Behavior | +| --------- | -------- | +| Restore conversation | Truncates message history before the selected user message and updates the latest assistant data in the index. | +| Restore code | Reads the selected checkpoint from `file-history/.git` and restores tracked files. | +| Restore both | Restores code first, then truncates the conversation history. | + +Restoring conversation rewrites the session JSONL file. Restoring code modifies workspace files tracked by the selected checkpoint. + +## Delete And Rename + +Deleting a session from the session list: + +- Removes the entry from `sessions-index.json`. +- Deletes the matching `.jsonl` file. +- Clears in-memory state, temporary working-directory state, controllers, and tracked process controls for that session. + +Renaming a session only updates the `summary` field in the index. It does not change message files or code checkpoints. + +## Notes + +- Session data is stored in the local user directory and separated by project. +- If a project directory is moved, the new project root path generates a new ``; history for the old path is not migrated automatically. +- `file-history/.git` is Deep Code's internal checkpoint repository and should not be edited manually. +- Deleting a session does not remove every historical object from the internal Git repository. It mainly removes the session index entry, message file, and runtime resources. diff --git a/docs/statusline.md b/docs/statusline.md new file mode 100644 index 00000000..4c731276 --- /dev/null +++ b/docs/statusline.md @@ -0,0 +1,149 @@ +# 状态栏插件 + +Deep Code CLI 支持通过插件向终端底部状态栏注入自定义信息(Git 分支、当前时间、token 用量等),无需修改 CLI 源码。状态栏行展示在输入框下方的快捷键提示行下方,所有 provider 的输出会用分隔符拼接后渲染。 + +## 配置 + +在 `~/.deepcode/settings.json`(或项目级 `.deepcode/settings.json`)中添加 `statusline` 字段: + +```json +{ + "statusline": { + "enabled": true, + "refreshMs": 2000, + "separator": " · ", + "providers": [ + { + "type": "command", + "id": "git", + "command": "git branch --show-current", + "color": "cyan" + }, + { + "type": "module", + "id": "tokens", + "path": "./.deepcode/plugins/tokens.mjs", + "color": "yellow" + } + ] + } +} +``` + +### 字段 + +| 字段 | 类型 | 说明 | +| ------------- | --------- | ------------------------------------------------------------------- | +| `enabled` | boolean | 是否启用。省略时,只要存在至少一个 provider 即视为启用 | +| `refreshMs` | number | 拉取间隔毫秒。最小 500,默认 2000 | +| `separator` | string | 多个 provider 输出之间的分隔符,默认 `" · "` | +| `providers` | array | provider 列表,按声明顺序渲染 | + +## Provider 类型 + +### `command` —— 执行外部命令 + +每隔 `refreshMs` 在 shell 中执行一次命令,取 stdout 第一行作为状态栏 segment。 + +| 字段 | 类型 | 必填 | 说明 | +| ----------- | ------- | ---- | ----------------------------------------------------------------- | +| `type` | string | 是 | 固定为 `"command"` | +| `command` | string | 是 | shell 命令(支持管道、重定向等) | +| `id` | string | 否 | 唯一标识。省略时按下标自动生成 | +| `cwd` | string | 否 | 执行目录。相对路径相对于项目根目录,省略时使用项目根目录 | +| `timeoutMs` | number | 否 | 超时毫秒,默认 1500。超时返回空串 | +| `color` | string | 否 | ink 支持的颜色名(如 `"red"`、`"#229ac3"`) | + +示例: + +```json +{ "type": "command", "id": "git", "command": "git status -sb | head -1" } +{ "type": "command", "id": "time", "command": "date +%H:%M" } +{ "type": "command", "id": "node", "command": "node -v", "color": "green" } +``` + +### `module` —— 加载 JS 模块 + +加载本地 JS/MJS 模块,调用其默认导出函数,把返回值作为 segment 文本。 + +| 字段 | 类型 | 必填 | 说明 | +| ----------- | ------- | ---- | ----------------------------------------------------------------------------------- | +| `type` | string | 是 | 固定为 `"module"` | +| `path` | string | 是 | 模块路径。相对路径相对于项目根目录 | +| `id` | string | 否 | 唯一标识 | +| `timeoutMs` | number | 否 | 超时毫秒,默认 2000 | +| `color` | string | 否 | ink 支持的颜色 | + +模块需导出一个 `default` 函数(或具名 `provider`): + +```js +// .deepcode/plugins/tokens.mjs +export default function tokensProvider({ projectRoot, session }) { + // 返回字符串(同步或异步) + if (session?.activeSessionId) { + return `msgs:${session.messageCount} reqs:${session.requestCount} tokens:${session.totalTokens}`; + } + return `tokens: 1.2k`; +} +``` + +函数接收一个对象 `{ projectRoot: string, session: SessionInfo | null }`,返回 `string` 或 `Promise`。 + +`SessionInfo` 结构: + +| 字段 | 类型 | 说明 | +| ----------------- | ------------------ | --------------------------------------------------- | +| `activeSessionId` | `string \| null` | 当前活跃会话的 ID,无会话时为 `null` | +| `messageCount` | `number` | 当前会话中的消息总数 | +| `requestCount` | `number` | 当前会话中的 LLM API 请求次数 | +| `totalTokens` | `number` | 当前会话中消耗的 token 总数 | + +## 安全限制 + +- **module provider 路径必须位于项目根目录或用户home目录之下**,绝对路径在这两个范围之外会被拒绝加载(防止从任意位置执行代码)。 +- 单个 segment 文本被自动: + - 取第一个非空行 + - 去除 ANSI 转义序列 + - 折叠空白字符 + - 截断到 40 个字符(超出加 `…`) +- command provider 的 stdout 最多读取 4 KB。 +- 任何 provider 抛错、超时、或返回空字符串,**只跳过该 segment**,不影响其它 provider。 + +## 行为 + +- 启动 CLI 后立即触发一次拉取,之后按 `refreshMs` 周期刷新。 +- 用户级与项目级配置的 `providers` 数组会**合并**(用户先、项目后);其他字段以项目级为优先。 +- 状态栏行在任何场景下都显示(包括 busy、permission prompt 等),不影响 busy 提示。 +- 修改配置文件后需重启 CLI 生效(不会热加载)。 + +## 完整示例 + +```json +{ + "statusline": { + "enabled": true, + "refreshMs": 3000, + "providers": [ + { + "type": "command", + "id": "branch", + "command": "git branch --show-current", + "color": "cyan" + }, + { + "type": "command", + "id": "dirty", + "command": "git status --porcelain | wc -l | xargs -I{} echo '{} files changed'", + "color": "yellow" + }, + { + "type": "module", + "id": "ts-errors", + "path": "./.deepcode/plugins/ts-errors.mjs", + "color": "red", + "timeoutMs": 5000 + } + ] + } +} +``` diff --git a/docs/statusline_en.md b/docs/statusline_en.md new file mode 100644 index 00000000..340c32cc --- /dev/null +++ b/docs/statusline_en.md @@ -0,0 +1,149 @@ +# Status Line Plugins + +Deep Code CLI lets you inject custom information into the status line at the bottom of the terminal (Git branch, current time, token usage, etc.) through plugins, without modifying the CLI source. The status line renders below the keyboard hint line under the prompt input, and all provider outputs are concatenated with a separator. + +## Configuration + +Add a `statusline` field to `~/.deepcode/settings.json` (or the project-level `.deepcode/settings.json`): + +```json +{ + "statusline": { + "enabled": true, + "refreshMs": 2000, + "separator": " · ", + "providers": [ + { + "type": "command", + "id": "git", + "command": "git branch --show-current", + "color": "cyan" + }, + { + "type": "module", + "id": "tokens", + "path": "./.deepcode/plugins/tokens.mjs", + "color": "yellow" + } + ] + } +} +``` + +### Fields + +| Field | Type | Description | +| ------------- | --------- | ---------------------------------------------------------------------------- | +| `enabled` | boolean | Whether the status line is enabled. If omitted, defaults to true when at least one provider is configured. | +| `refreshMs` | number | Refresh interval in milliseconds. Minimum 500, default 2000. | +| `separator` | string | Separator between provider outputs. Default `" · "`. | +| `providers` | array | List of providers, rendered in declaration order. | + +## Provider Types + +### `command` — Run an External Command + +Executes a shell command every `refreshMs` and uses the first line of stdout as the status segment. + +| Field | Type | Required | Description | +| ----------- | ------- | -------- | ------------------------------------------------------------------------ | +| `type` | string | Yes | Must be `"command"`. | +| `command` | string | Yes | Shell command (supports pipes, redirection, etc.). | +| `id` | string | No | Unique identifier. Auto-generated from index if omitted. | +| `cwd` | string | No | Working directory. Relative paths resolved against the project root. | +| `timeoutMs` | number | No | Timeout in milliseconds. Default 1500. Empty string on timeout. | +| `color` | string | No | Ink-supported color (e.g. `"red"`, `"#229ac3"`). | + +Examples: + +```json +{ "type": "command", "id": "git", "command": "git status -sb | head -1" } +{ "type": "command", "id": "time", "command": "date +%H:%M" } +{ "type": "command", "id": "node", "command": "node -v", "color": "green" } +``` + +### `module` — Load a JS Module + +Loads a local JS/MJS module and calls its default-exported function. The return value becomes the segment text. + +| Field | Type | Required | Description | +| ----------- | ------- | -------- | ------------------------------------------------------------------------------------ | +| `type` | string | Yes | Must be `"module"`. | +| `path` | string | Yes | Module path. Relative paths resolved against the project root. | +| `id` | string | No | Unique identifier. | +| `timeoutMs` | number | No | Timeout in milliseconds. Default 2000. | +| `color` | string | No | Ink-supported color. | + +The module must export a `default` function (or a named `provider`): + +```js +// .deepcode/plugins/tokens.mjs +export default function tokensProvider({ projectRoot, session }) { + // Return a string (sync or async). + if (session?.activeSessionId) { + return `msgs:${session.messageCount} reqs:${session.requestCount} tokens:${session.totalTokens}`; + } + return `tokens: 1.2k`; +} +``` + +The function receives `{ projectRoot: string, session: SessionInfo | null }` and returns `string` or `Promise`. + +`SessionInfo` shape: + +| Field | Type | Description | +| ----------------- | ------------------- | ---------------------------------------------------------- | +| `activeSessionId` | `string \| null` | ID of the currently active session, or `null` if none. | +| `messageCount` | `number` | Total messages in the active session. | +| `requestCount` | `number` | Total LLM API requests made in the active session. | +| `totalTokens` | `number` | Total tokens consumed in the active session. | + +## Safety Constraints + +- **Module provider paths must reside within the project root or the user's home directory**; absolute paths outside both are rejected (to prevent loading arbitrary code). +- Each segment's text is automatically: + - Reduced to the first non-empty line + - Stripped of ANSI escape sequences + - Whitespace-collapsed + - Truncated to 40 characters (with `…` for overflow) +- Command provider stdout is capped at 4 KB. +- If any provider throws, times out, or returns an empty string, **only that segment is skipped**; the rest are unaffected. + +## Behavior + +- The first refresh fires immediately after CLI startup, then on the configured interval. +- The `providers` arrays from user-level and project-level configs are **merged** (user first, project second); other fields prefer the project-level value. +- The status line is shown in every state (including busy and permission prompts) without interfering with busy indicators. +- Changes to config require a CLI restart (no hot reload). + +## Full Example + +```json +{ + "statusline": { + "enabled": true, + "refreshMs": 3000, + "providers": [ + { + "type": "command", + "id": "branch", + "command": "git branch --show-current", + "color": "cyan" + }, + { + "type": "command", + "id": "dirty", + "command": "git status --porcelain | wc -l | xargs -I{} echo '{} files changed'", + "color": "yellow" + }, + { + "type": "module", + "id": "ts-errors", + "path": "./.deepcode/plugins/ts-errors.mjs", + "color": "red", + "timeoutMs": 5000 + } + ] + } +} +``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 50e41491..abeb8390 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,12 +43,53 @@ export default tseslint.config( }, // Test files: relaxed rules { - files: ["src/tests/**/*.ts"], + files: ["packages/*/src/tests/**/*.ts", "packages/*/src/tests/**/*.mjs"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + }, + }, rules: { "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": "off", }, }, + // Script files: Node.js environment + { + files: ["./scripts/**/*.js", "./scripts/**/*.mjs", "packages/*/scripts/**/*.js"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + }, + }, + }, + // Statusline plugins: Node.js environment + { + files: [".deepcode/plugins/**/*.mjs", ".deepcode/plugins/**/*.js"], + languageOptions: { + globals: { + process: "readonly", + console: "readonly", + }, + }, + }, + // Browser resources: VSCode webview scripts + { + files: ["packages/*/resources/**/*.js"], + languageOptions: { + globals: { + window: "readonly", + document: "readonly", + console: "readonly", + FileReader: "readonly", + Blob: "readonly", + URL: "readonly", + fetch: "readonly", + }, + }, + }, // Prettier config: disable conflicting ESLint rules, MUST be last - prettierConfig, + prettierConfig ); diff --git a/package-lock.json b/package-lock.json index 800d75a5..5c894763 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,28 +1,14 @@ { - "name": "@vegamo/deepcode-cli", - "version": "0.1.22", + "name": "@vegamo/deepcode", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@vegamo/deepcode-cli", - "version": "0.1.22", + "name": "@vegamo/deepcode", "license": "MIT", - "dependencies": { - "chalk": "^5.6.2", - "ejs": "^5.0.2", - "gradient-string": "^3.0.0", - "gray-matter": "^4.0.3", - "ignore": "^7.0.5", - "ink": "^7.0.1", - "ink-gradient": "^4.0.0", - "openai": "^6.35.0", - "react": "^19.2.5", - "zod": "^4.4.3" - }, - "bin": { - "deepcode": "dist/cli.js" - }, + "workspaces": [ + "packages/*" + ], "devDependencies": { "@eslint/js": "^9.39.4", "@types/ejs": "^3.1.5", @@ -36,17 +22,15 @@ "husky": "^9.1.7", "lint-staged": "^17.0.4", "prettier": "^3.8.3", + "react-devtools-core": "^7.0.1", "tsx": "^4.21.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2" - }, - "engines": { - "node": ">=22" } }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.3.0", - "resolved": "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", "license": "MIT", "dependencies": { @@ -57,14 +41,210 @@ "node": ">=18" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@azu/format-text": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", + "integrity": "sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@azu/style-format": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@azu/style-format/-/style-format-1.0.1.tgz", + "integrity": "sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "@azu/format-text": "^1.0.1" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", + "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", + "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.14.0.tgz", + "integrity": "sha512-Dfl7hPZe9/JJwRhFFXHq2z1oHYBuGubmff3kWXOsd1AGgyXlqjNYAWuN/1JL/ZrcZBs8TKMjGSil6Rcc7E8VPQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@azure/msal-common": "16.9.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.9.0.tgz", + "integrity": "sha512-1MWGjqgUCRAYgLmVFZKp7fs3Rg1TFvIMgywY8ze2olNVvLlJoRThuoziWSDJuwwyJI5L4rnLb9Tyt5D9GvSLPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.5.tgz", + "integrity": "sha512-RUuewWk9JvWJS5Yiy8/74Lm1rQAWlrU/qg/Bgtk1jIauVRtnb9XKwS5Xg0J+Whwjesq9EVrBIFgQEP8vHxgezA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.9.0", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -73,9 +253,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -83,21 +263,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -114,14 +294,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -131,14 +311,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -148,9 +328,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -158,29 +338,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -190,9 +370,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -200,19 +380,18 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -220,27 +399,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -250,33 +429,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -284,23 +463,23 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -315,9 +494,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -332,9 +511,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -349,9 +528,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -366,9 +545,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -383,9 +562,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -400,9 +579,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -417,9 +596,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -434,9 +613,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -451,9 +630,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -468,9 +647,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -485,9 +664,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -502,9 +681,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -519,9 +698,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -536,9 +715,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -553,9 +732,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -570,9 +749,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -587,9 +766,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -604,9 +783,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -621,9 +800,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -638,9 +817,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -655,9 +834,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -672,9 +851,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -689,9 +868,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -706,9 +885,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -723,9 +902,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -741,7 +920,7 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", - "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", @@ -760,7 +939,7 @@ }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", @@ -773,7 +952,7 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", @@ -783,7 +962,7 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.2", - "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", @@ -798,7 +977,7 @@ }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", - "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", @@ -811,7 +990,7 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", - "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", @@ -824,7 +1003,7 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", - "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", @@ -846,39 +1025,9 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@eslint/js": { "version": "9.39.4", - "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", @@ -891,7 +1040,7 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", - "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", @@ -901,7 +1050,7 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", @@ -915,7 +1064,7 @@ }, "node_modules/@humanfs/core": { "version": "0.19.2", - "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", @@ -928,7 +1077,7 @@ }, "node_modules/@humanfs/node": { "version": "0.16.8", - "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", @@ -943,7 +1092,7 @@ }, "node_modules/@humanfs/types": { "version": "0.15.0", - "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, "license": "Apache-2.0", @@ -953,7 +1102,7 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", @@ -967,7 +1116,7 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", @@ -981,7 +1130,7 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", @@ -992,7 +1141,7 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", @@ -1003,7 +1152,7 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", @@ -1013,14 +1162,14 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", @@ -1029,1301 +1178,4154 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@types/ejs": { - "version": "3.1.5", - "resolved": "https://registry.npmmirror.com/@types/ejs/-/ejs-3.1.5.tgz", - "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/gradient-string": { - "version": "1.1.6", - "resolved": "https://registry.npmmirror.com/@types/gradient-string/-/gradient-string-1.1.6.tgz", - "integrity": "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==", "license": "MIT", "dependencies": { - "@types/tinycolor2": "*" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 8" + } }, - "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" } }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "node_modules/@secretlint/config-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", + "integrity": "sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==", + "dev": true, "license": "MIT", "dependencies": { - "csstype": "^3.2.2" + "@secretlint/types": "^10.2.2" + }, + "engines": { + "node": ">=20.0.0" } }, - "node_modules/@types/tinycolor2": { - "version": "1.4.6", - "resolved": "https://registry.npmmirror.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", - "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", - "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "node_modules/@secretlint/config-loader": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/config-loader/-/config-loader-10.2.2.tgz", + "integrity": "sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/type-utils": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" + "@secretlint/profiler": "^10.2.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "ajv": "^8.17.1", + "debug": "^4.4.1", + "rc-config-loader": "^4.1.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.59.2", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=20.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", - "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "node_modules/@secretlint/config-loader/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", - "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "node_modules/@secretlint/config-loader/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/core": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/core/-/core-10.2.2.tgz", + "integrity": "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", - "debug": "^4.4.3" + "@secretlint/profiler": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "structured-source": "^4.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node": ">=20.0.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", - "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "node_modules/@secretlint/formatter": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", + "debug": "^4.4.1", + "pluralize": "^8.0.0", + "strip-ansi": "^7.1.0", + "table": "^6.9.0", + "terminal-link": "^4.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=20.0.0" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", - "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "node_modules/@secretlint/node": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.2.2.tgz", + "integrity": "sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" + "@secretlint/config-loader": "^10.2.2", + "@secretlint/core": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "@secretlint/source-creator": "^10.2.2", + "@secretlint/types": "^10.2.2", + "debug": "^4.4.1", + "p-map": "^7.0.3" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "node": ">=20.0.0" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "node_modules/@secretlint/profiler": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/profiler/-/profiler-10.2.2.tgz", + "integrity": "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==", "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } + "license": "MIT" }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", - "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "node_modules/@secretlint/resolver": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@secretlint/secretlint-formatter-sarif": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz", + "integrity": "sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.2", - "@typescript-eslint/tsconfig-utils": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" + "node-sarif-builder": "^3.2.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/@secretlint/secretlint-rule-no-dotenv": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz", + "integrity": "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==", "dev": true, "license": "MIT", + "dependencies": { + "@secretlint/types": "^10.2.2" + }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=20.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/@secretlint/secretlint-rule-preset-recommend": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz", + "integrity": "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=20.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "node_modules/@secretlint/source-creator": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/source-creator/-/source-creator-10.2.2.tgz", + "integrity": "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "brace-expansion": "^5.0.5" + "@secretlint/types": "^10.2.2", + "istextorbinary": "^9.5.0" }, "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=20.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "node_modules/@secretlint/types": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=20.0.0" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.2.tgz", - "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "node_modules/@sindresorhus/merge-streams": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", + "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", "dev": true, "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.2", - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "node_modules/@textlint/ast-node-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.2", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@azu/format-text": "^1.0.2", + "@azu/style-format": "^1.0.1", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", + "pluralize": "^2.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "table": "^6.9.0", + "text-table": "^0.2.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=8" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@textlint/linter-formatter/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, "engines": { - "node": ">=0.4.0" + "node": ">=8" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@textlint/linter-formatter/node_modules/pluralize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", + "integrity": "sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==", "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } + "license": "MIT" }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/@textlint/linter-formatter/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=8" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { - "environment": "^1.0.0" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } + "node_modules/@textlint/module-interop": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", + "dev": true, + "license": "MIT" }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "node_modules/@textlint/resolver": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", + "dev": true, + "license": "MIT" }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@textlint/types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", + "dev": true, "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@textlint/ast-node-types": "15.7.1" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "node_modules/@types/ejs": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", + "integrity": "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gradient-string": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@types/gradient-string/-/gradient-string-1.1.6.tgz", + "integrity": "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==", "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "@types/tinycolor2": "*" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.29", - "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", - "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } + "license": "MIT" }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@types/linkify-it": "^5", + "@types/mdurl": "^2" } }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "undici-types": ">=7.24.0 <7.24.7" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "csstype": "^3.2.2" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001792", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", - "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "license": "MIT" }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "license": "MIT" + }, + "node_modules/@types/vscode": { + "version": "1.125.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", + "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/cli-boxes": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-4.0.1.tgz", - "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18.20 <19 || >=20.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 4" } }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^4.0.0" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/cli-truncate": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-6.0.0.tgz", - "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0" + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" }, "engines": { - "node": ">=22" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "dev": true, "license": "MIT", "dependencies": { - "convert-to-spaces": "^2.0.1" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=7.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "18 || 20 || >=22" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "balanced-match": "^4.0.2" }, "engines": { - "node": ">= 8" + "node": "18 || 20 || >=22" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "ms": "^2.1.3" + "brace-expansion": "^5.0.5" }, "engines": { - "node": ">=6.0" + "node": "18 || 20 || >=22" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, - "license": "MIT" - }, - "node_modules/ejs": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", - "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", - "license": "Apache-2.0", + "license": "ISC", "bin": { - "ejs": "bin/cli.js" + "semver": "bin/semver.js" }, "engines": { - "node": ">=0.12.18" + "node": ">=10" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.353", - "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", - "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", + "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vegamo/deepcode-cli": { + "resolved": "packages/cli", + "link": true + }, + "node_modules/@vegamo/deepcode-core": { + "resolved": "packages/core", + "link": true + }, + "node_modules/@vscode/vsce": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.9.2.tgz", + "integrity": "sha512-XSxMosEEDO6vLxELAHVkwmhC0qe0ijZni2jB9Rcs8kQsW4lhTDQ/wMzmwFs/buotAWSnpmUp/dRWD2ufG3UYKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@secretlint/node": "^10.1.2", + "@secretlint/secretlint-formatter-sarif": "^10.1.2", + "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", + "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^4.1.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^12.1.0", + "form-data": "^4.0.0", + "glob": "^13.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^14.1.0", + "mime": "^1.3.4", + "minimatch": "^10.2.2", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "secretlint": "^10.1.2", + "semver": "^7.5.2", + "tmp": "^0.2.3", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^3.2.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vscode/vsce/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vscode/vsce/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vscode/vsce/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binaryextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", + "integrity": "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boundary": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", + "integrity": "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepcode-vscode": { + "resolved": "packages/vscode-ide-companion", + "link": true + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/editions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/editions/-/editions-6.22.0.tgz", + "integrity": "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "version-range": "^4.15.0" + }, + "engines": { + "ecmascript": ">= es5", + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/ejs": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", + "integrity": "sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==", + "license": "Apache-2.0", + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.12.18" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-extra": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", + "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^2.1.0", + "fast-glob": "^3.3.3", + "ignore": "^7.0.3", + "path-type": "^6.0.0", + "slash": "^5.1.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gradient-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/gradient-string/-/gradient-string-3.0.0.tgz", + "integrity": "sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "tinygradient": "^1.1.5" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gradient-string/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ink": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.0.tgz", + "integrity": "sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-gradient": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/ink-gradient/-/ink-gradient-4.0.1.tgz", + "integrity": "sha512-0ckdiM84zkfCdnTtcnq4BS3egIhUPPDoCqSx/7NUFsAVooBbdRuGnnWpk0fuaOTqU6rlZRh9F4LN1UI8fxd81Q==", + "license": "MIT", + "dependencies": { + "@types/gradient-string": "^1.1.6", + "gradient-string": "^3.0.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "peerDependencies": { + "ink": ">=6", + "react": ">=19.2.0" + } + }, + "node_modules/ink/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istextorbinary": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", + "integrity": "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "binaryextensions": "^6.11.0", + "editions": "^6.21.0", + "textextensions": "^6.11.0" + }, + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" }, "engines": { - "node": ">=18" + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" + "engines": { + "node": ">=10" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lint-staged": { + "version": "17.0.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", + "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" + "listr2": "^10.2.1", + "picomatch": "^4.0.4", + "string-argv": "^0.3.2", + "tinyexec": "^1.2.4" }, "bin": { - "eslint": "bin/eslint.js" + "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=22.22.1" }, "funding": { - "url": "https://eslint.org/donate" + "url": "https://opencollective.com/lint-staged" }, - "peerDependencies": { - "jiti": "*" + "optionalDependencies": { + "yaml": "^2.9.0" + } + }, + "node_modules/listr2": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } + "engines": { + "node": ">=22.13.0" } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" + "engines": { + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/eslint-config-prettier" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, - "peerDependencies": { - "eslint": ">=7.0.0" + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.1.1", - "resolved": "https://registry.npmmirror.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", - "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "node_modules/listr2/node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" + "p-locate": "^5.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT" }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "license": "MIT" }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "restore-cursor": "^5.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.1.0" + "mimic-function": "^5.0.0" }, "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" + "node": ">=18" }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, - "license": "MIT" - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", "license": "MIT", "dependencies": { - "is-extendable": "^0.1.0" + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/log-update/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "node": ">=14" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=16.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=16" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "ISC" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" + "node_modules/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">= 0.4" } }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 8" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmmirror.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "engines": { + "node": ">=8.6" } }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" + "license": "MIT", + "bin": { + "mime": "cli.js" }, "engines": { - "node": ">=10.13.0" + "node": ">=4" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.6" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "mime-db": "1.52.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.6" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=6" } }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", "dev": true, "license": "MIT", "engines": { @@ -2333,220 +5335,262 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gradient-string": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/gradient-string/-/gradient-string-3.0.0.tgz", - "integrity": "sha512-frdKI4Qi8Ihp4C6wZNB565de/THpIaw3DjP5ku87M+N9rNSGmPTjfkq61SdRXB7eCaL8O1hkKDvf6CDMtOzIAg==", + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "tinygradient": "^1.1.5" - }, + "optional": true, "engines": { - "node": ">=14" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=6.0" + "node": "*" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "dev": true, "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } + "optional": true }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmmirror.com/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.92.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", + "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", "dev": true, "license": "MIT", - "bin": { - "husky": "bin.js" + "optional": true, + "dependencies": { + "semver": "^7.3.5" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "node": ">=10" } }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", + "node_modules/node-abi/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">= 4" + "node": ">=10" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", "dev": true, "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.8.19" + "node": ">=18" } }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "node_modules/node-sarif-builder": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.4.0.tgz", + "integrity": "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "@types/sarif": "^2.1.7", + "fs-extra": "^11.1.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=20" } }, - "node_modules/ink": { - "version": "7.0.1", - "resolved": "https://registry.npmmirror.com/ink/-/ink-7.0.1.tgz", - "integrity": "sha512-o6LAC268PLawlGVYrXTyaTfke4VtJftEheuwbgkQf7yvSXyWp1nRwBbAyKEkWXFZZsW/la5wrMuNbuBvZK2C1w==", - "license": "MIT", + "node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "@alcalzone/ansi-tokenize": "^0.3.0", - "ansi-escapes": "^7.3.0", - "ansi-styles": "^6.2.3", - "auto-bind": "^5.0.1", - "chalk": "^5.6.2", - "cli-boxes": "^4.0.1", - "cli-cursor": "^4.0.0", - "cli-truncate": "^6.0.0", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.45.1", - "indent-string": "^5.0.0", - "is-in-ci": "^2.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.33.0", - "scheduler": "^0.27.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^9.0.0", - "stack-utils": "^2.0.6", - "string-width": "^8.2.0", - "terminal-size": "^4.0.1", - "type-fest": "^5.5.0", - "widest-line": "^6.0.0", - "wrap-ansi": "^10.0.0", - "ws": "^8.20.0", - "yoga-layout": "~3.2.1" + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": ">=22" - }, - "peerDependencies": { - "@types/react": ">=19.2.0", - "react": ">=19.2.0", - "react-devtools-core": ">=6.1.2" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/normalize-package-data/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/ink-gradient": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/ink-gradient/-/ink-gradient-4.0.0.tgz", - "integrity": "sha512-Yx227CStr4DaXVkRAQPbBufSUTqe4a4FLOPVoypXZyae5h3A5jWyqZpTmAIbm7iiiqNYCkKIFBUPJM6nSICfxA==", - "license": "MIT", - "dependencies": { - "@types/gradient-string": "^1.1.6", - "gradient-string": "^3.0.0", - "strip-ansi": "^7.1.2" + "node_modules/normalize-package-data/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=20" + "node": ">=10" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - }, - "peerDependencies": { - "ink": ">=6" + "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" }, "engines": { "node": ">=18" @@ -2555,285 +5599,264 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/openai": { + "version": "6.44.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz", + "integrity": "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.8.0" } }, - "node_modules/is-in-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/is-in-ci/-/is-in-ci-2.0.0.tgz", - "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", - "bin": { - "is-in-ci": "cli.js" + "dependencies": { + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=20" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "p-limit": "^3.0.2" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "dev": true, "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { + "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "callsites": "^3.0.0" }, "engines": { "node": ">=6" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", + "node_modules/parse-json/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=0.10.0" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" + "semver": "^5.1.0" } }, - "node_modules/lint-staged": { - "version": "17.0.4", - "resolved": "https://registry.npmmirror.com/lint-staged/-/lint-staged-17.0.4.tgz", - "integrity": "sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA==", + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, - "license": "MIT", - "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", - "string-argv": "^0.3.2", - "tinyexec": "^1.1.2" - }, + "license": "ISC", "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=22.22.1" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - }, - "optionalDependencies": { - "yaml": "^2.8.4" + "semver": "bin/semver" } }, - "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmmirror.com/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" + "entities": "^6.0.0" }, - "engines": { - "node": ">=22.13.0" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/listr2/node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" + "domhandler": "^5.0.3", + "parse5": "^7.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/listr2/node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" + "parse5": "^7.0.0" }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=20" + "node": ">=0.12" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmmirror.com/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=18" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/log-update/node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "20 || >=22" } }, - "node_modules/log-update/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "node_modules/path-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", + "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", "dev": true, "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, "engines": { "node": ">=18" }, @@ -2841,403 +5864,446 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update/node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/log-update/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=4" } }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" }, - "engines": { - "node": ">=18" + "bin": { + "prebuild-install": "bin.js" }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "engines": { + "node": ">=10" } }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.8.0" } }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=18" + "node": ">=14" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, - "license": "ISC", + "license": "MIT", + "optional": true, "dependencies": { - "yallist": "^3.0.2" + "end-of-stream": "^1.1.0", + "once": "^1.3.1" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, - "license": "ISC", + "license": "BSD-3-Clause", "dependencies": { - "brace-expansion": "^1.1.7" + "side-channel": "^1.1.0" }, "engines": { - "node": "*" + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, - "license": "MIT" + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "node_modules/rc-config-loader": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.4.tgz", + "integrity": "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "json5": "^2.2.3", + "require-from-string": "^2.0.2" + } }, - "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/openai": { - "version": "6.35.0", - "resolved": "https://registry.npmmirror.com/openai/-/openai-6.35.0.tgz", - "integrity": "sha512-L/skwIGnt5xQZHb0UfTu9uAUKbis3ehKypOuJKi20QvG7UStV6C8IC3myGYHcdiF4kms/bAvOJ9UqqNWqi8x/Q==", - "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" + "node_modules/react-devtools-core": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-7.0.1.tgz", + "integrity": "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" }, "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" }, "peerDependenciesMeta": { - "ws": { + "bufferutil": { "optional": true }, - "zod": { + "utf-8-validate": { "optional": true } } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "scheduler": "^0.27.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "yocto-queue": "^0.1.0" + "mute-stream": "~0.0.4" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, + "node_modules/read-package-up": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", + "integrity": "sha512-Q5hMVBYur/eQNWDdbF4/Wqqr9Bjvtrw2kjGxxBbKLbx8bVCL8gcArjTy8zDUuLGQicftpMuU0riQNcAsbtOVsw==", "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "find-up-simple": "^1.0.1", + "read-pkg": "^10.0.0", + "type-fest": "^5.2.0" }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", + "node_modules/read-package-up/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", "dependencies": { - "callsites": "^3.0.0" + "lru-cache": "^11.1.0" }, "engines": { - "node": ">=6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", - "license": "MIT", + "node_modules/read-package-up/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "20 || >=22" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", + "node_modules/read-package-up/node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, "engines": { - "node": ">=8" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, + "node_modules/read-package-up/node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", - "dev": true, - "license": "BlueOak-1.0.0", + "node_modules/read-package-up/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": "20 || >=22" + "node": ">=10" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, + "node_modules/read-package-up/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", "dev": true, "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, "engines": { - "node": ">= 0.8.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "node_modules/read-pkg/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=14" + "node": ">=16" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 6" } }, - "node_modules/react-reconciler": { - "version": "0.33.0", - "resolved": "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.33.0.tgz", - "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, "engines": { "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^19.2.0" } }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", @@ -3245,19 +6311,9 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/restore-cursor": { "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", "license": "MIT", "dependencies": { @@ -3271,22 +6327,130 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", - "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true, "license": "MIT" }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", - "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/secretlint": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", + "integrity": "sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@secretlint/config-creator": "^10.2.2", + "@secretlint/formatter": "^10.2.2", + "@secretlint/node": "^10.2.2", + "@secretlint/profiler": "^10.2.2", + "debug": "^4.4.1", + "globby": "^14.1.0", + "read-pkg": "^9.0.1" + }, + "bin": { + "secretlint": "bin/secretlint.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/section-matter": { "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/section-matter/-/section-matter-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", "license": "MIT", "dependencies": { @@ -3294,51 +6458,202 @@ "kind-of": "^6.0.0" }, "engines": { - "node": ">=4" + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/signal-exit": { "version": "3.0.7", - "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/slice-ansi": { "version": "9.0.0", - "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-9.0.0.tgz", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", "license": "MIT", "dependencies": { @@ -3352,15 +6667,59 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "license": "CC0-1.0" + }, "node_modules/sprintf-js": { "version": "1.0.3", - "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, "node_modules/stack-utils": { "version": "2.0.6", - "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "license": "MIT", "dependencies": { @@ -3370,9 +6729,29 @@ "node": ">=10" } }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", - "resolved": "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", "dev": true, "license": "MIT", @@ -3382,7 +6761,7 @@ }, "node_modules/string-width": { "version": "8.2.1", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "license": "MIT", "dependencies": { @@ -3398,7 +6777,7 @@ }, "node_modules/strip-ansi": { "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { @@ -3413,7 +6792,7 @@ }, "node_modules/strip-bom-string": { "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", "license": "MIT", "engines": { @@ -3422,7 +6801,7 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", @@ -3433,9 +6812,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/structured-source": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", + "integrity": "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boundary": "^2.0.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", @@ -3446,593 +6835,365 @@ "node": ">=8" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terminal-size": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/terminal-size/-/terminal-size-4.0.1.tgz", - "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=14.18" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinygradient": { - "version": "1.1.5", - "resolved": "https://registry.npmmirror.com/tinygradient/-/tinygradient-1.1.5.tgz", - "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", - "license": "MIT", - "dependencies": { - "@types/tinycolor2": "^1.4.0", - "tinycolor2": "^1.0.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=18" + "node": ">=10.0.0" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], + "node_modules/table/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], + "node_modules/table/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/table/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=18" + "node": ">=8" } }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" } }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, "engines": { - "node": ">=18" + "node": ">=6" } }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" + }, "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "license": "MIT" + }, + "node_modules/textextensions": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-6.11.0.tgz", + "integrity": "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==", + "dev": true, + "license": "Artistic-2.0", + "dependencies": { + "editions": "^6.21.0" + }, "engines": { - "node": ">=18" + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, "engines": { - "node": ">=18" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" } }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], "engines": { - "node": ">=18" + "node": ">=14.14" } }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "is-number": "^7.0.0" + }, "engines": { - "node": ">=18" + "node": ">=8.0" } }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, "engines": { - "node": ">=18" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": ">=18" + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "node": "*" } }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", @@ -4044,9 +7205,9 @@ } }, "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -4058,9 +7219,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, "node_modules/typescript": { "version": "6.0.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", @@ -4073,16 +7246,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz", - "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.2", - "@typescript-eslint/parser": "8.59.2", - "@typescript-eslint/typescript-estree": "8.59.2", - "@typescript-eslint/utils": "8.59.2" + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4096,16 +7269,61 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ @@ -4136,7 +7354,7 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", @@ -4144,9 +7362,71 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/version-range": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", + "integrity": "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==", + "dev": true, + "license": "Artistic-2.0", + "engines": { + "node": ">=4" + }, + "funding": { + "url": "https://bevry.me/fund" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", @@ -4162,7 +7442,7 @@ }, "node_modules/widest-line": { "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/widest-line/-/widest-line-6.0.0.tgz", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", "license": "MIT", "dependencies": { @@ -4177,7 +7457,7 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", @@ -4187,7 +7467,7 @@ }, "node_modules/wrap-ansi": { "version": "10.0.0", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "license": "MIT", "dependencies": { @@ -4202,10 +7482,30 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4223,17 +7523,66 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "optional": true, @@ -4247,9 +7596,75 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yauzl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", @@ -4262,13 +7677,13 @@ }, "node_modules/yoga-layout": { "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/yoga-layout/-/yoga-layout-3.2.1.tgz", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" }, "node_modules/zod": { "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { @@ -4277,7 +7692,7 @@ }, "node_modules/zod-validation-error": { "version": "4.0.2", - "resolved": "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", "dev": true, "license": "MIT", @@ -4287,6 +7702,104 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "packages/cli": { + "name": "@vegamo/deepcode-cli", + "version": "0.1.33", + "license": "MIT", + "dependencies": { + "@vegamo/deepcode-core": "file:../core", + "chalk": "^5.6.2", + "gradient-string": "^3.0.0", + "ignore": "^7.0.5", + "ink": "^7.0.4", + "ink-gradient": "^4.0.1", + "react": "^19.2.5", + "read-package-up": "^12.0.0", + "yargs": "^18.0.0" + }, + "bin": { + "deepcode": "dist/cli.js" + }, + "devDependencies": { + "@types/yargs": "^17.0.35" + }, + "engines": { + "node": ">=22" + } + }, + "packages/cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/cli/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "packages/core": { + "name": "@vegamo/deepcode-core", + "version": "0.1.33", + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "ejs": "^5.0.2", + "gray-matter": "^4.0.3", + "ignore": "^7.0.5", + "openai": "^6.35.0", + "undici": "^7.25.0", + "zod": "^4.4.3" + } + }, + "packages/core/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "packages/core/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "packages/vscode-ide-companion": { + "name": "deepcode-vscode", + "version": "0.1.23", + "license": "MIT", + "dependencies": { + "@vegamo/deepcode-core": "file:../core", + "markdown-it": "^14.2.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.1", + "@types/vscode": "^1.85.0", + "@vscode/vsce": "^3.6.0" + }, + "engines": { + "vscode": "^1.85.0" + } } } } diff --git a/package.json b/package.json index c438d689..d4f502b4 100644 --- a/package.json +++ b/package.json @@ -1,54 +1,35 @@ { - "name": "@vegamo/deepcode-cli", - "version": "0.1.22", - "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", + "name": "@vegamo/deepcode", + "description": "Deep Code — CLI, core library, and VSCode companion", "license": "MIT", + "packageManager": "npm@10.9.4", "type": "module", + "workspaces": [ + "packages/*" + ], "repository": { "type": "git", - "url": "https://github.com/lessweb/deepcode-cli.git" - }, - "homepage": "https://deepcode.vegamo.cn", - "bin": { - "deepcode": "./dist/cli.js" - }, - "main": "./dist/cli.js", - "files": [ - "dist/cli.js", - "templates/tools/**", - "templates/prompts/**", - "templates/skills/**", - "README.md", - "LICENSE" - ], - "engines": { - "node": ">=22" + "url": "git+https://github.com/lessweb/deepcode-cli.git" }, "scripts": { - "typecheck": "tsc -p ./ --noEmit", - "bundle": "esbuild ./src/cli.tsx --bundle --platform=node --format=esm --target=node18 --outfile=dist/cli.js --banner:js=\"#!/usr/bin/env node\" --jsx=automatic --jsx-import-source=react --packages=external --log-override:empty-import-meta=silent", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "format": "prettier --write 'src/**/*.{ts,tsx}'", - "format:check": "prettier --check 'src/**/*.{ts,tsx}'", + "typecheck": "npm run typecheck --workspaces --if-present", + "generate": "node scripts/generate-git-commit-info.js", + "bundle": "npm run generate && node scripts/esbuild.config.js && node scripts/copy-bundle-assets.js", + "lint": "eslint \"packages/*/src/**/*.{ts,tsx}\" \"scripts/*.js\"", + "lint:fix": "eslint \"packages/*/src/**/*.{ts,tsx}\" \"scripts/*.js\" --fix", + "format": "prettier --write \"packages/*/src/**/*.{ts,tsx}\" \"scripts/*.js\"", + "format:check": "prettier --check \"packages/*/src/**/*.{ts,tsx}\" \"scripts/*.js\"", "check": "npm run typecheck && npm run lint && npm run format:check", - "build": "npm run check && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", - "test": "node src/tests/run-tests.mjs", - "test:single": "tsx --test", - "prepack": "npm run build", - "prepare": "husky" - }, - "dependencies": { - "chalk": "^5.6.2", - "ejs": "^5.0.2", - "gradient-string": "^3.0.0", - "gray-matter": "^4.0.3", - "ignore": "^7.0.5", - "ink": "^7.0.1", - "ink-gradient": "^4.0.0", - "openai": "^6.35.0", - "react": "^19.2.5", - "zod": "^4.4.3" + "clean": "node scripts/clean.js", + "build": "node scripts/build.js", + "build:vscode": "node scripts/build-vscode-companion.js", + "start": "node scripts/start.js", + "build-and-start": "npm run build && npm run start", + "test": "npm run test --workspaces --if-present", + "release:version": "node scripts/version.js", + "prepare:package": "node scripts/prepare-package.js", + "prepare:vscode": "node scripts/prepare-vscode.js", + "prepare": "husky && npm run build && npm run bundle" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -63,6 +44,7 @@ "husky": "^9.1.7", "lint-staged": "^17.0.4", "prettier": "^3.8.3", + "react-devtools-core": "^7.0.1", "tsx": "^4.21.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2" diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..c860e690 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,49 @@ +{ + "name": "@vegamo/deepcode-cli", + "version": "0.1.33", + "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/lessweb/deepcode-cli.git" + }, + "homepage": "https://deepcode.vegamo.cn", + "bin": { + "deepcode": "./dist/cli.js" + }, + "main": "./dist/cli.js", + "files": [ + "dist/cli.js", + "dist/chunks/**", + "dist/templates/**", + "dist/bundled/**", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=22" + }, + "scripts": { + "typecheck": "tsc -p ./ --noEmit", + "bundle": "node ../../scripts/esbuild.config.js", + "build": "npm run typecheck && npm run bundle && node ../../scripts/copy-bundle-assets.js && node -e \"import('node:fs').then(f => f.chmodSync('dist/cli.js', 0o755))\"", + "prepublishOnly": "npm run build", + "format": "prettier --write .", + "test": "node src/tests/run-tests.mjs" + }, + "dependencies": { + "@vegamo/deepcode-core": "file:../core", + "chalk": "^5.6.2", + "gradient-string": "^3.0.0", + "ignore": "^7.0.5", + "ink": "^7.0.4", + "ink-gradient": "^4.0.1", + "react": "^19.2.5", + "read-package-up": "^12.0.0", + "yargs": "^18.0.0" + }, + "devDependencies": { + "@types/yargs": "^17.0.35" + } +} diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts new file mode 100644 index 00000000..b86eda41 --- /dev/null +++ b/packages/cli/src/cli-args.ts @@ -0,0 +1,160 @@ +/** + * CLI argument parsing helpers. + * Uses yargs for robust argument parsing and validation. + */ + +import type { Argv } from "yargs"; +import Yargs from "yargs"; +import { getCliVersion } from "./utils/version"; +import { writeStderrLine } from "./utils/stdio-helpers"; +import { hideBin } from "yargs/helpers"; + +// UUID v4 regex pattern for validation +const SESSION_ID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Validates if a string is a valid session ID format. + */ +export function isValidSessionId(value: string): boolean { + return SESSION_ID_REGEX.test(value); +} + +export interface ParsedCliArgs { + /** Prompt text from -p / --prompt */ + prompt: string | undefined; + /** + * Resume session identifier: + * - `undefined` — --resume was not used + * - `true` — --resume was used without a session ID (show picker) + * - `string` — --resume was used + */ + resume: string | true | undefined; + /** True when --version / -v was passed */ + version: boolean; + /** True when --help / -h was passed */ + help: boolean; +} + +const EPILOG = [ + "Configuration:", + " ~/.deepcode/settings.json User-level API key, model, base URL", + " ./.deepcode/settings.json Project-level settings", + " ./.deepcode/skills/*/SKILL.md Project-level native skills", + " ./.agents/skills/*/SKILL.md Project-level interoperable skills", + " ~/.deepcode/skills/*/SKILL.md User-level native skills", + " ~/.agents/skills/*/SKILL.md User-level interoperable skills", + "", + "Inside the TUI:", + " enter Send the prompt", + " shift+enter Insert a newline", + " home/end Move within the current line", + " alt+left/right Move by word", + " ctrl+w Delete the previous word", + " ctrl+v Paste an image from the clipboard", + " ctrl+x Clear pasted images", + " esc Interrupt the current model turn", + " / Open the skills/commands menu", + " /skills List available skills", + " /model Select model, thinking mode and effort control", + " /new Start a fresh conversation", + " /init Initialize an AGENTS.md file with instructions for LLM", + " /resume Pick a previous conversation to continue", + " /continue Continue the active conversation, or resume one if empty", + " /undo Restore code and/or conversation to a previous point", + " /mcp Show MCP server status and available tools", + " /raw Toggle display mode for viewing or collapsing reasoning content", + " /exit Quit", + " ctrl+d twice Quit", +].join("\n"); + +async function configureYargs(argv?: string[]) { + const rawArgv = argv ?? hideBin(process.argv); + const yargsInstance = Yargs(rawArgv) + .locale("en") + .scriptName("deepcode") + .usage( + "Usage: $0 [options] [command]\n\nDeep Code - Launch an interactive CLI, use -p/--prompt for non-interactive mode" + ) + .command("$0 [query..]", "Launch Deep Code CLI", (yargsInstance: Argv) => + yargsInstance + .option("prompt", { + alias: "p", + type: "string", + describe: "Submit a prompt on launch", + }) + .option("resume", { + alias: "r", + type: "string", + describe: "Resume a specific session by its ID. Use without an ID to show session picker.", + }) + .check((argv: { [x: string]: unknown }) => { + const query = argv["query"] as string | string[] | undefined; + const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query; + + if (argv["prompt"] && hasPositionalQuery) { + return "Cannot use both a positional prompt and the --prompt (-p) flag together"; + } + // bare --resume conflicts with --prompt + if (argv["resume"] === "" && argv["prompt"]) { + return "Cannot use --resume without a session ID together with --prompt.\nUse --resume -p to resume a session and send a prompt."; + } + // validate --resume format if provided + if (argv["resume"] && argv["resume"] !== "" && !isValidSessionId(argv["resume"] as string)) { + return `Invalid session ID: "${argv["resume"]}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; + } + // empty prompt is meaningless + if (argv["prompt"] === "") { + return "--prompt / -p requires a non-empty value."; + } + return true; + }) + ) + .example("deepcode", "Launch the interactive TUI in the current directory") + .example("deepcode -p ", "Launch with a pre-filled prompt") + .example("deepcode -r, --resume [sessionId]", "Resume a session or show session picker") + .epilog(EPILOG) + .strict() + .demandCommand(0, 0) + .wrap(Math.min(process.stdout.columns || 80, 120)); + yargsInstance + .version(await getCliVersion()) + .alias("v", "version") + .help() + .alias("h", "help"); + yargsInstance.wrap(yargsInstance.terminalWidth()); + return yargsInstance; +} + +/** + * Parse CLI arguments with validation. + * + * On validation failure the `.fail()` handler prints the error, shows help, + * and calls `process.exit(1)`, so this function always either returns a + * valid `ParsedCliArgs` or terminates the process. + */ +export async function parseArguments(argv?: string[]): Promise { + const y = (await configureYargs(argv)).exitProcess(false).fail((msg, _err, yargs) => { + writeStderrLine(msg || _err?.message || "Unknown error"); + yargs.showHelp(); + process.exit(1); + }); + + const parsed = y.parseSync() as Record; + + const resumeRaw = parsed.resume as string | undefined; + let resume: ParsedCliArgs["resume"]; + if (resumeRaw === undefined) { + resume = undefined; + } else if (resumeRaw === "") { + resume = true; + } else { + resume = resumeRaw; + } + + return { + prompt: parsed.prompt as string | undefined, + resume, + version: parsed.version === true, + help: parsed.help === true, + }; +} diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx new file mode 100644 index 00000000..80b11f08 --- /dev/null +++ b/packages/cli/src/cli.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { render } from "ink"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { setShellIfWindows, getProjectCode } from "@vegamo/deepcode-core"; +import { checkForNpmUpdate, promptForPendingUpdate } from "./common/update-check"; +import { AppContainer } from "./ui"; +import { parseArguments } from "./cli-args"; +import { writeStderrLine, writeStdoutLine } from "./utils/stdio-helpers"; +import { getPackageJson } from "./utils/package"; +import { CLI_VERSION } from "./generated/git-commit"; + +void main(); + +async function main(): Promise { + const packageInfo = await getPackageJson(); + const parsed = await parseArguments(); + + // --version and --help are handled by yargs internally (prints output as side effect) + // but with .exitProcess(false) we need to exit manually. + if (parsed.version || parsed.help) { + process.exit(0); + } + + // Configure Windows shell AFTER --version/--help handling. + // On Windows without Git Bash, setShellIfWindows() throws and calls process.exit(1). + // If called before argument parsing, --help and --version would fail on those machines. + configureWindowsShell(); + + let initialPrompt = parsed.prompt; + let resumeSessionId = parsed.resume; + const projectRoot = process.cwd(); + + if (!process.stdin.isTTY) { + writeStderrLine("deepcode requires an interactive terminal (TTY). Re-run from a real terminal session.\n"); + process.exit(1); + } + + // Validate --resume before entering TUI + if (typeof resumeSessionId === "string") { + const projectCode = getProjectCode(projectRoot); + const indexPath = join(homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); + try { + const index = JSON.parse(readFileSync(indexPath, "utf-8")); + const found = + Array.isArray(index?.entries) && index.entries.some((e: { id: string }) => e.id === resumeSessionId); + if (!found) { + writeStderrLine(`No saved session found with ID "${resumeSessionId}".\n`); + process.exit(1); + } + } catch { + writeStderrLine(`No saved session found with ID "${resumeSessionId}".\n`); + process.exit(1); + } + } + + const updatePromptResult = await promptForPendingUpdate(packageInfo); + if (updatePromptResult.installed) { + process.exit(0); + } + + const restartRef: { current: (() => void) | null } = { current: null }; + + function startApp(): void { + let restarting = false; + const appInitialPrompt = initialPrompt; + initialPrompt = undefined; + const appResumeSessionId = resumeSessionId; + resumeSessionId = undefined; + const inkInstance = render( + restartRef.current?.()} + />, + { exitOnCtrlC: false } + ); + + restartRef.current = () => { + restarting = true; + writeStdoutLine("\u001B[2J\u001B[3J\u001B[H"); + inkInstance.unmount(); + startApp(); + }; + + inkInstance.waitUntilExit().then(() => { + if (!restarting) { + restartRef.current = null; + process.exit(0); + } + }); + } + + void checkForNpmUpdate(packageInfo); + + startApp(); +} + +/** + * Configure shell environment for Windows. + * Sets NoDefaultCurrentDirectoryInExePath and resolves Git Bash path. + * Must be called after --version/--help handling to avoid blocking those + * commands on Windows machines without Git Bash installed. + */ +function configureWindowsShell(): void { + process.env.NoDefaultCurrentDirectoryInExePath = "1"; + try { + setShellIfWindows(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + writeStderrLine(`deepcode: ${message}\n`); + process.exit(1); + } +} diff --git a/src/updateCheck.ts b/packages/cli/src/common/update-check.ts similarity index 93% rename from src/updateCheck.ts rename to packages/cli/src/common/update-check.ts index fcd9bfba..2dad85f8 100644 --- a/src/updateCheck.ts +++ b/packages/cli/src/common/update-check.ts @@ -4,14 +4,9 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { render, type Instance } from "ink"; -import chalk from "chalk"; -import { UpdatePrompt, type UpdatePromptChoice } from "./ui"; -import { killProcessTree } from "./common/process-tree"; - -export type PackageInfo = { - name: string; - version: string; -}; +import { UpdatePrompt, type UpdatePromptChoice } from "../ui"; +import { killProcessTree } from "@vegamo/deepcode-core"; +import type { PackageJson } from "../utils/package"; type UpdateState = { pending?: { @@ -27,15 +22,16 @@ const UPDATE_STATE_FILE = "update-check.json"; const NPM_VIEW_TIMEOUT_MS = 5000; const MAX_NPM_VIEW_OUTPUT_CHARS = 64 * 1024; const TENCENT_MIRROR_REGISTRY = "https://mirrors.cloud.tencent.com/npm/"; +export const UPDATE_SUCCESS_MESSAGE = "🎉 Update ran successfully! Please restart Deep Code."; -export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise<{ installed: boolean }> { +export async function promptForPendingUpdate(packageInfo: PackageJson): Promise<{ installed: boolean }> { const state = readUpdateState(); const pending = state.pending; if (!pending) { return { installed: false }; } - if (compareVersions(packageInfo.version, pending.latestVersion) >= 0) { + if (compareVersions(packageInfo.version!, pending.latestVersion) >= 0) { writeUpdateState({ ...state, pending: null }); return { installed: false }; } @@ -48,7 +44,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< const installSpec = `${pending.packageName}@${pending.latestVersion}`; const installCommand = `npm install -g ${installSpec}`; const choice = await promptUpdateChoice({ - currentVersion: packageInfo.version, + currentVersion: packageInfo.version!, latestVersion: pending.latestVersion, installCommand, }); @@ -57,9 +53,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< const ok = await runNpmInstallGlobal(installSpec); if (ok) { writeUpdateState({ ...state, pending: null }); - process.stdout.write( - `\n${chalk.red("Deep Code has been updated. Please restart the CLI to use the new version.")}\n\n` - ); + process.stdout.write(`${UPDATE_SUCCESS_MESSAGE}\n\n`); } return { installed: ok }; } @@ -74,7 +68,7 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< return { installed: false }; } -export async function checkForNpmUpdate(packageInfo: PackageInfo): Promise { +export async function checkForNpmUpdate(packageInfo: PackageJson): Promise { if (!packageInfo.name || !packageInfo.version) { return; } diff --git a/src/tests/askUserQuestion.test.ts b/packages/cli/src/tests/ask-user-question.test.ts similarity index 98% rename from src/tests/askUserQuestion.test.ts rename to packages/cli/src/tests/ask-user-question.test.ts index f7543512..7b4f387e 100644 --- a/src/tests/askUserQuestion.test.ts +++ b/packages/cli/src/tests/ask-user-question.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, formatAskUserQuestionDecline } from "../ui"; -import type { SessionMessage } from "../session"; +import type { SessionMessage } from "@vegamo/deepcode-core"; function message(content: unknown): SessionMessage { const now = "2026-04-29T00:00:00.000Z"; diff --git a/packages/cli/src/tests/cli-args.test.ts b/packages/cli/src/tests/cli-args.test.ts new file mode 100644 index 00000000..fe90eeed --- /dev/null +++ b/packages/cli/src/tests/cli-args.test.ts @@ -0,0 +1,211 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { parseArguments, isValidSessionId } from "../cli-args"; + +// ── isValidSessionId ───────────────────────────────────────────────────────── + +test("isValidSessionId accepts valid UUID", () => { + assert.ok(isValidSessionId("0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6")); +}); + +test("isValidSessionId rejects invalid format", () => { + assert.ok(!isValidSessionId("not-a-uuid")); + assert.ok(!isValidSessionId("")); + assert.ok(!isValidSessionId("abc")); +}); + +// ── parseArguments: basic parsing ────────────────────────────────────────────── + +test("parseArguments returns prompt after -p", async () => { + const r = await parseArguments(["-p", "hello world"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, "hello world"); +}); + +test("parseArguments returns prompt after --prompt", async () => { + const r = await parseArguments(["--prompt", "hello world"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, "hello world"); +}); + +test("parseArguments returns undefined prompt when -p is not present", async () => { + const r = await parseArguments(["--resume"]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, undefined); +}); + +test("parseArguments returns session ID after --resume", async () => { + const r = await parseArguments(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); +}); + +test("parseArguments returns true when --resume has no value", async () => { + const r = await parseArguments(["--resume"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, true); +}); + +test("parseArguments returns undefined resume when not present", async () => { + const r = await parseArguments(["-p", "test"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, undefined); +}); + +test("parseArguments returns defaults for empty args", async () => { + const r = await parseArguments([]); + assert.ok(!("message" in r)); + assert.equal(r.prompt, undefined); + assert.equal(r.resume, undefined); + assert.equal(r.version, false); + assert.equal(r.help, false); +}); + +// ── parseArguments: -r alias ─────────────────────────────────────────────────── + +test("parseArguments returns session ID after -r", async () => { + const r = await parseArguments(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); +}); + +test("parseArguments returns true when -r has no value", async () => { + const r = await parseArguments(["-r"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, true); +}); + +test("parseArguments handles -r combined with -p", async () => { + const r = await parseArguments(["-r", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6", "-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); + assert.equal(r.prompt, "hello"); +}); + +// ── parseArguments: --version / --help ───────────────────────────────────────── + +test("parseArguments detects --version", async () => { + const r = await parseArguments(["--version"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.help, false); +}); + +test("parseArguments detects -v", async () => { + const r = await parseArguments(["-v"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); +}); + +test("parseArguments detects --help", async () => { + const r = await parseArguments(["--help"]); + assert.ok(!("message" in r)); + assert.equal(r.help, true); + assert.equal(r.version, false); +}); + +test("parseArguments detects -h", async () => { + const r = await parseArguments(["-h"]); + assert.ok(!("message" in r)); + assert.equal(r.help, true); +}); + +test("parseArguments version and help are false when not passed", async () => { + const r = await parseArguments(["-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.version, false); + assert.equal(r.help, false); +}); + +test("parseArguments handles -v combined with -r (both flags set)", async () => { + const r = await parseArguments(["-v", "-r", "abc"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.resume, "abc"); +}); + +// ── parseArguments: combined usage ───────────────────────────────────────────── + +test("parseArguments handles --resume combined with -p", async () => { + const r = await parseArguments(["--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6", "-p", "hello"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); + assert.equal(r.prompt, "hello"); +}); + +test("parseArguments handles -p before --resume ", async () => { + const r = await parseArguments(["-p", "hello", "--resume", "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"]); + assert.ok(!("message" in r)); + assert.equal(r.resume, "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); + assert.equal(r.prompt, "hello"); +}); + +test("parseArguments --version takes precedence over --help", async () => { + const r = await parseArguments(["--version", "--help"]); + assert.ok(!("message" in r)); + assert.equal(r.version, true); + assert.equal(r.help, true); +}); + +// ── parseArguments: error cases (mock process.exit) ──────────────────────────── +// Command-level and top-level errors both call process.exit(1) via yargs .fail(). + +function withMockedExit(fn: (exitSpy: { calls: number[] }) => Promise): Promise { + const original = process.exit; + const stderrWrite = process.stderr.write; + // Suppress yargs help/error output during tests + process.stderr.write = (() => true) as typeof process.stderr.write; + const exitSpy: { calls: number[] } = { calls: [] }; + process.exit = ((code?: number) => { + exitSpy.calls.push(code ?? 0); + throw new Error(`process.exit(${code})`); + }) as typeof process.exit; + return fn(exitSpy).finally(() => { + process.exit = original; + process.stderr.write = stderrWrite; + }); +} + +test("parseArguments exits on unknown flags", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["--unknown-flag"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on bare -r with -p", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["-r", "-p", "hello"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on empty -p value", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["-p", ""]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); + +test("parseArguments exits on invalid --resume session ID", async () => { + await withMockedExit(async (exitSpy) => { + try { + await parseArguments(["--resume", "not-a-uuid"]); + } catch { + /* expected */ + } + assert.ok(exitSpy.calls.length >= 1); + }); +}); diff --git a/src/tests/clipboard.test.ts b/packages/cli/src/tests/clipboard.test.ts similarity index 90% rename from src/tests/clipboard.test.ts rename to packages/cli/src/tests/clipboard.test.ts index dbe9ff95..3ca892eb 100644 --- a/src/tests/clipboard.test.ts +++ b/packages/cli/src/tests/clipboard.test.ts @@ -5,7 +5,7 @@ import * as os from "os"; import * as path from "path"; // eslint-disable-next-line @typescript-eslint/consistent-type-imports -type ClipboardModule = typeof import("../ui/clipboard"); +type ClipboardModule = typeof import("../ui/core/clipboard"); const ORIGINAL_PATH = process.env.PATH; const ORIGINAL_PLATFORM = process.platform; @@ -30,7 +30,7 @@ function withPlatform(platform: NodeJS.Platform, fn: () => T): T { test("readClipboardImage returns null when no clipboard helpers are installed", async () => { // Reload module so it picks up the patched PATH at spawn time. - const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; + const moduleUrl = new URL(`../ui/core/clipboard.ts?t=${Date.now()}`, import.meta.url).href; const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; const result = withCleanPath(() => readClipboardImage()); assert.equal(result, null); @@ -63,7 +63,7 @@ test( { mode: 0o755 } ); - const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; + const moduleUrl = new URL(`../ui/core/clipboard.ts?t=${Date.now()}`, import.meta.url).href; const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; process.env.PATH = binDir; diff --git a/src/tests/dropdownMenu.test.ts b/packages/cli/src/tests/dropdown-menu.test.ts similarity index 98% rename from src/tests/dropdownMenu.test.ts rename to packages/cli/src/tests/dropdown-menu.test.ts index 3e4e3ef5..e6a0a1a4 100644 --- a/src/tests/dropdownMenu.test.ts +++ b/packages/cli/src/tests/dropdown-menu.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { calculateVisibleStart } from "../ui/DropdownMenu"; +import { calculateVisibleStart } from "../ui/components/DropdownMenu"; test("calculateVisibleStart centers active item when possible", () => { // 10 items, max 5 visible, active index 4 (middle) diff --git a/src/tests/exitSummary.test.ts b/packages/cli/src/tests/exit-summary.test.ts similarity index 64% rename from src/tests/exitSummary.test.ts rename to packages/cli/src/tests/exit-summary.test.ts index 5ea4b579..45317b1e 100644 --- a/src/tests/exitSummary.test.ts +++ b/packages/cli/src/tests/exit-summary.test.ts @@ -1,9 +1,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildExitSummaryText } from "../ui"; -import type { ModelUsage, SessionEntry } from "../session"; +import { buildExitSummaryText, buildResumeHintText } from "../ui"; +import type { ModelUsage, SessionEntry } from "@vegamo/deepcode-core"; -const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); +const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*[a-zA-Z]/g, ""); test("buildExitSummaryText only shows Goodbye and model usage with cached tokens", () => { const summary = stripAnsi( @@ -90,6 +90,55 @@ test("buildExitSummaryText does not derive usage rows from legacy aggregate usag assert.doesNotMatch(summary, /11,966/); }); +test("buildExitSummaryText does not show resume hint when sessionId is provided", () => { + const sessionId = "0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"; + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession(null), + sessionId, + }) + ); + + assert.match(summary, /Goodbye!/); + assert.doesNotMatch(summary, /deepcode --resume 0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6/); + assert.doesNotMatch(summary, /To continue this session/); +}); + +test("buildExitSummaryText does not show resume hint when sessionId is omitted", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: buildSession(null), + }) + ); + + assert.match(summary, /Goodbye!/); + assert.doesNotMatch(summary, /deepcode --resume/); + assert.doesNotMatch(summary, /To continue this session/); +}); + +test("buildExitSummaryText does not show resume hint with null session", () => { + const summary = stripAnsi( + buildExitSummaryText({ + session: null, + sessionId: "test-session-id", + }) + ); + + assert.match(summary, /Goodbye!/); + assert.doesNotMatch(summary, /deepcode --resume test-session-id/); + assert.doesNotMatch(summary, /To continue this session/); +}); + +test("buildResumeHintText shows resume command when sessionId is provided", () => { + const hint = stripAnsi(buildResumeHintText("0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6") ?? ""); + + assert.equal(hint, "To continue this session, run deepcode --resume 0a5cb7a5-c39d-4c39-a11b-05f8b22b8df6"); +}); + +test("buildResumeHintText returns null when sessionId is omitted", () => { + assert.equal(buildResumeHintText(), null); +}); + function buildSession(usage: ModelUsage | null, usagePerModel: Record | null = null): SessionEntry { return { id: "session-1", diff --git a/src/tests/fileMentions.test.ts b/packages/cli/src/tests/file-mentions.test.ts similarity index 85% rename from src/tests/fileMentions.test.ts rename to packages/cli/src/tests/file-mentions.test.ts index b382eeed..36d2c21d 100644 --- a/src/tests/fileMentions.test.ts +++ b/packages/cli/src/tests/file-mentions.test.ts @@ -10,7 +10,7 @@ import { replaceCurrentFileMentionToken, scanFileMentionItems, type FileMentionItem, -} from "../ui/fileMentions"; +} from "../ui/core/file-mentions"; test("getCurrentFileMentionToken detects bare @file tokens under the cursor", () => { assert.deepEqual(getCurrentFileMentionToken({ text: "review @src/app.ts please", cursor: 10 }), { @@ -86,18 +86,62 @@ test("scanFileMentionItems returns relative slash-separated files and directorie try { fs.mkdirSync(path.join(root, "src")); fs.writeFileSync(path.join(root, "src", "index.ts"), ""); - fs.mkdirSync(path.join(root, "node_modules")); - fs.writeFileSync(path.join(root, "node_modules", "ignored.js"), ""); + fs.mkdirSync(path.join(root, "vendor")); + fs.writeFileSync(path.join(root, "vendor", "dep.js"), ""); assert.deepEqual( scanFileMentionItems(root).map((item) => item.path), - ["node_modules/", "node_modules/ignored.js", "src/", "src/index.ts"] + ["src/", "src/index.ts", "vendor/", "vendor/dep.js"] ); } finally { fs.rmSync(root, { recursive: true, force: true }); } }); +test("scanFileMentionItems applies default noisy-directory ignores when no gitignore is applicable", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + for (const directory of [ + ".next", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "build", + "dist", + "node_modules", + "out", + "target", + ]) { + fs.mkdirSync(path.join(root, directory)); + fs.writeFileSync(path.join(root, directory, "ignored.txt"), ""); + } + fs.mkdirSync(path.join(root, ".config")); + fs.writeFileSync(path.join(root, ".config", "settings.json"), ""); + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "src", "index.ts"), ""); + + assert.deepEqual( + scanFileMentionItems(root).map((item) => item.path), + [".config/", ".config/settings.json", "src/", "src/index.ts"] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("scanFileMentionItems default max item cap is above 2000", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); + try { + for (let index = 0; index < 2001; index++) { + fs.writeFileSync(path.join(root, `file-${index.toString().padStart(4, "0")}.txt`), ""); + } + + assert.equal(scanFileMentionItems(root).length, 2001); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + test("scanFileMentionItems respects project gitignore patterns inside git repositories", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-file-mentions-")); try { diff --git a/src/tests/loadingText.test.ts b/packages/cli/src/tests/loading-text.test.ts similarity index 100% rename from src/tests/loadingText.test.ts rename to packages/cli/src/tests/loading-text.test.ts diff --git a/packages/cli/src/tests/markdown.test.ts b/packages/cli/src/tests/markdown.test.ts new file mode 100644 index 00000000..73e2f93c --- /dev/null +++ b/packages/cli/src/tests/markdown.test.ts @@ -0,0 +1,108 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { renderMarkdown, renderMarkdownSegments } from "../ui"; + +function stripAnsi(text: string): string { + return text.replace(/\[[0-9;]*m/g, ""); +} + +function visualWidth(text: string): number { + let width = 0; + for (const ch of text) { + const code = ch.codePointAt(0) ?? 0; + width += + ch.length >= 2 || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xff00 && code <= 0xffe6) + ? 2 + : 1; + } + return width; +} + +test("renderMarkdown returns empty string for empty input", () => { + assert.equal(renderMarkdown(""), ""); +}); + +test("renderMarkdown preserves heading text", () => { + const result = stripAnsi(renderMarkdown("# Title")); + assert.equal(result.includes("Title"), true); + assert.equal(result.includes("#"), true); +}); + +test("renderMarkdown preserves code fences with language tag", () => { + const result = stripAnsi(renderMarkdown("```js\nconsole.log(1);\n```")); + assert.equal(result.includes("[js]"), true); + assert.equal(result.includes("console.log(1);"), true); +}); + +test("renderMarkdown styles inline code without removing it", () => { + const result = stripAnsi(renderMarkdown("Use `npm install` first.")); + assert.equal(result.includes("npm install"), true); +}); + +test("renderMarkdown preserves underscores inside inline code", () => { + const source = + "Use `redo_completed_tasks2_1min`, replace `execute_query` with `select_one`/`select_all`, and check `ocr_result`."; + const result = stripAnsi(renderMarkdown(source)); + assert.equal( + result, + "Use redo_completed_tasks2_1min, replace execute_query with select_one/select_all, and check ocr_result." + ); +}); + +test("renderMarkdown preserves underscores in plain identifiers", () => { + const result = stripAnsi(renderMarkdown("Check redo_completed_tasks2_1min and ocr_result values.")); + assert.equal(result, "Check redo_completed_tasks2_1min and ocr_result values."); +}); + +test("renderMarkdown keeps bullet markers", () => { + const result = stripAnsi(renderMarkdown("- item one\n- item two")); + assert.equal(result.includes("- item one"), true); + assert.equal(result.includes("- item two"), true); +}); + +test("renderMarkdown handles plain text unchanged in stripped form", () => { + const text = "hello world\nthis is a sentence"; + const result = stripAnsi(renderMarkdown(text)); + assert.equal(result, text); +}); + +test("renderMarkdownSegments renders CJK table cells within the requested width", () => { + const table = [ + "| 编号 | 状态 | 任务 | 备注 |", + "|---|---|---|---|", + "| 1 | ✅ | 写代码 | 这是一个很长很长的中文备注用于验证表格在终端宽度不足时是否能够自动换行而不是溢出 |", + ].join("\n"); + + const segment = renderMarkdownSegments(table, 60).find((item) => item.kind === "table"); + assert.ok(segment); + const lines = stripAnsi(segment.body).split("\n"); + assert.equal(lines[0].startsWith("┌"), true); + assert.equal(lines.at(-1)?.startsWith("└"), true); + assert.equal( + lines.every((line) => visualWidth(line) <= 60), + true + ); + assert.equal(lines.length > 4, true); +}); + +test("renderMarkdown preserves empty table cells", () => { + const result = stripAnsi(renderMarkdown("| A | B | C |\n|---|---|---|\n|x||z|", 80)); + const bodyRow = result.split("\n").find((line) => line.includes("x") && line.includes("z")); + assert.ok(bodyRow); + assert.equal((bodyRow.match(/│/g) ?? []).length, 4); +}); + +test("renderMarkdown keeps text separated from rendered table blocks", () => { + const result = stripAnsi(renderMarkdown("Before\n| A | B |\n|---|---|\n| 1 | 2 |\nAfter", 40)); + assert.equal(result.includes("Before\n┌"), true); + assert.equal(result.includes("┘\nAfter"), true); +}); + +test("renderMarkdown does not render tables inside code fences", () => { + const result = stripAnsi(renderMarkdown("```md\n| A | B |\n|---|---|\n| 1 | 2 |\n```", 40)); + assert.equal(result.includes("| A | B |"), true); + assert.equal(result.includes("┌"), false); +}); diff --git a/packages/cli/src/tests/message-view.test.ts b/packages/cli/src/tests/message-view.test.ts new file mode 100644 index 00000000..c1c2d69d --- /dev/null +++ b/packages/cli/src/tests/message-view.test.ts @@ -0,0 +1,344 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import React from "react"; +import { renderToString } from "ink"; +import { parseDiffPreview } from "../ui"; +import { MessageView, getPromptEchoContentWidth } from "../ui/components/MessageView"; +import { + buildThinkingSummary, + formatBashStatusParams, + formatToolStatusParams, + renderMessageToStdout, + getUpdatePlanPreviewLines, + parseToolPayload, +} from "../ui/components/MessageView/utils"; +import { RawMode } from "../ui/contexts"; +import type { SessionMessage } from "@vegamo/deepcode-core"; +import type { ToolSummary } from "../ui/components/MessageView/types"; + +test("parseDiffPreview removes headers and classifies lines", () => { + const lines = parseDiffPreview( + ["--- a/file.txt", "+++ b/file.txt", "@@ -1,1 +1,1 @@", " context", "-old", "+new"].join("\n") + ); + + assert.deepEqual(lines, [ + { marker: " ", content: "context", kind: "context" }, + { marker: "-", content: "old", kind: "removed" }, + { marker: "+", content: "new", kind: "added" }, + ]); +}); + +test("parseDiffPreview keeps nonstandard context lines", () => { + const lines = parseDiffPreview("...\n+added"); + assert.deepEqual(lines, [ + { marker: " ", content: "...", kind: "context" }, + { marker: "+", content: "added", kind: "added" }, + ]); +}); + +test("MessageView summarizes thinking content across lines", () => { + assert.equal( + buildThinkingSummary("Plan:\n\nInspect the code and update tests", null, RawMode.Lite), + "Plan: Inspect the code and update tests" + ); +}); + +test("MessageView removes a trailing colon from thinking summary", () => { + assert.equal(buildThinkingSummary("Planning:", null, RawMode.Lite), "Planning"); +}); + +test("MessageView falls back to a reasoning placeholder for hidden reasoning content in Lite mode", () => { + assert.equal( + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Lite), + "(reasoning...)" + ); +}); + +test("MessageView shows full reasoning content in Normal/Raw mode", () => { + assert.equal( + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.None), + "hidden chain of thought" + ); + assert.equal( + buildThinkingSummary("", { reasoning_content: "hidden chain of thought" }, RawMode.Raw), + "hidden chain of thought" + ); +}); + +test("formatBashStatusParams compacts multi-line commands and keeps the final description", () => { + assert.equal( + formatBashStatusParams('python3 -c "\nprint(1)\nprint(2)\n" # Run inline script'), + 'python3 -c " ... " # Run inline script' + ); +}); + +test("formatToolStatusParams preserves compacted Bash params but truncates other tools", () => { + assert.equal( + formatToolStatusParams({ + name: "bash", + params: "cat <<'EOF'\nhello\nEOF # Print heredoc", + ok: true, + metadata: null, + }), + "cat <<'EOF' ... EOF # Print heredoc" + ); + assert.equal(formatToolStatusParams({ name: "read", params: "first\nsecond", ok: true, metadata: null }), "first"); +}); + +// --- renderMessageToStdout tests --- + +function makeSessionMessage(overrides: Partial & Pick): SessionMessage { + const now = new Date().toISOString(); + return { + id: overrides.id ?? `test-${Math.random().toString(36).slice(2)}`, + sessionId: overrides.sessionId ?? "test-session", + role: overrides.role, + content: overrides.content ?? null, + visible: overrides.visible ?? true, + compacted: overrides.compacted ?? false, + createTime: overrides.createTime ?? now, + updateTime: overrides.updateTime ?? now, + contentParams: overrides.contentParams ?? null, + messageParams: overrides.messageParams ?? null, + meta: overrides.meta, + html: overrides.html, + }; +} + +function stripAnsi(text: string): string { + return text.replace(/\u001b\[[0-9;]*m/g, ""); +} + +test("renderMessageToStdout returns empty for invisible messages", () => { + const msg = makeSessionMessage({ role: "user", content: "hello", visible: false }); + assert.equal(renderMessageToStdout(msg, RawMode.Raw), ""); +}); + +test("renderMessageToStdout renders user messages with > prefix", () => { + const msg = makeSessionMessage({ role: "user", content: "fix the bug" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("> fix the bug")); +}); + +test("renderMessageToStdout shows (no content) for empty user messages", () => { + const msg = makeSessionMessage({ role: "user", content: "" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("(no content)")); +}); + +test("MessageView echoes submitted user prompts with live prompt wrapping width", () => { + assert.equal(getPromptEchoContentWidth(8), 5); + + const msg = makeSessionMessage({ role: "user", content: "abcdefg" }); + const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 }); + + const text = stripAnsi(output); + assert.equal(text, " > abcde\n fg\n"); + assert.ok( + text + .trimEnd() + .split("\n") + .every((line) => line.length <= 8) + ); +}); + +test("MessageView echoes model changes with submitted prompt wrapping", () => { + const msg = makeSessionMessage({ + role: "system", + content: "abcdefgh", + meta: { isModelChange: true }, + }); + const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 }); + + const text = stripAnsi(output); + assert.equal(text, " > abcde\n fgh\n"); + assert.ok( + text + .trimEnd() + .split("\n") + .every((line) => line.length <= 8) + ); +}); + +test("renderMessageToStdout renders assistant non-thinking messages with ✦", () => { + const msg = makeSessionMessage({ role: "assistant", content: "Here is the fix" }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✦")); + assert.ok(output.includes("Here is the fix")); +}); + +test("renderMessageToStdout renders assistant thinking messages with ✧ Thinking", () => { + const msg = makeSessionMessage({ + role: "assistant", + content: "Plan:\nAnalyze the code", + meta: { asThinking: true }, + }); + const output = renderMessageToStdout(msg, RawMode.Lite); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Thinking")); + assert.ok(output.includes("Plan: Analyze the code")); +}); + +test("renderMessageToStdout renders tool messages with ✧ and tool name", () => { + const payload = JSON.stringify({ name: "read", ok: true }); + const msg = makeSessionMessage({ role: "tool", content: payload }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Read")); +}); + +test("renderMessageToStdout renders tool messages with resultMd output", () => { + const payload = JSON.stringify({ name: "read", ok: true }); + const msg = makeSessionMessage({ + role: "tool", + content: payload, + meta: { resultMd: "File content:\n line 1\n line 2" }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("✧")); + assert.ok(output.includes("Read")); + assert.ok(output.includes("└ Result")); + assert.ok(output.includes("File content:")); + assert.ok(output.includes("line 1")); +}); + +test("renderMessageToStdout compacts multi-line Bash params", () => { + const payload = JSON.stringify({ name: "bash", ok: true }); + const msg = makeSessionMessage({ + role: "tool", + content: payload, + meta: { paramsMd: 'python3 -c "\nprint(1)\nprint(2)\n" # Run inline script' }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes('python3 -c " ... " # Run inline script')); + assert.ok(!output.includes("print(1)")); +}); + +test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview and resultMd", () => { + const payload = JSON.stringify({ + name: "UpdatePlan", + ok: true, + metadata: { plan: "Step 1: Analyze\nStep 2: Implement\nStep 3: Test" }, + }); + const msg = makeSessionMessage({ + role: "tool", + content: payload, + meta: { resultMd: "Plan updated successfully" }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("UpdatePlan")); + assert.ok(output.includes("└ Plan")); + assert.ok(output.includes("Step 1: Analyze")); + assert.ok(output.includes(" Result")); + assert.ok(output.includes("Plan updated successfully")); +}); + +test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview", () => { + const payload = JSON.stringify({ + name: "UpdatePlan", + ok: true, + metadata: { plan: "Step 1: Analyze\nStep 2: Implement\nStep 3: Test" }, + }); + const msg = makeSessionMessage({ role: "tool", content: payload }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("UpdatePlan")); + assert.ok(output.includes("└ Plan")); + assert.ok(output.includes("Step 1: Analyze")); + assert.ok(output.includes("Step 2: Implement")); + // Verify resultMd is NOT included when meta.resultMd is absent + assert.ok(!output.includes("└ Result")); +}); + +test("renderMessageToStdout renders system model change messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "Switched to deepseek-v4-pro", + meta: { isModelChange: true }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("> Switched to deepseek-v4-pro")); +}); + +test("renderMessageToStdout renders system skill load messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "", + meta: { skill: { name: "code-review", path: "", description: "" } }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("⚡ Loaded skill: code-review")); +}); + +test("renderMessageToStdout renders system summary messages", () => { + const msg = makeSessionMessage({ + role: "system", + content: "", + meta: { isSummary: true }, + }); + const output = renderMessageToStdout(msg, RawMode.Raw); + assert.ok(output.includes("(conversation summary inserted)")); +}); + +test("renderMessageToStdout returns empty for unknown system messages", () => { + const msg = makeSessionMessage({ role: "system", content: "" }); + assert.equal(renderMessageToStdout(msg, RawMode.Raw), ""); +}); + +// --- getUpdatePlanPreviewLines tests --- + +test("getUpdatePlanPreviewLines returns empty for failed tool", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: false, metadata: { plan: "Step 1" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for non-UpdatePlan tool", () => { + const summary: ToolSummary = { name: "edit", params: "", ok: true, metadata: { plan: "Step 1" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for missing plan metadata", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: true, metadata: null }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines returns empty for empty plan string", () => { + const summary: ToolSummary = { name: "UpdatePlan", params: "", ok: true, metadata: { plan: "" } }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), []); +}); + +test("getUpdatePlanPreviewLines extracts plan lines and filters empty rows", () => { + const summary: ToolSummary = { + name: "UpdatePlan", + params: "", + ok: true, + metadata: { plan: "Step 1: Analyze\n\nStep 2: Implement\n \nStep 3: Test" }, + }; + assert.deepEqual(getUpdatePlanPreviewLines(summary), ["Step 1: Analyze", "Step 2: Implement", "Step 3: Test"]); +}); + +// --- parseToolPayload tests --- + +test("parseToolPayload returns defaults for null content", () => { + const result = parseToolPayload(null); + assert.deepEqual(result, { name: null, ok: true, metadata: null }); +}); + +test("parseToolPayload returns defaults for invalid JSON", () => { + const result = parseToolPayload("not valid json"); + assert.deepEqual(result, { name: null, ok: true, metadata: null }); +}); + +test("parseToolPayload parses valid JSON with name/ok/metadata", () => { + const result = parseToolPayload(JSON.stringify({ name: "read", ok: true, metadata: { file: "src/index.ts" } })); + assert.deepEqual(result, { name: "read", ok: true, metadata: { file: "src/index.ts" } }); +}); + +test("parseToolPayload respects ok: false", () => { + const result = parseToolPayload(JSON.stringify({ name: "bash", ok: false, metadata: null })); + assert.deepEqual(result, { name: "bash", ok: false, metadata: null }); +}); + +test("parseToolPayload trims whitespace from name", () => { + const result = parseToolPayload(JSON.stringify({ name: " read ", ok: true })); + assert.equal(result.name, "read"); +}); diff --git a/packages/cli/src/tests/permission-prompt.test.ts b/packages/cli/src/tests/permission-prompt.test.ts new file mode 100644 index 00000000..4f1d87e9 --- /dev/null +++ b/packages/cli/src/tests/permission-prompt.test.ts @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getScopeRiskColor } from "../ui/views/PermissionPrompt"; + +test("getScopeRiskColor maps permission scopes by risk", () => { + assert.equal(getScopeRiskColor("read-in-cwd"), "#22c55e"); + assert.equal(getScopeRiskColor("query-git-log"), "#22c55e"); + + assert.equal(getScopeRiskColor("read-out-cwd"), "#f59e0b"); + assert.equal(getScopeRiskColor("write-in-cwd"), "#f59e0b"); + assert.equal(getScopeRiskColor("network"), "#f59e0b"); + assert.equal(getScopeRiskColor("mcp"), "#f59e0b"); + + assert.equal(getScopeRiskColor("write-out-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("delete-in-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("delete-out-cwd"), "#ef4444"); + assert.equal(getScopeRiskColor("mutate-git-log"), "#ef4444"); + assert.equal(getScopeRiskColor("unknown"), "#ef4444"); +}); diff --git a/src/tests/promptBuffer.test.ts b/packages/cli/src/tests/prompt-buffer.test.ts similarity index 94% rename from src/tests/promptBuffer.test.ts rename to packages/cli/src/tests/prompt-buffer.test.ts index 67ac23af..19537f7e 100644 --- a/src/tests/promptBuffer.test.ts +++ b/packages/cli/src/tests/prompt-buffer.test.ts @@ -106,14 +106,14 @@ test("getCurrentSlashToken returns the slash word at the cursor", () => { assert.equal(getCurrentSlashToken(buffer), "/skill"); }); -test("getCurrentSlashToken returns null when token contains whitespace", () => { +test("getCurrentSlashToken returns full text when it starts with /", () => { const buffer = { text: "/skill foo", cursor: 10 }; - assert.equal(getCurrentSlashToken(buffer), null); + assert.equal(getCurrentSlashToken(buffer), "/skill foo"); }); -test("getCurrentSlashToken supports slash on a new line", () => { +test("getCurrentSlashToken returns null when text starts on a new line with /", () => { const buffer = { text: "do this\n/n", cursor: 10 }; - assert.equal(getCurrentSlashToken(buffer), "/n"); + assert.equal(getCurrentSlashToken(buffer), null); }); test("getCurrentSlashToken returns null when no slash prefix", () => { diff --git a/src/tests/promptInputKeys.test.ts b/packages/cli/src/tests/prompt-input-keys.test.ts similarity index 60% rename from src/tests/promptInputKeys.test.ts rename to packages/cli/src/tests/prompt-input-keys.test.ts index 69d20758..0c5773cf 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/packages/cli/src/tests/prompt-input-keys.test.ts @@ -13,16 +13,31 @@ import { formatSelectedSkillsStatus, getPromptCursorPlacement, getPromptReturnKeyAction, + isPromptCursorAtWrapBoundary, isClearImageAttachmentsShortcut, - parseTerminalInput, + isRawModeShortcut, removeCurrentSlashToken, + resolvePromptTerminalCursorPosition, toggleSkillSelection, renderBufferWithCursor, buildInitPromptSubmission, + buildPromptDraftFromSessionMessage, disableTerminalExtendedKeys, enableTerminalExtendedKeys, + EMPTY_BUFFER, + insertText, + backspace, } from "../ui"; -import type { SkillInfo } from "../session"; +import type { SessionMessage, SkillInfo } from "@vegamo/deepcode-core"; +import { dispatchTerminalInput, parseTerminalInput } from "../ui/hooks"; + +function collectDispatchedInput(data: string) { + const events: ReturnType[] = []; + dispatchTerminalInput(data, (input, key) => { + events.push({ input, key }); + }); + return events; +} test("parseTerminalInput treats DEL bytes as backspace", () => { const { input, key } = parseTerminalInput("\u007F"); @@ -71,6 +86,45 @@ test("parseTerminalInput keeps DEL payload for meta+backspace", () => { assert.equal(key.backspace, false); }); +test("dispatchTerminalInput splits iOS CJK composition packets", () => { + const events = collectDispatchedInput("가\u007F나"); + assert.equal(events.length, 3); + assert.equal(events[0]?.input, "가"); + assert.equal(events[1]?.input, ""); + assert.equal(events[1]?.key.backspace, true); + assert.equal(events[2]?.input, "나"); +}); + +test("dispatchTerminalInput applies multi-step CJK composition to the prompt buffer", () => { + let state = EMPTY_BUFFER; + dispatchTerminalInput("ㄱ\u007F가\u007F각", (input, key) => { + if (key.backspace) { + state = backspace(state); + return; + } + state = insertText(state, input); + }); + + assert.equal(state.text, "각"); + assert.equal(state.cursor, 1); +}); + +test("dispatchTerminalInput preserves meta+backspace as one event", () => { + const events = collectDispatchedInput("\u001B\u007F"); + assert.equal(events.length, 1); + assert.equal(events[0]?.input, "\u007F"); + assert.equal(events[0]?.key.meta, true); + assert.equal(events[0]?.key.backspace, false); + assert.equal(events[0]?.key.escape, false); +}); + +test("dispatchTerminalInput emits consecutive backspaces from one packet", () => { + const events = collectDispatchedInput("\u007F\u007F"); + assert.equal(events.length, 2); + assert.equal(events[0]?.key.backspace, true); + assert.equal(events[1]?.key.backspace, true); +}); + test("parseTerminalInput keeps BS payload for meta+backspace", () => { const { input, key } = parseTerminalInput("\u001B\b"); assert.equal(input, "\b"); @@ -112,6 +166,31 @@ test("terminal extended key helpers request and restore modifyOtherKeys mode", ( assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); }); +test("buildPromptDraftFromSessionMessage restores text and image urls", () => { + const message: SessionMessage = { + id: "user-with-images", + sessionId: "session-1", + role: "user", + content: "revise this prompt", + contentParams: [ + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + { type: "text", text: "ignored" }, + { type: "image_url", image_url: { url: "data:image/jpeg;base64,def" } }, + ], + messageParams: null, + compacted: false, + visible: true, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }; + + assert.deepEqual(buildPromptDraftFromSessionMessage(message, 7), { + nonce: 7, + text: "revise this prompt", + imageUrls: ["data:image/png;base64,abc", "data:image/jpeg;base64,def"], + }); +}); + test("parseTerminalInput recognizes terminal focus events", () => { const focusIn = parseTerminalInput("\u001B[I"); const focusOut = parseTerminalInput("\u001B[O"); @@ -128,6 +207,13 @@ test("parseTerminalInput recognizes ctrl+x as the image attachment clear shortcu assert.equal(isClearImageAttachmentsShortcut(input, key), true); }); +test("parseTerminalInput recognizes ctrl+r as the raw mode shortcut", () => { + const { input, key } = parseTerminalInput("\u0012"); + assert.equal(input, "r"); + assert.equal(key.ctrl, true); + assert.equal(isRawModeShortcut(input, key), true); +}); + test("parseTerminalInput recognizes ctrl+- modifyOtherKeys sequence (standard)", () => { const { input, key } = parseTerminalInput("\u001B[45;5u"); assert.equal(input, "-"); @@ -218,24 +304,83 @@ test("renderBufferWithCursor styles exactly one simulated cursor", () => { assert.equal((renderBufferWithCursor({ text: "hello\nworld", cursor: 6 }, true).match(ANSI_RE) ?? []).length, 2); }); -test("getPromptCursorPlacement targets the prompt row above divider and footer", () => { - const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80, 2, "Enter send"); - assert.deepEqual(placement, { rowsUp: 3, column: 7 }); +test("renderBufferWithCursor can suppress the simulated cursor for real terminal cursor mode", () => { + assert.equal( + (renderBufferWithCursor({ text: "", cursor: 0 }, true, undefined, undefined, false).match(ANSI_RE) ?? []).length, + 0 + ); + assert.equal( + stripAnsi(renderBufferWithCursor({ text: "", cursor: 0 }, true, "Ask anything", undefined, false)), + " Ask anything" + ); + assert.equal( + (renderBufferWithCursor({ text: "hello", cursor: 1 }, true, undefined, undefined, false).match(ANSI_RE) ?? []) + .length, + 0 + ); + assert.equal( + stripAnsi(renderBufferWithCursor({ text: "hello\n", cursor: 6 }, true, undefined, undefined, false)), + "hello\n " + ); +}); + +test("getPromptCursorPlacement targets an Ink-relative prompt cell", () => { + const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 80); + assert.deepEqual(placement, { row: 0, column: 5 }); }); test("getPromptCursorPlacement targets the reserved row after a trailing newline", () => { - const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80, 2, "Enter send"); - assert.deepEqual(placement, { rowsUp: 3, column: 2 }); + const placement = getPromptCursorPlacement({ text: "hello\n", cursor: 6 }, 80); + assert.deepEqual(placement, { row: 1, column: 0 }); }); test("getPromptCursorPlacement accounts for CJK character width", () => { - const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80, 2, "Enter send"); - assert.equal(placement.column, 6); + const placement = getPromptCursorPlacement({ text: "你好", cursor: 2 }, 80); + assert.equal(placement.column, 4); }); test("getPromptCursorPlacement accounts for multiline buffer rows", () => { - const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80, 2, "Enter send"); - assert.deepEqual(placement, { rowsUp: 3, column: 7 }); - const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80, 2, "Enter send"); - assert.deepEqual(middle, { rowsUp: 4, column: 4 }); + const placement = getPromptCursorPlacement({ text: "hello\nworld", cursor: 11 }, 80); + assert.deepEqual(placement, { row: 1, column: 5 }); + const middle = getPromptCursorPlacement({ text: "hello\nworld", cursor: 2 }, 80); + assert.deepEqual(middle, { row: 0, column: 2 }); +}); + +test("getPromptCursorPlacement accounts for wrapped input rows", () => { + const placement = getPromptCursorPlacement({ text: "hello", cursor: 5 }, 5); + assert.deepEqual(placement, { row: 1, column: 0 }); + const cursorBeforeWrappedChar = getPromptCursorPlacement({ text: "hello!", cursor: 5 }, 5); + assert.deepEqual(cursorBeforeWrappedChar, { row: 1, column: 0 }); + const secondLine = getPromptCursorPlacement({ text: "hello!", cursor: 6 }, 5); + assert.deepEqual(secondLine, { row: 1, column: 1 }); +}); + +test("isPromptCursorAtWrapBoundary detects hard-wrapped cursor positions", () => { + assert.equal(isPromptCursorAtWrapBoundary({ text: "hell", cursor: 4 }, 5), false); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello", cursor: 5 }, 5), true); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello!", cursor: 6 }, 5), true); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello world", cursor: 6 }, 5), true); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello\n", cursor: 6 }, 5), false); + assert.equal(isPromptCursorAtWrapBoundary({ text: "hello\nworld", cursor: 11 }, 5), true); +}); + +test("resolvePromptTerminalCursorPosition requires matching measured layout", () => { + const placement = { row: 1, column: 4 }; + const origin = { layoutKey: "skills:1", left: 2, top: 3 }; + + assert.deepEqual(resolvePromptTerminalCursorPosition(placement, true, "skills:1", origin), { x: 6, y: 4 }); + assert.equal(resolvePromptTerminalCursorPosition(placement, true, "skills:0", origin), undefined); + assert.equal(resolvePromptTerminalCursorPosition(placement, false, "skills:1", origin), undefined); + assert.equal(resolvePromptTerminalCursorPosition(placement, true, "skills:1", null), undefined); +}); + +test("resolvePromptTerminalCursorPosition clamps negative terminal cells", () => { + assert.deepEqual( + resolvePromptTerminalCursorPosition({ row: 0, column: 1 }, true, "current", { + layoutKey: "current", + left: -5, + top: -1, + }), + { x: 0, y: 0 } + ); }); diff --git a/src/tests/promptUndoRedo.test.ts b/packages/cli/src/tests/prompt-undo-redo.test.ts similarity index 98% rename from src/tests/promptUndoRedo.test.ts rename to packages/cli/src/tests/prompt-undo-redo.test.ts index c1999f15..d4590fb6 100644 --- a/src/tests/promptUndoRedo.test.ts +++ b/packages/cli/src/tests/prompt-undo-redo.test.ts @@ -8,7 +8,7 @@ import { recordPromptEdit, redoPromptEdit, undoPromptEdit, -} from "../ui/promptUndoRedo"; +} from "../ui/core/prompt-undo-redo"; test("prompt undo and redo restore edited buffer states", () => { const history = createPromptUndoRedoState(); diff --git a/packages/cli/src/tests/run-tests.mjs b/packages/cli/src/tests/run-tests.mjs new file mode 100644 index 00000000..87748b2d --- /dev/null +++ b/packages/cli/src/tests/run-tests.mjs @@ -0,0 +1,15 @@ +// Test runner for @vegamo/deepcode-cli +import { globSync } from "glob"; +import { spawnSync } from "child_process"; +import { fileURLToPath } from "url"; +import * as path from "path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const testFiles = globSync("*.test.ts", { cwd: __dirname }); + +const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { + stdio: "inherit", + cwd: __dirname, +}); + +process.exit(result.status ?? 1); diff --git a/packages/cli/src/tests/session-list.test.ts b/packages/cli/src/tests/session-list.test.ts new file mode 100644 index 00000000..654b4152 --- /dev/null +++ b/packages/cli/src/tests/session-list.test.ts @@ -0,0 +1,119 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { formatSessionTitle, filterSessions, formatSessionStatus } from "../ui"; +import type { SessionEntry } from "@vegamo/deepcode-core"; + +test("formatSessionTitle replaces newlines with spaces", () => { + assert.equal(formatSessionTitle("first line\nsecond line\r\nthird"), "first line second line third"); +}); + +test("formatSessionTitle truncates after normalizing whitespace", () => { + assert.equal(formatSessionTitle("one\n two three", 10), "one two th…"); +}); + +test("formatSessionStatus maps status values to display labels", () => { + assert.equal(formatSessionStatus("completed"), "done"); + assert.equal(formatSessionStatus("processing"), "running"); + assert.equal(formatSessionStatus("pending"), "pending"); + assert.equal(formatSessionStatus("waiting_for_user"), "waiting"); + assert.equal(formatSessionStatus("failed"), "failed"); + assert.equal(formatSessionStatus("interrupted"), "stopped"); + assert.equal(formatSessionStatus("ask_permission"), "waiting"); + assert.equal(formatSessionStatus("permission_denied"), "denied"); + assert.equal(formatSessionStatus("unknown_status" as any), "unknown_status"); +}); + +test("filterSessions returns all sessions when query is empty", () => { + const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]); + assert.equal(filterSessions(sessions, "").length, 2); + assert.equal(filterSessions(sessions, " ").length, 2); +}); + +test("filterSessions matches by summary (case-insensitive)", () => { + const sessions = buildSessions([ + { summary: "Fix login bug" }, + { summary: "Add dark mode" }, + { summary: "Refactor auth module" }, + ]); + + assert.equal(filterSessions(sessions, "login").length, 1); + assert.equal(filterSessions(sessions, "LOGIN").length, 1); + assert.equal(filterSessions(sessions, "Login").length, 1); +}); + +test("filterSessions matches by status (case-insensitive)", () => { + const sessions = buildSessions([ + { summary: "Task 1", status: "completed" }, + { summary: "Task 2", status: "failed" }, + { summary: "Task 3", status: "completed" }, + ]); + + assert.equal(filterSessions(sessions, "failed").length, 1); + assert.equal(filterSessions(sessions, "completed").length, 2); +}); + +test("filterSessions matches by failReason", () => { + const sessions = buildSessions([ + { summary: "Task 1", status: "failed", failReason: "API key not found" }, + { summary: "Task 2", status: "completed" }, + ]); + + assert.equal(filterSessions(sessions, "API key").length, 1); + assert.equal(filterSessions(sessions, "not found").length, 1); +}); + +test("filterSessions matches by assistantReply", () => { + const sessions = buildSessions([ + { summary: "Task 1", assistantReply: "The bug was fixed by updating the config." }, + { summary: "Task 2", assistantReply: "Dark mode has been added successfully." }, + ]); + + assert.equal(filterSessions(sessions, "dark mode").length, 1); + assert.equal(filterSessions(sessions, "config").length, 1); +}); + +test("filterSessions returns empty array when no match", () => { + const sessions = buildSessions([{ summary: "Fix login bug" }, { summary: "Add dark mode" }]); + + assert.equal(filterSessions(sessions, "nonexistent").length, 0); +}); + +test("filterSessions matches across multiple fields on same session", () => { + const sessions = buildSessions([ + { summary: "Fix login bug", status: "failed", failReason: "Timeout error" }, + { summary: "Add dark mode", status: "completed" }, + ]); + + // Should match the first session via status + assert.equal(filterSessions(sessions, "failed").length, 1); + // Should match the first session via failReason + assert.equal(filterSessions(sessions, "timeout").length, 1); + // Partial summary match + assert.equal(filterSessions(sessions, "login").length, 1); +}); + +test("filterSessions handles sessions with null fields", () => { + const sessions = buildSessions([{ summary: null }, { summary: "Valid summary" }]); + + assert.equal(filterSessions(sessions, "valid").length, 1); + assert.equal(filterSessions(sessions, "summary").length, 1); +}); + +function buildSessions(overrides: Array>): SessionEntry[] { + return overrides.map((override, i) => ({ + id: `session-${i}`, + summary: override.summary ?? null, + assistantReply: override.assistantReply ?? null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: override.status ?? "completed", + failReason: override.failReason ?? null, + usage: null, + usagePerModel: null, + activeTokens: 0, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + processes: null, + })); +} diff --git a/src/tests/slashCommands.test.ts b/packages/cli/src/tests/slash-commands.test.ts similarity index 84% rename from src/tests/slashCommands.test.ts rename to packages/cli/src/tests/slash-commands.test.ts index bba52447..420e5a48 100644 --- a/src/tests/slashCommands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -7,7 +7,7 @@ import { formatSlashCommandDescription, formatSlashCommandLabel, } from "../ui"; -import type { SkillInfo } from "../session"; +import type { SkillInfo } from "@vegamo/deepcode-core"; const skills: SkillInfo[] = [ { name: "skill-writer", path: "~/.agents/skills/skill-writer/SKILL.md", description: "Write a SKILL.md" }, @@ -19,7 +19,18 @@ test("buildSlashCommands prefixes skills before built-ins", () => { assert.equal(items[0].kind, "skill"); assert.equal(items[0].name, "skill-writer"); const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name); - assert.deepEqual(builtinNames, ["skills", "model", "new", "init", "resume", "continue", "mcp", "exit"]); + assert.deepEqual(builtinNames, [ + "skills", + "model", + "new", + "init", + "resume", + "continue", + "undo", + "mcp", + "raw", + "exit", + ]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -66,6 +77,13 @@ test("findExactSlashCommand returns built-in /continue", () => { assert.equal(item?.kind, "continue"); }); +test("findExactSlashCommand returns built-in /undo", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/undo"); + assert.ok(item); + assert.equal(item?.kind, "undo"); +}); + test("findExactSlashCommand returns built-in /skills", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/skills"); @@ -80,6 +98,13 @@ test("findExactSlashCommand returns built-in /model", () => { assert.equal(item?.kind, "model"); }); +test("findExactSlashCommand returns built-in /raw", () => { + const items = buildSlashCommands(skills); + const item = findExactSlashCommand(items, "/raw"); + assert.ok(item); + assert.equal(item?.kind, "raw"); +}); + test("findExactSlashCommand returns the matching skill", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/code-review"); diff --git a/packages/cli/src/tests/statusline.test.ts b/packages/cli/src/tests/statusline.test.ts new file mode 100644 index 00000000..0336b626 --- /dev/null +++ b/packages/cli/src/tests/statusline.test.ts @@ -0,0 +1,312 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { sanitizeStatusText, STATUS_SEGMENT_MAX_LENGTH } from "../ui/statusline/sanitize"; +import { validateModulePath, loadModuleProvider } from "../ui/statusline/module-provider"; +import { createCommandStatusProvider } from "../ui/statusline/command-provider"; +import { StatusLineManager } from "../ui/statusline/manager"; +import { resolveSettings, resolveSettingsSources } from "@vegamo/deepcode-core"; +import type { ResolvedStatusLineSettings } from "@vegamo/deepcode-core"; + +test("sanitizeStatusText returns empty for null/undefined/empty", () => { + assert.equal(sanitizeStatusText(undefined), ""); + assert.equal(sanitizeStatusText(null), ""); + assert.equal(sanitizeStatusText(""), ""); +}); + +test("sanitizeStatusText keeps first non-empty line and strips ANSI", () => { + assert.equal(sanitizeStatusText("\n\nfirst\nsecond"), "first"); + assert.equal(sanitizeStatusText("red text"), "red text"); + assert.equal(sanitizeStatusText("multiple spaces\t\there"), "multiple spaces here"); +}); + +test("sanitizeStatusText truncates to max length with ellipsis", () => { + const long = "x".repeat(STATUS_SEGMENT_MAX_LENGTH + 20); + const result = sanitizeStatusText(long); + assert.equal(result.length, STATUS_SEGMENT_MAX_LENGTH); + assert.ok(result.endsWith("…")); +}); + +test("sanitizeStatusText respects custom max length", () => { + assert.equal(sanitizeStatusText("hello world", 5), "hell…"); + assert.equal(sanitizeStatusText("hi", 5), "hi"); +}); + +test("validateModulePath accepts paths under project root", () => { + const projectRoot = path.resolve(os.tmpdir(), "deepcode-test-project"); + const inside = path.join(projectRoot, "plugins", "status.js"); + const result = validateModulePath(inside, projectRoot); + assert.equal(result, path.normalize(inside)); +}); + +test("validateModulePath accepts relative paths resolved under project root", () => { + const projectRoot = path.resolve(os.tmpdir(), "deepcode-test-project"); + const result = validateModulePath("plugins/status.js", projectRoot); + assert.equal(result, path.normalize(path.join(projectRoot, "plugins", "status.js"))); +}); + +test("validateModulePath rejects paths outside project root and home", () => { + const projectRoot = path.resolve(os.tmpdir(), "deepcode-isolated-test"); + // Use a path guaranteed to be outside both projectRoot and HOME. + const outside = path.resolve("/totally-not-in-any-allowed-base/status.js"); + const result = validateModulePath(outside, projectRoot); + assert.equal(result, null); +}); + +test("resolveSettings produces a default statusline with no providers", () => { + const resolved = resolveSettings({}, { model: "default-model", baseURL: "https://default.example.com" }, {}); + assert.equal(resolved.statusline.enabled, false); + assert.equal(resolved.statusline.refreshMs, 2000); + assert.deepEqual(resolved.statusline.providers, []); +}); + +test("resolveSettings normalizes statusline providers and filters invalid entries", () => { + const resolved = resolveSettings( + { + statusline: { + enabled: true, + refreshMs: 3000, + providers: [ + { type: "command", id: "git", command: "git status -sb" }, + { type: "command", command: "" } as never, // invalid: empty command + { type: "module", path: "./plugins/x.js" }, + { type: "module" } as never, // invalid: missing path + { type: "unknown" } as never, // invalid: bad type + ], + }, + }, + { model: "default-model", baseURL: "https://default.example.com" }, + {} + ); + assert.equal(resolved.statusline.enabled, true); + assert.equal(resolved.statusline.refreshMs, 3000); + assert.equal(resolved.statusline.providers.length, 2); + assert.equal(resolved.statusline.providers[0]?.type, "command"); + assert.equal(resolved.statusline.providers[1]?.type, "module"); +}); + +test("resolveSettings clamps refreshMs to minimum and ignores invalid values", () => { + const tooSmall = resolveSettings({ statusline: { refreshMs: 100 } }, { model: "m", baseURL: "https://e" }, {}); + assert.equal(tooSmall.statusline.refreshMs, 2000); // falls back to default +}); + +test("createCommandStatusProvider returns stdout from short commands", async () => { + const provider = createCommandStatusProvider( + { type: "command", command: process.platform === "win32" ? "echo hello" : "printf hello" }, + process.cwd(), + "test-cmd" + ); + const ac = new AbortController(); + const result = await provider.fetch({ projectRoot: process.cwd(), signal: ac.signal }); + assert.ok(result.includes("hello")); +}); + +test("createCommandStatusProvider times out long-running commands", async () => { + const sleepCmd = process.platform === "win32" ? "ping -n 5 127.0.0.1 > nul" : "sleep 3"; + const provider = createCommandStatusProvider( + { type: "command", command: sleepCmd, timeoutMs: 200 }, + process.cwd(), + "slow" + ); + const ac = new AbortController(); + const start = Date.now(); + const result = await provider.fetch({ projectRoot: process.cwd(), signal: ac.signal }); + const elapsed = Date.now() - start; + assert.ok(elapsed < 1500, `expected timeout within ~1.5s, got ${elapsed}ms`); + assert.equal(result, ""); +}); + +test("createCommandStatusProvider returns empty on non-existent command", async () => { + const provider = createCommandStatusProvider( + { type: "command", command: "this-command-definitely-does-not-exist-xyz-abc-12345" }, + process.cwd(), + "missing" + ); + const ac = new AbortController(); + const result = await provider.fetch({ projectRoot: process.cwd(), signal: ac.signal }); + // Either empty (failure) or shell error message — both fine, just must not hang/throw. + assert.equal(typeof result, "string"); +}); + +test("loadModuleProvider returns null when the path does not exist", async () => { + const provider = await loadModuleProvider( + path.join(os.tmpdir(), "does-not-exist-xyz.mjs"), + undefined, + "missing", + 1000 + ); + assert.equal(provider, null); +}); + +test("loadModuleProvider isolates errors thrown by the user function", async () => { + // Create a temporary module that throws. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-statusline-")); + const modPath = path.join(dir, "bad.mjs"); + fs.writeFileSync(modPath, "export default () => { throw new Error('boom'); }", "utf8"); + try { + const provider = await loadModuleProvider(modPath, undefined, "bad", 1000); + assert.ok(provider, "provider should load even if its fn throws on invocation"); + const ac = new AbortController(); + await assert.rejects(provider!.fetch({ projectRoot: process.cwd(), signal: ac.signal })); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadModuleProvider succeeds for a well-formed module", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-statusline-")); + const modPath = path.join(dir, "good.mjs"); + fs.writeFileSync(modPath, "export default ({ projectRoot }) => `root=${projectRoot}`;", "utf8"); + try { + const provider = await loadModuleProvider(modPath, "yellow", "good", 1000); + assert.ok(provider); + assert.equal(provider!.color, "yellow"); + const ac = new AbortController(); + const result = await provider!.fetch({ projectRoot: "/some/root", signal: ac.signal }); + assert.equal(result, "root=/some/root"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("loadModuleProvider removes abort listener after successful fetch", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-statusline-")); + const modPath = path.join(dir, "cleanup.mjs"); + fs.writeFileSync(modPath, "export default () => 'ok';", "utf8"); + try { + const provider = await loadModuleProvider(modPath, undefined, "cleanup", 10_000); + assert.ok(provider); + + const ac = new AbortController(); + const signal = ac.signal; + const originalAdd = signal.addEventListener; + const originalRemove = signal.removeEventListener; + let abortListenerAdds = 0; + let abortListenerRemoves = 0; + signal.addEventListener = function (this: AbortSignal, ...args: Parameters) { + if (args[0] === "abort") { + abortListenerAdds += 1; + } + return originalAdd.apply(this, args); + } as AbortSignal["addEventListener"]; + signal.removeEventListener = function (this: AbortSignal, ...args: Parameters) { + if (args[0] === "abort") { + abortListenerRemoves += 1; + } + return originalRemove.apply(this, args); + } as AbortSignal["removeEventListener"]; + + const result = await provider!.fetch({ projectRoot: dir, signal }); + assert.equal(result, "ok"); + assert.equal(abortListenerAdds, 1); + assert.equal(abortListenerRemoves, 1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("resolveSettingsSources lets project-level providers override user-level by id", () => { + const resolved = resolveSettingsSources( + { + statusline: { + enabled: true, + providers: [ + { type: "command", id: "model", command: "echo user-model" }, + { type: "command", id: "branch", command: "echo user-branch" }, + ], + }, + }, + { + statusline: { + providers: [ + { type: "command", id: "model", command: "echo project-model" }, + { type: "command", id: "cwd", command: "echo project-cwd" }, + ], + }, + }, + { model: "default-model", baseURL: "https://default.example.com" } + ); + const ids = resolved.statusline.providers.map((p) => p.id); + assert.deepEqual(ids, ["branch", "model", "cwd"]); + const modelProvider = resolved.statusline.providers.find((p) => p.id === "model"); + assert.equal(modelProvider?.type === "command" && modelProvider.command, "echo project-model"); +}); + +test("StatusLineManager emits segments after fetch and stops cleanly", async () => { + const config: ResolvedStatusLineSettings = { + enabled: true, + refreshMs: 60_000, + separator: " · ", + providers: [ + { + type: "command", + id: "echo", + command: process.platform === "win32" ? "echo hello" : "printf hello", + }, + ], + }; + const manager = new StatusLineManager(); + const updates: Array> = []; + const unsub = manager.subscribe((segments) => updates.push(segments.map((s) => ({ id: s.id, text: s.text })))); + await manager.start(config, process.cwd()); + + // Wait for the initial fetch to settle. + await new Promise((resolve) => setTimeout(resolve, 400)); + + unsub(); + manager.stop(); + + const populated = updates.find((u) => u.length > 0 && u[0]?.text.includes("hello")); + assert.ok(populated, `expected an update with 'hello' segment; got ${JSON.stringify(updates)}`); +}); + +test("StatusLineManager skips fetch when disabled", async () => { + const config: ResolvedStatusLineSettings = { + enabled: false, + refreshMs: 60_000, + separator: " · ", + providers: [{ type: "command", command: "echo whatever" }], + }; + const manager = new StatusLineManager(); + const updates: Array<{ id: string; text: string }[]> = []; + manager.subscribe((segs) => updates.push(segs.map((s) => ({ id: s.id, text: s.text })))); + await manager.start(config, process.cwd()); + await new Promise((resolve) => setTimeout(resolve, 100)); + manager.stop(); + assert.equal(updates.length, 0); +}); + +test("StatusLineManager isolates a failing provider from succeeding ones", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-statusline-")); + const badMod = path.join(dir, "bad.mjs"); + const goodMod = path.join(dir, "good.mjs"); + fs.writeFileSync(badMod, "export default () => { throw new Error('boom'); }", "utf8"); + fs.writeFileSync(goodMod, "export default () => 'ok';", "utf8"); + + try { + const config: ResolvedStatusLineSettings = { + enabled: true, + refreshMs: 60_000, + separator: " · ", + providers: [ + { type: "module", id: "bad", path: badMod }, + { type: "module", id: "good", path: goodMod }, + ], + }; + const manager = new StatusLineManager(); + let lastSegments: Array<{ id: string; text: string }> = []; + manager.subscribe((segs) => { + lastSegments = segs.map((s) => ({ id: s.id, text: s.text })); + }); + await manager.start(config, dir); + await new Promise((resolve) => setTimeout(resolve, 400)); + manager.stop(); + assert.equal(lastSegments.length, 1); + assert.equal(lastSegments[0]?.id, "good"); + assert.equal(lastSegments[0]?.text, "ok"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/src/tests/thinkingState.test.ts b/packages/cli/src/tests/thinking-state.test.ts similarity index 96% rename from src/tests/thinkingState.test.ts rename to packages/cli/src/tests/thinking-state.test.ts index 8f2a0e30..efbee883 100644 --- a/src/tests/thinkingState.test.ts +++ b/packages/cli/src/tests/thinking-state.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { findExpandedThinkingId } from "../ui"; -import type { SessionMessage } from "../session"; +import type { SessionMessage } from "@vegamo/deepcode-core"; function buildMessage( id: string, diff --git a/src/tests/updateCheck.test.ts b/packages/cli/src/tests/update-check.test.ts similarity index 68% rename from src/tests/updateCheck.test.ts rename to packages/cli/src/tests/update-check.test.ts index ce77fe5e..34e85912 100644 --- a/src/tests/updateCheck.test.ts +++ b/packages/cli/src/tests/update-check.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { compareVersions, parseNpmViewVersion } from "../updateCheck"; +import { UPDATE_SUCCESS_MESSAGE, compareVersions, parseNpmViewVersion } from "../common/update-check"; test("compareVersions orders semantic versions", () => { assert.equal(compareVersions("0.1.4", "0.1.3"), 1); @@ -14,3 +14,7 @@ test("parseNpmViewVersion parses npm view JSON and plain output", () => { assert.equal(parseNpmViewVersion("0.1.5\n"), "0.1.5"); assert.equal(parseNpmViewVersion("\n"), null); }); + +test("UPDATE_SUCCESS_MESSAGE tells the user to restart Deep Code", () => { + assert.equal(UPDATE_SUCCESS_MESSAGE, "🎉 Update ran successfully! Please restart Deep Code."); +}); diff --git a/src/tests/welcomeScreen.test.ts b/packages/cli/src/tests/welcome-screen.test.ts similarity index 97% rename from src/tests/welcomeScreen.test.ts rename to packages/cli/src/tests/welcome-screen.test.ts index df7e109b..45bb7413 100644 --- a/src/tests/welcomeScreen.test.ts +++ b/packages/cli/src/tests/welcome-screen.test.ts @@ -32,5 +32,6 @@ test("buildWelcomeTips includes built-in slash commands and loaded skills", () = const labels = tips.map((tip) => tip.label); assert.ok(labels.includes("/new")); assert.ok(labels.includes("/loaded")); + assert.ok(labels.includes("Ctrl+R")); assert.equal(labels.includes("/fresh"), false); }); diff --git a/src/AsciiArt.ts b/packages/cli/src/ui/ascii-art.ts similarity index 100% rename from src/AsciiArt.ts rename to packages/cli/src/ui/ascii-art.ts diff --git a/src/ui/DropdownMenu.tsx b/packages/cli/src/ui/components/DropdownMenu/index.tsx similarity index 99% rename from src/ui/DropdownMenu.tsx rename to packages/cli/src/ui/components/DropdownMenu/index.tsx index 6593ff8d..cf323141 100644 --- a/src/ui/DropdownMenu.tsx +++ b/packages/cli/src/ui/components/DropdownMenu/index.tsx @@ -64,7 +64,7 @@ const DropdownMenu = React.memo(function DropdownMenu({ maxVisible = 8, width, title, - titleColor = "magenta", + titleColor = "#229ac3", activeColor = "cyanBright", helpText, emptyText = "No items found", diff --git a/packages/cli/src/ui/components/FileMentionMenu/index.tsx b/packages/cli/src/ui/components/FileMentionMenu/index.tsx new file mode 100644 index 00000000..f00b367e --- /dev/null +++ b/packages/cli/src/ui/components/FileMentionMenu/index.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import { Box, Text } from "ink"; +import { useInput } from "ink"; +import DropdownMenu from "../DropdownMenu"; +import type { FileMentionItem, FileMentionToken } from "../../core/file-mentions"; + +type Props = { + open: boolean; + width: number; + token: FileMentionToken | null; + items: FileMentionItem[]; + onClose: () => void; + onSelect: (item: FileMentionItem) => void; +}; + +const FileMentionMenu: React.FC = ({ open, width, token, items, onClose, onSelect }) => { + const [activeIndex, setActiveIndex] = useState(0); + + // Reset index when opened + useEffect(() => { + if (open) { + setActiveIndex(0); + } + }, [open]); + + // Validate activeIndex bounds + useEffect(() => { + if (!open) { + return; + } + if (items.length === 0) { + setActiveIndex(0); + return; + } + if (activeIndex >= items.length) { + setActiveIndex(Math.max(0, items.length - 1)); + } + }, [activeIndex, items.length, open]); + + useInput( + (input, key) => { + if (!open) { + return; + } + + if (key.escape) { + onClose(); + return; + } + + if (key.upArrow) { + if (items.length > 0) { + setActiveIndex((idx) => (idx - 1 + items.length) % items.length); + } + return; + } + + if (key.downArrow) { + if (items.length > 0) { + setActiveIndex((idx) => (idx + 1) % items.length); + } + return; + } + + if (key.tab || (key.return && !key.shift && !key.meta)) { + const selected = items[activeIndex]; + if (selected) { + onSelect(selected); + return; + } + if (key.tab) { + onClose(); + } + return; + } + }, + { isActive: open } + ); + + if (!open) { + return null; + } + + return ( + ({ + key: item.path, + label: item.path, + description: item.type === "directory" ? "directory" : "file", + }))} + activeIndex={activeIndex} + activeColor="#229ac3" + maxVisible={8} + renderItem={(item, isActive) => ( + + {isActive ? "> " : " "} + + + {item.label} + + + {item.description ? ( + + {item.description} + + ) : null} + + )} + /> + ); +}; + +export default FileMentionMenu; diff --git a/packages/cli/src/ui/components/MessageView/index.tsx b/packages/cli/src/ui/components/MessageView/index.tsx new file mode 100644 index 00000000..12b8229d --- /dev/null +++ b/packages/cli/src/ui/components/MessageView/index.tsx @@ -0,0 +1,234 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { renderMarkdown, renderMarkdownSegments } from "./markdown"; +import { + buildThinkingSummary, + buildToolSummary, + formatStatusName, + formatToolStatusParams, + getToolDiffPreviewLines, + getUpdatePlanPreviewLines, +} from "./utils"; +import type { DiffPreviewLine, MessageViewProps } from "./types"; +import { RawMode, useRawModeContext } from "../../contexts"; + +const PROMPT_ECHO_PREFIX_WIDTH = 2; +const PROMPT_ECHO_MARGIN_LEFT = 1; + +export function MessageView({ message, collapsed, width = 80 }: MessageViewProps): React.ReactElement | null { + const { mode } = useRawModeContext(); + if (!message.visible) { + return null; + } + + if (message.role === "user") { + const text = message.content || "(no content)"; + return ( + + ); + } + + if (message.role === "assistant") { + const isThinking = Boolean(message.meta?.asThinking); + const content = (message.content || "").trim(); + + if (isThinking) { + const summary = buildThinkingSummary(content, message.messageParams, mode); + if (collapsed !== false) { + return ( + + + + ); + } + return ( + + + + {content ? {renderMarkdown(content)} : null} + + + ); + } + + const containerWidth = Math.max(1, width - 2); + const contentWidth = Math.max(1, width - 4); + + return ( + + + + + + {content + ? renderMarkdownSegments(content, Math.max(20, contentWidth - 4)).map((seg, i) => { + if (seg.kind === "table") { + return ( + + {seg.body.split("\n").map((line, lineIndex) => ( + + {line} + + ))} + + ); + } + return {seg.body}; + }) + : null} + + + ); + } + + if (message.role === "tool") { + const summary = buildToolSummary(message); + const diffLines = getToolDiffPreviewLines(summary); + const planLines = getUpdatePlanPreviewLines(summary); + return ( + + + {diffLines.length > 0 ? : null} + {planLines.length > 0 ? : null} + + ); + } + + if (message.role === "system") { + // Render model change messages in the same style as user commands. + if (message.meta?.isModelChange) { + return ; + } + + if (message.meta?.skill) { + return ( + + ⚡ Loaded skill: {message.meta.skill.name} + + ); + } + if (message.meta?.isSummary) { + return ( + + + (conversation summary inserted) + + + ); + } + return null; + } + + return null; +} + +export function getPromptEchoContentWidth(width: number): number { + return Math.max(1, width - PROMPT_ECHO_MARGIN_LEFT - PROMPT_ECHO_PREFIX_WIDTH); +} + +function PromptEchoLine({ + text, + width, + attachmentCount = 0, +}: { + text: string; + width: number; + attachmentCount?: number; +}): React.ReactElement { + const contentWidth = getPromptEchoContentWidth(width); + const containerWidth = Math.max(1, width - PROMPT_ECHO_MARGIN_LEFT); + return ( + + + {"> "} + + + + {text} + + {attachmentCount > 0 ? {` 📎 ${attachmentCount} image attachment(s)`} : null} + + + ); +} + +function StatusLine({ + bulletColor, + name, + params, + width, +}: { + bulletColor: "gray" | "green" | "red"; + name: string; + params: string; + width: number; +}): React.ReactElement { + const { mode } = useRawModeContext(); + const containerWidth = Math.max(1, width - 2); + const contentWidth = Math.max(1, width - 4); + return ( + + + + ✧ + + + + + + {name} + + {params ? ( + + {` ${params}`} + + ) : null} + + + + ); +} + +function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { + return ( + + └ Changes + + {lines.map((line, index) => ( + + + {line.marker} + + + {line.content} + + + ))} + + + ); +} + +function PlanPreview({ lines }: { lines: string[] }): React.ReactElement { + return ( + + └ Plan + + {lines.map((line, index) => ( + + {line} + + ))} + + + ); +} diff --git a/packages/cli/src/ui/components/MessageView/markdown.ts b/packages/cli/src/ui/components/MessageView/markdown.ts new file mode 100644 index 00000000..0b012642 --- /dev/null +++ b/packages/cli/src/ui/components/MessageView/markdown.ts @@ -0,0 +1,426 @@ +import chalk from "chalk"; + +/** + * A rendered piece of markdown. Consumers should use `wrap="truncate-end"` for + * `table` segments and the default wrap mode for `text` segments so that Ink + * never breaks box-drawing lines at cell boundary spaces. + */ +export type MarkdownSegment = + | { kind: "text"; body: string } + | { kind: "table"; body: string } + | { kind: "code"; body: string; lang: string }; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Render markdown to a single string (backward-compatible). */ +export function renderMarkdown(text: string, maxWidth?: number): string { + return renderMarkdownSegments(text, maxWidth) + .map((s) => s.body) + .reduce((out, body) => { + if (!out) return body; + if (!body) return out; + return out.endsWith("\n") || body.startsWith("\n") ? out + body : `${out}\n${body}`; + }, ""); +} + +/** Render markdown, returning typed segments so the caller can choose the + right `` per segment. */ +export function renderMarkdownSegments(text: string, maxWidth?: number): MarkdownSegment[] { + if (!text) return []; + + const segments: MarkdownSegment[] = []; + const fenceSegments = splitByFences(text); + + for (const seg of fenceSegments) { + if (seg.kind === "code") { + const langTag = seg.lang ? chalk.dim(`[${seg.lang}]`) + "\n" : ""; + segments.push({ kind: "code", body: langTag + chalk.cyan(seg.body), lang: seg.lang }); + continue; + } + const blocks = splitTableBlocks(seg.body); + for (const b of blocks) { + if (b.kind === "table") { + segments.push({ kind: "table", body: renderTableBorder(b.rows, maxWidth) }); + } else { + const body = b.body + .split("\n") + .map((line) => renderInlineLine(line)) + .join("\n"); + if (body) segments.push({ kind: "text", body }); + } + } + } + + return segments; +} + +// --------------------------------------------------------------------------- +// Code fences +// --------------------------------------------------------------------------- + +type FenceSegment = { kind: "text"; body: string } | { kind: "code"; lang: string; body: string }; + +function splitByFences(text: string): FenceSegment[] { + const segments: FenceSegment[] = []; + const lines = text.split(/\r?\n/); + let buffer: string[] = []; + let inFence = false; + let fenceLang = ""; + let fenceBody: string[] = []; + + const flushText = () => { + if (buffer.length > 0) { + segments.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; + } + }; + + for (const line of lines) { + const m = /^\s*```(\w*)\s*$/.exec(line); + if (m) { + if (!inFence) { + flushText(); + inFence = true; + fenceLang = m[1] ?? ""; + fenceBody = []; + } else { + segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); + inFence = false; + } + continue; + } + (inFence ? fenceBody : buffer).push(line); + } + + if (inFence) { + segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); + } else { + flushText(); + } + + return segments; +} + +// --------------------------------------------------------------------------- +// Table parsing +// --------------------------------------------------------------------------- + +type TableBlock = { kind: "text"; body: string } | { kind: "table"; rows: string[][] }; + +function splitTableBlocks(text: string): TableBlock[] { + const lines = text.split(/\r?\n/); + const blocks: TableBlock[] = []; + let buffer: string[] = []; + let tableRows: string[][] = []; + let inTable = false; + + const flushText = () => { + if (buffer.length > 0) { + blocks.push({ kind: "text", body: buffer.join("\n") }); + buffer = []; + } + }; + const flushTable = () => { + if (tableRows.length >= 2) { + blocks.push({ kind: "table", rows: tableRows }); + } else if (tableRows.length > 0) { + buffer.push(...tableRows.map((r) => r.join(" | "))); + } + tableRows = []; + }; + + const sepRe = /^\|?\s*:?[-]{3,}:?\s*(\|\s*:?[-]{3,}:?\s*)*\|?\s*$/; + const parseRow = (row: string) => { + let body = row.trim(); + if (body.startsWith("|")) body = body.slice(1); + if (body.endsWith("|")) body = body.slice(0, -1); + return body.split("|").map((s) => s.trim()); + }; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + const nextTrimmed = (lines[i + 1] ?? "").trim(); + + // skip separator line + if (inTable && sepRe.test(trimmed) && tableRows.length === 1) continue; + + const isRow = /^\|.+\|$/.test(trimmed); + const isHeader = isRow && i + 1 < lines.length && sepRe.test(nextTrimmed); + + if (isHeader && !inTable) { + flushText(); + inTable = true; + tableRows = [parseRow(trimmed)]; + continue; + } + + if (isRow && inTable) { + tableRows.push(parseRow(trimmed)); + continue; + } + + if (inTable && !isRow) { + flushTable(); + inTable = false; + } + buffer.push(line); + } + + return inTable ? [...blocks, ...flushTableResult(tableRows)] : [...blocks, ...flushTextOnly(buffer, tableRows)]; +} + +function flushTableResult(rows: string[][]): TableBlock[] { + if (rows.length >= 2) return [{ kind: "table", rows }]; + if (rows.length > 0) return [{ kind: "text", body: rows.map((r) => r.join(" | ")).join("\n") }]; + return []; +} + +function flushTextOnly(buffer: string[], tableRows: string[][]): TableBlock[] { + const result: TableBlock[] = []; + if (buffer.length > 0) result.push({ kind: "text", body: buffer.join("\n") }); + if (tableRows.length >= 2) result.push({ kind: "table", rows: tableRows }); + else if (tableRows.length > 0) result.push({ kind: "text", body: tableRows.map((r) => r.join(" | ")).join("\n") }); + return result; +} + +// --------------------------------------------------------------------------- +// Terminal visual width (CJK / emoji = 2 cols, ASCII = 1) +// --------------------------------------------------------------------------- + +function visualWidth(text: string): number { + let w = 0; + for (const ch of text) { + if (ch.length >= 2) { + w += 2; + continue; + } + const code = ch.codePointAt(0) ?? ch.charCodeAt(0); + w += isWideChar(code) ? 2 : 1; + } + return w; +} + +function isWideChar(code: number): boolean { + return ( + (code >= 0x1100 && code <= 0x115f) || // Hangul Jamo + (code >= 0x2329 && code <= 0x232a) || // Misc technical + (code >= 0x2e80 && code <= 0xa4cf) || // CJK Radicals, Kangxi, CJK all + (code >= 0xac00 && code <= 0xd7af) || // Hangul Syllables + (code >= 0xf900 && code <= 0xfaff) || // CJK Compat + (code >= 0xfe10 && code <= 0xfe6f) || // CJK Compat Forms + (code >= 0xff00 && code <= 0xffe6) || // Fullwidth + (code >= 0x20000 && code <= 0x3fffd) || // CJK Ext B+ + (code >= 0x1f300 && code <= 0x1faff) || // Emoji & pictographs + (code >= 0x2600 && code <= 0x27bf) || // Misc Symbols + (code >= 0x2300 && code <= 0x23ff) || // Misc Technical + (code >= 0x2b00 && code <= 0x2bff) || // Misc Symbols & Arrows + (code >= 0x1f000 && code <= 0x1f02f) // Mahjong & Domino + ); +} + +// --------------------------------------------------------------------------- +// Table rendering +// --------------------------------------------------------------------------- + +function renderTableBorder(rows: string[][], maxWidth?: number): string { + if (rows.length === 0) return ""; + + const colCount = rows[0].length; + const normalizedRows = rows.map((row) => + Array.from({ length: colCount }, (_, i) => { + return row[i] ?? ""; + }) + ); + const calcW = (cs: number[]) => cs.reduce((a, b) => a + b + 2, 0) + cs.length + 1; + + // Natural width per column, measured as terminal cells rather than UTF-16 units. + const natural: number[] = Array.from({ length: colCount }, (_, i) => { + const texts = normalizedRows.map((r) => r[i] ?? ""); + const maxLine = Math.max(4, ...texts.map((t) => visualWidth(t))); + return maxLine; + }); + + // Keep minimums small so long CJK text or unbroken tokens can wrap by character. + const minWidths: number[] = Array.from({ length: colCount }, (_, i) => { + const headerWidth = visualWidth(normalizedRows[0]?.[i] ?? ""); + const labelColumn = natural[i] <= 12; + const minReadable = labelColumn ? natural[i] : Math.max(4, Math.min(headerWidth, 12)); + return Math.min(natural[i], minReadable); + }); + + let colWidths: number[]; + const totalNatural = calcW(natural); + const totalMin = calcW(minWidths); + + const effectiveMax = maxWidth ?? 120; // default to a generous terminal width + + if (totalNatural <= effectiveMax) { + // Content fits comfortably — use natural widths and grow to fill available space + colWidths = [...natural]; + const slack = effectiveMax - totalNatural; + if (slack > 0) { + // Distribute slack proportionally to content columns (skip tiny label columns) + const isLabel = colWidths.map((w) => w <= 8); + const candidates = colWidths.map((w, i) => (isLabel[i] ? 0 : w)); + const totalWeight = candidates.reduce((a, b) => a + b, 0); + if (totalWeight > 0) { + for (let ci = 0; ci < colCount; ci++) { + if (candidates[ci] > 0) { + colWidths[ci] += Math.floor((slack * candidates[ci]) / totalWeight); + } + } + } + } + } else if (totalMin >= effectiveMax) { + colWidths = [...minWidths]; + while (calcW(colWidths) > effectiveMax && colWidths.some((w) => w > 1)) { + const widest = colWidths.reduce((maxIdx, width, idx) => (width > colWidths[maxIdx] ? idx : maxIdx), 0); + colWidths[widest]--; + } + } else { + // Need to compress — start from mins, share remaining budget proportionally + const budget = effectiveMax - totalMin; + const deficits = natural.map((n, i) => Math.max(0, n - minWidths[i])); + const totalDeficit = deficits.reduce((a, b) => a + b, 0); + colWidths = [...minWidths]; + if (totalDeficit > 0) { + for (let ci = 0; ci < colCount; ci++) { + colWidths[ci] += Math.floor((budget * deficits[ci]) / totalDeficit); + } + } + // Distribute any leftover due to flooring + let used = calcW(colWidths); + const deficitByIdx = colWidths.map((w, i) => ({ i, gap: natural[i] - w })); + deficitByIdx.sort((a, b) => b.gap - a.gap); + for (const { i } of deficitByIdx) { + if (used >= effectiveMax) break; + if (colWidths[i] < natural[i]) { + colWidths[i]++; + used = calcW(colWidths); + } + } + } + + // Word-wrap a single cell + const wrapCell = (text: string, width: number): string[] => { + if (!text) return [""]; + const lines: string[] = []; + let cur = ""; + const flush = () => { + if (cur.trim()) lines.push(cur.replace(/\s+$/, "")); + cur = ""; + }; + + for (const ch of text) { + const cw = visualWidth(ch); + if (visualWidth(cur) + cw > width) { + const lastSpace = cur.lastIndexOf(" "); + if (lastSpace > width / 3) { + const carry = cur.slice(lastSpace + 1); + cur = cur.slice(0, lastSpace); + flush(); + cur = carry + ch; + } else { + flush(); + cur = ch; + } + } else { + cur += ch; + } + } + if (cur.trim()) lines.push(cur.replace(/\s+$/, "")); + return lines.length > 0 ? lines : [""]; + }; + + const wrapped = normalizedRows.map((r) => r.map((c, ci) => wrapCell(c, colWidths[ci]))); + const heights = wrapped.map((wr) => Math.max(1, ...wr.map((lines) => lines.length))); + + const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - visualWidth(s))); + + const top = "┌" + colWidths.map((w) => "─".repeat(w + 2)).join("┬") + "┐"; + const hdr = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; + const sep = "├" + colWidths.map((w) => "─".repeat(w + 2)).join("┼") + "┤"; + const bot = "└" + colWidths.map((w) => "─".repeat(w + 2)).join("┴") + "┘"; + + const out: string[] = [top]; + + for (let ri = 0; ri < wrapped.length; ri++) { + const h = heights[ri]; + for (let li = 0; li < h; li++) { + const line = wrapped[ri].map((cellLines, ci) => " " + pad(cellLines[li] ?? "", colWidths[ci]) + " "); + out.push("│" + line.join("│") + "│"); + } + if (ri === 0 && rows.length > 1) out.push(hdr); + else if (ri < rows.length - 1) out.push(sep); + } + + out.push(bot); + return out.join("\n"); +} + +// --------------------------------------------------------------------------- +// Inline formatting (headings, lists, quotes, bold/italic/code) +// --------------------------------------------------------------------------- + +function renderInlineLine(line: string): string { + const headingMatch = /^(\s*)(#{1,6})\s+(.*)$/.exec(line); + if (headingMatch) { + const [, lead, hashes, content] = headingMatch; + const styled = hashes.length <= 2 ? chalk.bold.cyanBright(content) : chalk.bold.cyan(content); + return `${lead}${chalk.dim(hashes)} ${styled}`; + } + + const listMatch = /^(\s*)([-*+])\s+(.*)$/.exec(line); + if (listMatch) { + const [, lead, bullet, content] = listMatch; + return `${lead}${chalk.yellow(bullet)} ${renderInlineSpans(content)}`; + } + + const numListMatch = /^(\s*)(\d+\.)\s+(.*)$/.exec(line); + if (numListMatch) { + const [, lead, marker, content] = numListMatch; + return `${lead}${chalk.yellow(marker)} ${renderInlineSpans(content)}`; + } + + const quoteMatch = /^(\s*)>\s?(.*)$/.exec(line); + if (quoteMatch) { + const [, lead, content] = quoteMatch; + return `${lead}${chalk.dim("│ ")}${chalk.italic(renderInlineSpans(content))}`; + } + + return renderInlineSpans(line); +} + +function renderInlineSpans(text: string): string { + if (!text) return text; + + const parts: string[] = []; + const codeRe = /`([^`]+)`/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = codeRe.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(renderEmphasisSpans(text.slice(lastIndex, match.index))); + } + parts.push(chalk.cyan(match[1] ?? "")); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(renderEmphasisSpans(text.slice(lastIndex))); + } + + return parts.join(""); +} + +function renderEmphasisSpans(text: string): string { + let result = text; + result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); + result = result.replace(/(? chalk.italic(inner)); + result = result.replace(/(? chalk.italic(inner)); + return result; +} diff --git a/packages/cli/src/ui/components/MessageView/types.ts b/packages/cli/src/ui/components/MessageView/types.ts new file mode 100644 index 00000000..dc727469 --- /dev/null +++ b/packages/cli/src/ui/components/MessageView/types.ts @@ -0,0 +1,19 @@ +import type { SessionMessage } from "@vegamo/deepcode-core"; + +export type MessageViewProps = { + message: SessionMessage; + collapsed?: boolean; + width?: number; +}; +export type ToolSummary = { + name: string; + params: string; + ok: boolean; + metadata: Record | null; +}; + +export type DiffPreviewLine = { + marker: string; + content: string; + kind: "added" | "removed" | "context"; +}; diff --git a/packages/cli/src/ui/components/MessageView/utils.ts b/packages/cli/src/ui/components/MessageView/utils.ts new file mode 100644 index 00000000..4b6158d1 --- /dev/null +++ b/packages/cli/src/ui/components/MessageView/utils.ts @@ -0,0 +1,292 @@ +import type { DiffPreviewLine, ToolSummary } from "./types"; +import type { SessionMessage } from "@vegamo/deepcode-core"; +import { RawMode } from "../../contexts"; +import chalk from "chalk"; + +/** Type guard that checks whether a value is a plain object (not null, not an array). */ +export function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +/** Capitalizes the first character of a tool status name, falling back to "Tool". */ +export function formatStatusName(value: string): string { + return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : "Tool"; +} + +/** Truncates a string to the given maximum length, appending an ellipsis when truncated. */ +export function truncate(value: string, max: number): string { + if (value.length <= max) { + return value; + } + return `${value.slice(0, max)}…`; +} + +/** Returns the first non-empty line from a multi-line string, normalizing whitespace. */ +export function firstNonEmptyLine(value: string): string { + for (const line of value.split(/\r?\n/)) { + const trimmed = line.trim().replace(/\s+/g, " "); + if (trimmed) { + return trimmed; + } + } + return ""; +} + +/** + * Builds a one-line summary of thinking / reasoning content. + * Falls back to "(reasoning...)" when only reasoning_content params are present. + */ +export function buildThinkingSummary(content: string, messageParams: unknown | null, mode?: RawMode): string { + if (content) { + const normalized = content.replace(/\r?\n/g, " ").replace(/\s+/g, " "); + let result = truncate(normalized, 100); + if (result.endsWith(":") || result.endsWith(":")) { + result = result.slice(0, -1); + } + return result; + } + + const params = messageParams as { reasoning_content?: unknown } | null | undefined; + if (typeof params?.reasoning_content === "string" && params.reasoning_content.trim()) { + return mode !== RawMode.Lite ? params?.reasoning_content || "" : "(reasoning...)"; + } + + return ""; +} + +/** Formats multi-line Bash params as first line, a placeholder, and the final line. */ +export function formatBashStatusParams(params: string): string { + const value = params.trim(); + if (!value) { + return ""; + } + + const lines = value.split(/\r?\n/); + if (lines.length <= 1) { + return value; + } + + return `${lines[0]} ... ${lines[lines.length - 1].trimStart()}`; +} + +/** Formats a tool's parameters for status display, compacting multi-line Bash commands and truncating others. */ +export function formatToolStatusParams(summary: ToolSummary): string { + if (summary.name.toLowerCase() === "bash") { + return formatBashStatusParams(summary.params); + } + const params = firstNonEmptyLine(summary.params); + return truncate(params, 120); +} + +/** Builds a structured summary (name, params, ok, metadata) from a tool session message. */ +export function buildToolSummary(message: SessionMessage): ToolSummary { + const payload = parseToolPayload(message.content); + const metaFunctionName = + message.meta?.function && typeof (message.meta.function as { name?: unknown }).name === "string" + ? (message.meta.function as { name: string }).name + : null; + const name = payload.name || metaFunctionName || "tool"; + const params = + name === "AskUserQuestion" + ? extractAskUserQuestionParams(message) || getMetaParams(message) + : getMetaParams(message); + + return { + name, + params, + ok: payload.ok !== false, + metadata: payload.metadata, + }; +} + +/** Extracts the paramsMd field from a session message's metadata, trimmed. */ +export function getMetaParams(message: SessionMessage): string { + return typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; +} + +/** + * Extracts human-readable question text from an AskUserQuestion tool message. + * Tries the tool function arguments first, then falls back to parsing metadata params. + */ +export function extractAskUserQuestionParams(message: SessionMessage): string { + const fromFunction = extractQuestionsFromToolFunction(message.meta?.function); + if (fromFunction) { + return fromFunction; + } + + const params = getMetaParams(message); + if (!params) { + return ""; + } + + try { + const parsed = JSON.parse(params); + return extractQuestionsFromValue(parsed); + } catch { + return ""; + } +} + +/** + * Extracts question strings from a tool function object by parsing its JSON arguments. + */ +export function extractQuestionsFromToolFunction(toolFunction: unknown): string { + if (!toolFunction || typeof toolFunction !== "object") { + return ""; + } + const args = (toolFunction as { arguments?: unknown }).arguments; + if (typeof args !== "string" || !args.trim()) { + return ""; + } + try { + const parsed = JSON.parse(args); + return extractQuestionsFromValue((parsed as { questions?: unknown })?.questions); + } catch { + return ""; + } +} + +/** Extracts and joins question strings from an array of question objects. */ +export function extractQuestionsFromValue(value: unknown): string { + if (!Array.isArray(value)) { + return ""; + } + return value + .map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return ""; + } + return typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; + }) + .filter(Boolean) + .join(" / "); +} + +/** Parses a tool's JSON payload, extracting name, ok flag, and metadata. */ +export function parseToolPayload(content: string | null): { + name: string | null; + ok: boolean; + metadata: Record | null; +} { + if (!content) { + return { name: null, ok: true, metadata: null }; + } + + try { + const parsed = JSON.parse(content) as { name?: unknown; ok?: unknown; metadata?: unknown }; + return { + name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null, + ok: parsed.ok !== false, + metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null, + }; + } catch { + return { name: null, ok: true, metadata: null }; + } +} + +/** + * Returns structured diff preview lines for successful edit or write tool calls. + * Returns an empty array if the tool is not edit/write or has no diff_preview metadata. + */ +export function getToolDiffPreviewLines(summary: ToolSummary): DiffPreviewLine[] { + if (!summary.ok || !["edit", "write"].includes(summary.name.toLowerCase())) { + return []; + } + const diffPreview = summary.metadata?.diff_preview; + if (typeof diffPreview !== "string" || !diffPreview.trim()) { + return []; + } + return parseDiffPreview(diffPreview); +} + +/** Parses a unified-diff-style preview string into an array of structured diff lines. */ +export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { + return diffPreview + .split("\n") + .filter((line) => line && !line.startsWith("--- ") && !line.startsWith("+++ ") && !line.startsWith("@@ ")) + .map((line) => { + if (line.startsWith("+")) { + return { marker: "+", content: line.slice(1), kind: "added" }; + } + if (line.startsWith("-")) { + return { marker: "-", content: line.slice(1), kind: "removed" }; + } + return { + marker: " ", + content: line.startsWith(" ") ? line.slice(1) : line, + kind: "context", + }; + }); +} + +export function renderMessageToStdout(message: SessionMessage, mode: RawMode): string { + if (!message.visible) { + return ""; + } + + if (message.role === "user") { + const text = message.content || "(no content)"; + return chalk(`> ${text}`); + } + + if (message.role === "assistant") { + const isThinking = Boolean(message.meta?.asThinking); + const content = (message.content || "").trim(); + + if (isThinking) { + const summary = buildThinkingSummary(content, message.messageParams, mode); + return `${chalk("✧")} ${chalk("Thinking")}${summary ? ` ${chalk(summary)}` : ""}`; + } + + return `${chalk("✦")} ${content}`; + } + + if (message.role === "tool") { + const summary = buildToolSummary(message); + const params = formatToolStatusParams(summary); + const statusLine = `${chalk("✧")} ${chalk(formatStatusName(summary.name))}${params ? ` ${chalk(params)}` : ""}`; + + const metaResultMd = typeof message.meta?.resultMd === "string" ? message.meta.resultMd.trim() : ""; + const result = metaResultMd ? `\n${chalk.dim(" └ Result")}\n${metaResultMd}` : ""; + + const planLines = getUpdatePlanPreviewLines(summary); + if (planLines.length > 0) { + const planText = planLines.map((line) => ` ${line}`).join("\n"); + return `${statusLine}\n${chalk.dim(" └ Plan")}\n${planText}${result}`; + } + + return `${statusLine}${result}`; + } + + if (message.role === "system") { + if (message.meta?.isModelChange) { + return chalk(`> ${message.content}`); + } + if (message.meta?.skill && typeof message.meta.skill === "object") { + const skillName = (message.meta.skill as { name?: unknown }).name; + return chalk(`⚡ Loaded skill: ${typeof skillName === "string" ? skillName : ""}`); + } + if (message.meta?.isSummary) { + return chalk.dim.italic("(conversation summary inserted)"); + } + return ""; + } + + return ""; +} + +export function getUpdatePlanPreviewLines(summary: ToolSummary): string[] { + if (!summary.ok || summary.name !== "UpdatePlan") { + return []; + } + const plan = summary.metadata?.plan; + if (typeof plan !== "string" || !plan.trim()) { + return []; + } + return plan + .split(/\r?\n/) + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); +} diff --git a/packages/cli/src/ui/components/ModelsDropdown/index.tsx b/packages/cli/src/ui/components/ModelsDropdown/index.tsx new file mode 100644 index 00000000..9fe968b4 --- /dev/null +++ b/packages/cli/src/ui/components/ModelsDropdown/index.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useState } from "react"; +import { useInput } from "ink"; +import DropdownMenu from "../DropdownMenu"; +import type { ModelConfigSelection, ReasoningEffort } from "@vegamo/deepcode-core"; + +type ModelStep = "model" | "thinking"; + +type ThinkingModeOption = { + label: string; + thinkingEnabled: boolean; + reasoningEffort?: ReasoningEffort; +}; + +export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; + +export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ + { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, + { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, + { label: "No thinking", thinkingEnabled: false }, +]; + +function getThinkingOptionIndex(config: Pick): number { + const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { + if (!config.thinkingEnabled) { + return !option.thinkingEnabled; + } + return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort; + }); + return index >= 0 ? index : 0; +} + +type Props = { + open: boolean; + modelConfig: ModelConfigSelection; + width: number; + onClose: () => void; + onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + onStatusMessage?: (message: string | null) => void; +}; + +const ModelsDropdown: React.FC = ({ + open, + modelConfig, + width, + onClose, + onModelConfigChange, + onStatusMessage, +}) => { + const [step, setStep] = useState(null); + const [activeIndex, setActiveIndex] = useState(0); + const [pendingModel, setPendingModel] = useState(null); + + // Initialize state when opened + useEffect(() => { + if (open) { + const currentIndex = MODEL_COMMAND_MODELS.findIndex((m) => m === modelConfig.model); + setPendingModel(null); + setStep("model"); + setActiveIndex(currentIndex >= 0 ? currentIndex : 0); + } else { + setStep(null); + } + }, [open, modelConfig.model]); + + // Validate activeIndex bounds + useEffect(() => { + if (!step) { + return; + } + const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + if (activeIndex >= optionCount) { + setActiveIndex(Math.max(0, optionCount - 1)); + } + }, [activeIndex, step]); + + function selectItem(): void { + if (step === "model") { + const model = MODEL_COMMAND_MODELS[activeIndex] ?? modelConfig.model; + setPendingModel(model); + setStep("thinking"); + setActiveIndex(getThinkingOptionIndex(modelConfig)); + return; + } + + const option = MODEL_COMMAND_THINKING_OPTIONS[activeIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]!; + const selection: ModelConfigSelection = { + model: pendingModel ?? modelConfig.model, + thinkingEnabled: option.thinkingEnabled, + reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, + }; + onClose(); + Promise.resolve(onModelConfigChange(selection)) + .then((message) => { + if (message) { + onStatusMessage?.(message); + } + }) + .catch((error) => { + const msg = error instanceof Error ? error.message : String(error); + onStatusMessage?.(`Failed to update model settings: ${msg}`); + }); + } + + useInput( + (input, key) => { + if (!step) { + return; + } + + const optionCount = step === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; + + if (key.upArrow) { + setActiveIndex((idx) => (idx - 1 + optionCount) % optionCount); + return; + } + if (key.downArrow) { + setActiveIndex((idx) => (idx + 1) % optionCount); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + selectItem(); + return; + } + if (key.tab || key.escape) { + onClose(); + return; + } + }, + { isActive: open } + ); + + if (!open || !step) { + return null; + } + + const items = + step === "model" + ? MODEL_COMMAND_MODELS.map((model) => ({ + key: model, + label: model, + description: model === modelConfig.model ? "current model" : "", + selected: model === (pendingModel ?? modelConfig.model), + })) + : MODEL_COMMAND_THINKING_OPTIONS.map((option, i) => ({ + key: option.label, + label: option.label, + description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", + selected: getThinkingOptionIndex(modelConfig) === i, + })); + + return ( + + ); +}; + +export { getThinkingOptionIndex }; +export default ModelsDropdown; diff --git a/packages/cli/src/ui/components/RawModeExitPrompt/index.tsx b/packages/cli/src/ui/components/RawModeExitPrompt/index.tsx new file mode 100644 index 00000000..57ebf074 --- /dev/null +++ b/packages/cli/src/ui/components/RawModeExitPrompt/index.tsx @@ -0,0 +1,20 @@ +import { useRef, type ReactElement } from "react"; +import { useInput } from "ink"; +import { useRawModeContext, type RawMode } from "../../contexts"; + +export function RawModeExitPrompt({ onExit }: { onExit: (previousMode: RawMode) => void }): ReactElement | null { + const { previousMode } = useRawModeContext(); + // Snapshot the prior mode at mount so later context updates do not change the ESC target. + const snapshotRef = useRef(previousMode); + + useInput( + (_input, key) => { + if (key.escape) { + onExit(snapshotRef.current); + } + }, + { isActive: true } + ); + + return null; +} diff --git a/packages/cli/src/ui/components/RawModelDropdown/index.tsx b/packages/cli/src/ui/components/RawModelDropdown/index.tsx new file mode 100644 index 00000000..67f053c9 --- /dev/null +++ b/packages/cli/src/ui/components/RawModelDropdown/index.tsx @@ -0,0 +1,55 @@ +import React, { useState } from "react"; +import { useInput } from "ink"; +import DropdownMenu from "../DropdownMenu"; +import type { RawMode } from "../../contexts"; +import { RAW_COMMAND_MODELS, useRawModeContext } from "../../contexts"; + +const RawModelDropdown: React.FC<{ + open: boolean; + screenWidth: number; + onClose?: (value: boolean) => void; + onSelect?: (model: string) => void; +}> = ({ open = false, screenWidth, onSelect, onClose }) => { + const { mode, setMode } = useRawModeContext(); + const [index, setIndex] = useState(0); + useInput( + (input, key) => { + if (key.upArrow) { + setIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setIndex((i) => Math.min(RAW_COMMAND_MODELS.length - 1, i + 1)); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + setMode(RAW_COMMAND_MODELS[index].key as RawMode); + onClose?.(false); + onSelect?.(RAW_COMMAND_MODELS[index].key); + return; + } + if (key.escape) { + onClose?.(false); + return; + } + }, + { isActive: open } + ); + if (!open) { + return null; + } + return ( + ({ ...model, selected: model.key === mode }))} + helpText="Space/Enter select mode · Esc to close" + // onSelect={onSelect} + activeColor="#229ac3" + maxVisible={6} + activeIndex={index} + width={screenWidth} + /> + ); +}; + +export default RawModelDropdown; diff --git a/packages/cli/src/ui/components/SkillsDropdown/index.tsx b/packages/cli/src/ui/components/SkillsDropdown/index.tsx new file mode 100644 index 00000000..1fe65ebb --- /dev/null +++ b/packages/cli/src/ui/components/SkillsDropdown/index.tsx @@ -0,0 +1,74 @@ +import DropdownMenu from "../DropdownMenu"; +import React, { useEffect, useState } from "react"; +import type { SkillInfo } from "@vegamo/deepcode-core"; +import { useInput } from "ink"; +import { isSkillSelected } from "../../views/SlashCommandMenu"; + +const SkillsDropdown: React.FC<{ + open: boolean; + onClose?: (value: boolean) => void; + width: number; + skills: SkillInfo[]; + selectedSkills: SkillInfo[]; + onSelect?: (skill: SkillInfo) => void; +}> = ({ open, width, skills, selectedSkills, onSelect, onClose }) => { + const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); + useInput( + (input, key) => { + if (key.upArrow) { + setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); + return; + } + if (key.downArrow) { + setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); + return; + } + if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { + const skill = skills[skillsDropdownIndex]; + if (skill) { + onSelect?.(skill); + } + return; + } + if (key.tab) { + onClose?.(false); + return; + } + if (key.escape) { + onClose?.(false); + } + }, + { isActive: open } + ); + + useEffect(() => { + if (skillsDropdownIndex >= skills.length) { + setSkillsDropdownIndex(Math.max(0, skills.length - 1)); + } + }, [skills.length, skillsDropdownIndex]); + + if (!open) { + return null; + } + + return ( + ({ + key: skill.path || skill.name, + label: skill.name, + description: skill.path, + selected: isSkillSelected(selectedSkills, skill), + statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, + }))} + activeIndex={skillsDropdownIndex} + activeColor="#229ac3" + maxVisible={6} + /> + ); +}; + +export default SkillsDropdown; diff --git a/packages/cli/src/ui/components/index.ts b/packages/cli/src/ui/components/index.ts new file mode 100644 index 00000000..f3cbd675 --- /dev/null +++ b/packages/cli/src/ui/components/index.ts @@ -0,0 +1,7 @@ +export { default as RawModelDropdown } from "./RawModelDropdown"; +export { MessageView } from "./MessageView"; +export { RawModeExitPrompt } from "./RawModeExitPrompt"; +export { default as SkillsDropdown } from "./SkillsDropdown"; +export { default as ModelsDropdown } from "./ModelsDropdown"; +export { default as FileMentionMenu } from "./FileMentionMenu"; +export { default as DropdownMenu } from "./DropdownMenu"; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts new file mode 100644 index 00000000..7b336f10 --- /dev/null +++ b/packages/cli/src/ui/constants.ts @@ -0,0 +1,5 @@ +/** Separator used when rendering command arguments inline (e.g., `arg1 | arg2 | arg3`). */ +export const ARGS_SEPARATOR = " | "; + +/** ANSI escape code to clear the screen. */ +export const ANSI_CLEAR_SCREEN = "\u001B[2J\u001B[3J\u001B[H"; diff --git a/packages/cli/src/ui/contexts/AppContext.tsx b/packages/cli/src/ui/contexts/AppContext.tsx new file mode 100644 index 00000000..41b1d1d4 --- /dev/null +++ b/packages/cli/src/ui/contexts/AppContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from "react"; + +export interface AppState { + version: string; +} + +export const AppContext = createContext(null); + +export const useAppContext = (): AppState => { + const context = useContext(AppContext); + if (!context) { + // Safe fallback when App is rendered without AppContainer (e.g., in tests). + return { version: "unknown" }; + } + return context; +}; diff --git a/packages/cli/src/ui/contexts/RawModeContext.tsx b/packages/cli/src/ui/contexts/RawModeContext.tsx new file mode 100644 index 00000000..969cea71 --- /dev/null +++ b/packages/cli/src/ui/contexts/RawModeContext.tsx @@ -0,0 +1,67 @@ +import React, { createContext, useCallback, useContext, useRef, useState } from "react"; +import type { DropdownMenuItem } from "../components/DropdownMenu"; + +export enum RawMode { + None = "Normal mode", + Lite = "Lite mode", + Raw = "Raw scrollback mode", +} +export const RAW_COMMAND_MODELS: DropdownMenuItem[] = [ + { + label: "Lite mode", + key: RawMode.Lite, + description: "Collapse chain-of-thought reasoning.", + }, + { + label: "Normal mode", + key: RawMode.None, + description: "Show full chain-of-thought reasoning.", + }, + { + label: "Raw scrollback mode", + key: RawMode.Raw, + description: "Show scrollback mode for copy-friendly terminal selection.", + }, +] as const; + +type RawModeContextValue = { + mode: RawMode; + setMode: React.Dispatch>; + // The mode that was active right before the most recent mode transition. + previousMode: RawMode; +}; + +const RawModeContext = createContext({ + mode: RawMode.Lite, + setMode: () => {}, + previousMode: RawMode.Lite, +}); + +export function useRawModeContext() { + const context = useContext(RawModeContext); + if (!context) { + throw new Error("useRawModeContext must be used within a RawModeProvider"); + } + return context; +} + +export const RawModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [mode, _setMode] = useState(RawMode.Lite); + const previousModeRef = useRef(RawMode.Lite); + + const setMode = useCallback>>((next) => { + _setMode((current) => { + const resolved = typeof next === "function" ? (next as (prev: RawMode) => RawMode)(current) : next; + if (resolved !== current) { + previousModeRef.current = current; + } + return resolved; + }); + }, []); + + return ( + + {children} + + ); +}; diff --git a/packages/cli/src/ui/contexts/index.ts b/packages/cli/src/ui/contexts/index.ts new file mode 100644 index 00000000..37e40cdb --- /dev/null +++ b/packages/cli/src/ui/contexts/index.ts @@ -0,0 +1,3 @@ +export { AppContext, useAppContext } from "./AppContext"; +export type { AppState } from "./AppContext"; +export { RawMode, RAW_COMMAND_MODELS, useRawModeContext, RawModeProvider } from "./RawModeContext"; diff --git a/src/ui/askUserQuestion.ts b/packages/cli/src/ui/core/ask-user-question.ts similarity index 98% rename from src/ui/askUserQuestion.ts rename to packages/cli/src/ui/core/ask-user-question.ts index 8d168d86..f49b191e 100644 --- a/src/ui/askUserQuestion.ts +++ b/packages/cli/src/ui/core/ask-user-question.ts @@ -1,4 +1,4 @@ -import type { SessionMessage, SessionStatus } from "../session"; +import type { SessionMessage, SessionStatus } from "@vegamo/deepcode-core"; export type AskUserQuestionOption = { label: string; diff --git a/src/ui/clipboard.ts b/packages/cli/src/ui/core/clipboard.ts similarity index 100% rename from src/ui/clipboard.ts rename to packages/cli/src/ui/core/clipboard.ts diff --git a/src/ui/fileMentions.ts b/packages/cli/src/ui/core/file-mentions.ts similarity index 90% rename from src/ui/fileMentions.ts rename to packages/cli/src/ui/core/file-mentions.ts index cbacbe6d..54847f12 100644 --- a/src/ui/fileMentions.ts +++ b/packages/cli/src/ui/core/file-mentions.ts @@ -1,7 +1,7 @@ import * as fs from "fs"; import * as path from "path"; import ignore from "ignore"; -import type { PromptBufferState } from "./promptBuffer"; +import type { PromptBufferState } from "./prompt-buffer"; export type FileMentionItem = { path: string; @@ -15,8 +15,20 @@ export type FileMentionToken = { quoted: boolean; }; -const DEFAULT_MAX_ITEMS = 2000; +const DEFAULT_MAX_ITEMS = 20000; const DEFAULT_MAX_DEPTH = 8; +const DEFAULT_NOISY_DIR_NAMES = [ + ".git", + ".next", + ".pytest_cache", + ".ruff_cache", + "__pycache__", + "build", + "dist", + "node_modules", + "out", + "target", +]; type IgnoreMatcher = { base: string; @@ -104,7 +116,8 @@ export function scanFileMentionItems(root: string, maxItems = DEFAULT_MAX_ITEMS) if (rootRealPath) { visitedDirectories.add(rootRealPath); } - visit(root, 0, loadAncestorIgnoreMatchers(root, gitRoot)); + const initialMatchers = [...loadDefaultIgnoreMatchers(root, gitRoot), ...loadAncestorIgnoreMatchers(root, gitRoot)]; + visit(root, 0, initialMatchers); return items; } @@ -162,6 +175,33 @@ function loadDirectoryIgnoreMatchers(directory: string, gitRoot: string | null): return matchers; } +function loadDefaultIgnoreMatchers(root: string, gitRoot: string | null): IgnoreMatcher[] { + if (hasApplicableGitignore(root, gitRoot)) { + return []; + } + const patterns = DEFAULT_NOISY_DIR_NAMES.map((name) => `${name}/`); + return [{ base: root, matcher: ignore().add(patterns) }]; +} + +function hasApplicableGitignore(root: string, gitRoot: string | null): boolean { + if (!gitRoot) { + return false; + } + + const resolvedGitRoot = path.resolve(gitRoot); + let current = path.resolve(root); + while (isPathInsideOrEqual(current, resolvedGitRoot)) { + if (fs.existsSync(path.join(current, ".gitignore"))) { + return true; + } + if (current === resolvedGitRoot) { + break; + } + current = path.dirname(current); + } + return false; +} + function loadAncestorIgnoreMatchers(root: string, gitRoot: string | null): IgnoreMatcher[] { const resolvedRoot = path.resolve(root); const ancestors: string[] = []; diff --git a/src/ui/loadingText.ts b/packages/cli/src/ui/core/loading-text.ts similarity index 96% rename from src/ui/loadingText.ts rename to packages/cli/src/ui/core/loading-text.ts index bfb97d4c..c757ce55 100644 --- a/src/ui/loadingText.ts +++ b/packages/cli/src/ui/core/loading-text.ts @@ -1,4 +1,4 @@ -import type { LlmStreamProgress, SessionEntry } from "../session"; +import type { LlmStreamProgress, SessionEntry } from "@vegamo/deepcode-core"; type RunningProcesses = SessionEntry["processes"]; diff --git a/src/ui/promptBuffer.ts b/packages/cli/src/ui/core/prompt-buffer.ts similarity index 51% rename from src/ui/promptBuffer.ts rename to packages/cli/src/ui/core/prompt-buffer.ts index 3e3c1827..c3249304 100644 --- a/src/ui/promptBuffer.ts +++ b/packages/cli/src/ui/core/prompt-buffer.ts @@ -155,20 +155,145 @@ export function isEmpty(state: PromptBufferState): boolean { export function getCurrentSlashToken(state: PromptBufferState): string | null { const text = state.text; - if (text.length === 0) { + if (text.length === 0 || !text.startsWith("/")) { return null; } - const beforeCursor = text.slice(0, state.cursor); - const lastNewline = beforeCursor.lastIndexOf("\n"); - const lineStart = lastNewline + 1; - const line = beforeCursor.slice(lineStart); - if (!line.startsWith("/")) { - return null; + return text; +} + +/** + * Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. + * When the user pastes a large block of text (>10 lines or >1000 chars), a compact + * marker is inserted instead of the full content. The actual content is stored in a + * Map and expanded back before submission. + */ +export const PASTE_MARKER_REGEX = /\[paste #(\d+) (\+?\d+ lines|\d+ chars)\]/g; + +/** + * Find the paste marker that ends exactly at `state.cursor`, if any. + * Returns the marker's start and end positions, or `null`. + */ +export function findPasteMarkerBefore(state: PromptBufferState): { start: number; end: number } | null { + // Walk backwards through all markers and return the one that ends at the cursor. + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index + match[0].length === state.cursor) { + return { start: match.index, end: match.index + match[0].length }; + } } - if (/\s/.test(line)) { - return null; + return null; +} + +/** + * Find the paste marker that starts exactly at `state.cursor`, if any. + * Returns the marker's start and end positions, or `null`. + */ +export function findPasteMarkerAt(state: PromptBufferState): { start: number; end: number } | null { + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index === state.cursor) { + return { start: match.index, end: match.index + match[0].length }; + } + } + return null; +} + +/** + * If the cursor is immediately after a paste marker, delete the entire marker + * (atomic backspace). Returns the new state, or `state` unchanged if no marker. + */ +export function deletePasteMarkerBackward( + state: PromptBufferState, + validIds: Map +): PromptBufferState | null { + const marker = findPasteMarkerBefore(state); + if (!marker) return null; + // Only delete if this is a real paste marker (ID in validIds). + PASTE_MARKER_REGEX.lastIndex = 0; + const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end)); + if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null; + const text = state.text.slice(0, marker.start) + state.text.slice(marker.end); + return { text, cursor: marker.start }; +} + +/** + * If the cursor is at the start of a paste marker, delete the entire marker + * (atomic forward delete). Returns the new state, or `state` unchanged if no marker. + */ +export function deletePasteMarkerForward( + state: PromptBufferState, + validIds: Map +): PromptBufferState | null { + const marker = findPasteMarkerAt(state); + if (!marker) return null; + // Only delete if this is a real paste marker (ID in validIds). + PASTE_MARKER_REGEX.lastIndex = 0; + const m = PASTE_MARKER_REGEX.exec(state.text.slice(marker.start, marker.end)); + if (!m || !validIds.has(Number.parseInt(m[1]!, 10))) return null; + const text = state.text.slice(0, marker.start) + state.text.slice(marker.end); + return { text, cursor: marker.start }; +} + +/** + * Sanitize stored paste content (filter control chars, expand tabs). + * Called lazily on expand/submit, not during paste to keep paste instant. + */ +export function cleanPasteContent(text: string): string { + return text + .replace(/\r\n|\r/g, "\n") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/\t/g, " "); +} + +/** + * Expand paste markers in the text back to their original (cleaned) content. + * @param text - Text potentially containing paste markers. + * @param pastes - Map of paste ID → original content. + */ +export function expandPasteMarkers(text: string, pastes: Map): string { + if (pastes.size === 0) return text; + let result = text; + for (const [pasteId, pasteContent] of pastes) { + const markerRegex = new RegExp(`\\[paste #${pasteId} (\\+?\\d+ lines|\\d+ chars)\\]`, "g"); + result = result.replace(markerRegex, () => cleanPasteContent(pasteContent)); + } + return result; +} + +/** + * Find the paste marker that contains `state.cursor`, if any. + * Returns the marker's start, end, and numeric paste ID, or `null`. + */ +export function findPasteMarkerContaining(state: PromptBufferState): { start: number; end: number; id: number } | null { + let match: RegExpExecArray | null; + PASTE_MARKER_REGEX.lastIndex = 0; + while ((match = PASTE_MARKER_REGEX.exec(state.text)) !== null) { + if (match.index <= state.cursor && match.index + match[0].length >= state.cursor) { + return { + start: match.index, + end: match.index + match[0].length, + id: Number.parseInt(match[1]!, 10), + }; + } + } + return null; +} + +/** + * Check whether the text contains real paste markers (IDs present in validIds). + */ +export function hasActivePasteMarkers(text: string, validIds: Map): boolean { + if (!text.includes("[paste #")) return false; + PASTE_MARKER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) { + if (validIds.has(Number.parseInt(match[1]!, 10))) { + return true; + } } - return line; + return false; } function locate(state: PromptBufferState): { diff --git a/src/ui/promptUndoRedo.ts b/packages/cli/src/ui/core/prompt-undo-redo.ts similarity index 95% rename from src/ui/promptUndoRedo.ts rename to packages/cli/src/ui/core/prompt-undo-redo.ts index 9d30f57b..fd2870a6 100644 --- a/src/ui/promptUndoRedo.ts +++ b/packages/cli/src/ui/core/prompt-undo-redo.ts @@ -1,4 +1,4 @@ -import type { PromptBufferState } from "./promptBuffer"; +import type { PromptBufferState } from "./prompt-buffer"; export type PromptUndoRedoState = { undoStack: PromptBufferState[]; diff --git a/src/ui/slashCommands.ts b/packages/cli/src/ui/core/slash-commands.ts similarity index 81% rename from src/ui/slashCommands.ts rename to packages/cli/src/ui/core/slash-commands.ts index 6552ba09..ba5ae6ec 100644 --- a/src/ui/slashCommands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -1,6 +1,17 @@ -import type { SkillInfo } from "../session"; +import type { SkillInfo } from "@vegamo/deepcode-core"; -export type SlashCommandKind = "skill" | "skills" | "model" | "new" | "init" | "resume" | "continue" | "mcp" | "exit"; +export type SlashCommandKind = + | "skill" + | "skills" + | "model" + | "new" + | "init" + | "resume" + | "continue" + | "undo" + | "mcp" + | "raw" + | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -8,6 +19,7 @@ export type SlashCommandItem = { label: string; description: string; skill?: SkillInfo; + args?: string[]; }; export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ @@ -47,12 +59,25 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/continue", description: "Continue the active conversation or pick one to resume", }, + { + kind: "undo", + name: "undo", + label: "/undo", + description: "Restore code and/or conversation to a previous point", + }, { kind: "mcp", name: "mcp", label: "/mcp", description: "Show MCP server status and available tools", }, + { + kind: "raw", + name: "raw", + label: "/raw", + args: ["lite", "normal", "raw-scrollback"], + description: "Toggle display mode for viewing or collapsing reasoning content", + }, { kind: "exit", name: "exit", diff --git a/src/ui/thinkingState.ts b/packages/cli/src/ui/core/thinking-state.ts similarity index 58% rename from src/ui/thinkingState.ts rename to packages/cli/src/ui/core/thinking-state.ts index 6f419e24..0c9c5c7f 100644 --- a/src/ui/thinkingState.ts +++ b/packages/cli/src/ui/core/thinking-state.ts @@ -1,4 +1,4 @@ -import type { SessionMessage } from "../session"; +import type { SessionMessage } from "@vegamo/deepcode-core"; /** * Returns the message id of the assistant "thinking" message that should stay @@ -21,3 +21,18 @@ export function findExpandedThinkingId(messages: SessionMessage[]): string | nul } return expanded; } + +/** + * Returns whether a message's thinking block should be rendered collapsed. + * A thinking message is collapsed when its id does not match the currently + * expanded thinking id. + */ +export function isCollapsedThinking(message: SessionMessage, expandedId: string | null): boolean { + if (message.role !== "assistant") { + return false; + } + if (!message.meta?.asThinking) { + return false; + } + return message.id !== expandedId; +} diff --git a/src/ui/exitSummary.ts b/packages/cli/src/ui/exit-summary.ts similarity index 92% rename from src/ui/exitSummary.ts rename to packages/cli/src/ui/exit-summary.ts index c55d9ce8..67db1280 100644 --- a/src/ui/exitSummary.ts +++ b/packages/cli/src/ui/exit-summary.ts @@ -1,9 +1,10 @@ import chalk from "chalk"; import gradientString from "gradient-string"; -import type { ModelUsage, SessionEntry } from "../session"; +import type { ModelUsage, SessionEntry } from "@vegamo/deepcode-core"; type ExitSummaryInput = { session: SessionEntry | null; + sessionId?: string; }; const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g; @@ -72,7 +73,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const innerWidth = 98; const contentWidth = innerWidth - 4; // "│ " prefix + " │" suffix → 4 chars padding - const borderColor = chalk.hex("#229ac3e6"); + const borderColor = chalk.dim; const titleColor = gradientString("#229ac3e6", "rgb(125 51 247 / 0.7)"); const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; @@ -113,7 +114,7 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { padLeft("Output Tokens", colOutput) + padLeft("Cached Tokens", colCached); rows.push(chalk.bold(headerRow)); - rows.push(divider); + rows.push(chalk.gray(divider)); for (const { modelName, usage } of usageRows) { const reqsStr = formatNumber(usage.totalReqs).padStart(colReqs); @@ -142,3 +143,10 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { return [top, body, bottom].join("\n"); } + +export function buildResumeHintText(sessionId?: string): string | null { + if (!sessionId) { + return null; + } + return chalk.dim(`To continue this session, run `) + chalk.hex("#229ac3")(`deepcode --resume ${sessionId}`); +} diff --git a/packages/cli/src/ui/hooks/cursor.ts b/packages/cli/src/ui/hooks/cursor.ts new file mode 100644 index 00000000..677fa810 --- /dev/null +++ b/packages/cli/src/ui/hooks/cursor.ts @@ -0,0 +1,265 @@ +import { useCursor, useBoxMetrics } from "ink"; +import { useLayoutEffect, useState } from "react"; +import type { RefObject } from "react"; +import type { DOMElement } from "ink"; +import type { PromptBufferState } from "../core/prompt-buffer"; + +export type CursorPlacement = { + row: number; + column: number; +}; + +export type PromptCursorOrigin = { + layoutKey: string; + left: number; + top: number; +}; + +function showCursor(): string { + return "\u001B[?25h"; +} + +function hideCursor(): string { + return "\u001B[?25l"; +} + +function enableTerminalFocusReporting(): string { + return "\u001B[?1004h"; +} + +function disableTerminalFocusReporting(): string { + return "\u001B[?1004l"; +} + +function enableBracketedPaste(): string { + return "\u001B[?2004h"; +} + +function disableBracketedPaste(): string { + return "\u001B[?2004l"; +} + +export function enableTerminalExtendedKeys(): string { + return "\u001B[>4;1m"; +} + +export function disableTerminalExtendedKeys(): string { + return "\u001B[>4;0m"; +} + +export function getPromptCursorPlacement( + state: PromptBufferState, + screenWidth: number, + initialColumn = 0 +): CursorPlacement { + const width = Math.max(1, screenWidth); + const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); + const beforeCursor = state.text.slice(0, cursor); + const cursorPosition = measureTextPosition(beforeCursor, width, initialColumn); + return { row: cursorPosition.row, column: cursorPosition.column }; +} + +export function isPromptCursorAtWrapBoundary(state: PromptBufferState, screenWidth: number): boolean { + const width = Math.max(1, screenWidth); + const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); + const currentLineStart = state.text.lastIndexOf("\n", Math.max(0, cursor - 1)) + 1; + const currentLineBeforeCursor = state.text.slice(currentLineStart, cursor); + return measureTextPosition(currentLineBeforeCursor, width, 0).row > 0; +} + +function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } { + let row = 0; + let column = Math.min(initialColumn, width - 1); + let pendingWrap = false; + + for (const char of Array.from(text)) { + if (char === "\n") { + row++; + column = Math.min(initialColumn, width - 1); + pendingWrap = false; + continue; + } + + if (pendingWrap) { + row++; + column = Math.min(initialColumn, width - 1); + pendingWrap = false; + } + + const charColumns = textWidth(char); + if (column + charColumns > width) { + row++; + column = Math.min(initialColumn, width - 1); + } + column += charColumns; + if (column >= width) { + column = width; + pendingWrap = true; + } + } + + if (pendingWrap) { + return { row: row + 1, column: Math.min(initialColumn, width - 1) }; + } + + return { row, column }; +} + +function textWidth(value: string): number { + let width = 0; + for (const char of Array.from(value.normalize())) { + width += characterWidth(char); + } + return width; +} + +function characterWidth(char: string): number { + const codePoint = char.codePointAt(0) ?? 0; + if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) { + return 0; + } + if (codePoint >= 0x300 && codePoint <= 0x36f) { + return 0; + } + if ( + (codePoint >= 0x1100 && codePoint <= 0x115f) || + (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe19) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) + ) { + return 2; + } + return 1; +} + +export function usePromptTerminalCursor( + targetRef: RefObject, + placement: CursorPlacement, + isActive: boolean, + layoutKey = "default" +): boolean { + const { setCursorPosition } = useCursor(); + const metrics = useBoxMetrics(targetRef as RefObject); + const [origin, setOrigin] = useState(null); + + useLayoutEffect(() => { + if (!isActive || !metrics.hasMeasured) { + return; + } + + const absolutePosition = getAbsoluteElementPosition(targetRef.current); + setOrigin((previous) => { + if (!absolutePosition) { + return previous === null ? previous : null; + } + + if ( + previous?.layoutKey === layoutKey && + previous.left === absolutePosition.left && + previous.top === absolutePosition.top + ) { + return previous; + } + + return { + layoutKey, + left: absolutePosition.left, + top: absolutePosition.top, + }; + }); + }, [isActive, layoutKey, metrics.hasMeasured, metrics.height, metrics.left, metrics.top, metrics.width, targetRef]); + + const cursorPosition = resolvePromptTerminalCursorPosition(placement, isActive, layoutKey, origin); + setCursorPosition(cursorPosition); + return cursorPosition !== undefined; +} + +export function resolvePromptTerminalCursorPosition( + placement: CursorPlacement, + isActive: boolean, + layoutKey: string, + origin: PromptCursorOrigin | null +): { x: number; y: number } | undefined { + if (!isActive || origin?.layoutKey !== layoutKey) { + return undefined; + } + + return { + x: Math.max(0, Math.round(origin.left + placement.column)), + y: Math.max(0, Math.round(origin.top + placement.row)), + }; +} + +function getAbsoluteElementPosition(element: DOMElement | null): { left: number; top: number } | null { + let current: DOMElement | undefined = element ?? undefined; + let left = 0; + let top = 0; + + while (current) { + const layout = current.yogaNode?.getComputedLayout(); + if (!layout) { + return null; + } + left += layout.left; + top += layout.top; + current = current.parentNode; + } + + return { left, top }; +} + +export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(hideCursor()); + return () => { + stdout.write(showCursor()); + }; + }, [isActive, stdout]); +} + +export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableTerminalFocusReporting()); + return () => { + stdout.write(disableTerminalFocusReporting()); + }; + }, [isActive, stdout]); +} + +export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableTerminalExtendedKeys()); + return () => { + stdout.write(disableTerminalExtendedKeys()); + }; + }, [isActive, stdout]); +} + +export function useBracketedPaste(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableBracketedPaste()); + return () => { + stdout.write(disableBracketedPaste()); + }; + }, [isActive, stdout]); +} diff --git a/packages/cli/src/ui/hooks/index.ts b/packages/cli/src/ui/hooks/index.ts new file mode 100644 index 00000000..4e6bda52 --- /dev/null +++ b/packages/cli/src/ui/hooks/index.ts @@ -0,0 +1,21 @@ +export { useTerminalInput, parseTerminalInput, dispatchTerminalInput } from "./useTerminalInput"; +export type { InputKey } from "./useTerminalInput"; + +export { + useHiddenTerminalCursor, + useTerminalExtendedKeys, + useBracketedPaste, + usePromptTerminalCursor, + useTerminalFocusReporting, + getPromptCursorPlacement, + isPromptCursorAtWrapBoundary, + resolvePromptTerminalCursorPosition, +} from "./cursor"; + +export { usePasteHandling } from "./usePasteHandling"; +export type { PasteRegion, PasteHandlingState, PasteHandlingActions } from "./usePasteHandling"; + +export { useHistoryNavigation } from "./useHistoryNavigation"; +export type { HistoryNavigationState, HistoryNavigationActions } from "./useHistoryNavigation"; + +export { useStatusLine } from "./useStatusLine"; diff --git a/packages/cli/src/ui/hooks/useHistoryNavigation.ts b/packages/cli/src/ui/hooks/useHistoryNavigation.ts new file mode 100644 index 00000000..54ccabfd --- /dev/null +++ b/packages/cli/src/ui/hooks/useHistoryNavigation.ts @@ -0,0 +1,67 @@ +import type React from "react"; +import { useCallback, useState } from "react"; +import type { PromptBufferState } from "../core/prompt-buffer"; + +export type HistoryNavigationState = { + historyCursor: number; + draftBeforeHistory: string | null; +}; + +export type HistoryNavigationActions = { + /** + * Navigate through prompt history. Pass -1 for previous, 1 for next. + * Stores current draft before entering history mode and restores it when + * scrolling past the last entry. + */ + navigateHistory: (direction: -1 | 1) => void; + /** Exit history browsing mode, restoring the pre-history draft if any. */ + exitHistoryBrowsing: () => void; +}; + +export function useHistoryNavigation( + buffer: PromptBufferState, + setBuffer: React.Dispatch>, + promptHistory: string[] +): HistoryNavigationState & HistoryNavigationActions { + const [historyCursor, setHistoryCursor] = useState(-1); + const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); + + const exitHistoryBrowsing = useCallback((): void => { + setHistoryCursor(-1); + setDraftBeforeHistory(null); + }, []); + + function navigateHistory(direction: -1 | 1): void { + if (promptHistory.length === 0) { + return; + } + + const previousCursor = historyCursor === -1 ? promptHistory.length : historyCursor; + const nextCursor = Math.max(0, Math.min(promptHistory.length, previousCursor + direction)); + // Capture the current draft synchronously before `setDraftBeforeHistory`. + const draft = historyCursor === -1 ? buffer.text : draftBeforeHistory; + + if (historyCursor === -1) { + setDraftBeforeHistory(buffer.text); + } + + if (nextCursor === promptHistory.length) { + const text = draft ?? ""; + setBuffer({ text, cursor: text.length }); + setHistoryCursor(-1); + setDraftBeforeHistory(null); + return; + } + + const text = promptHistory[nextCursor] ?? ""; + setBuffer({ text, cursor: text.length }); + setHistoryCursor(nextCursor); + } + + return { + historyCursor, + draftBeforeHistory, + navigateHistory, + exitHistoryBrowsing, + }; +} diff --git a/packages/cli/src/ui/hooks/usePasteHandling.ts b/packages/cli/src/ui/hooks/usePasteHandling.ts new file mode 100644 index 00000000..beaf0859 --- /dev/null +++ b/packages/cli/src/ui/hooks/usePasteHandling.ts @@ -0,0 +1,157 @@ +import type React from "react"; +import { useEffect, useRef, useState } from "react"; +import type { PromptBufferState } from "../core/prompt-buffer"; +import { cleanPasteContent, findPasteMarkerContaining, hasActivePasteMarkers, insertText } from "../core/prompt-buffer"; + +export type PasteRegion = { + start: number; + end: number; + content: string; + marker: string; +}; + +export type PasteHandlingState = { + /** Ref holding all paste content keyed by paste ID. */ + pastesRef: React.RefObject>; + /** Ref holding expanded paste regions for Ctrl+O toggle. */ + expandedRegionsRef: React.RefObject>; + /** Counter for generating unique paste IDs. */ + pasteCounterRef: React.RefObject; + /** Whether any paste marker is currently collapsed. */ + hasCollapsedMarkers: boolean; + /** Whether any paste region has been expanded. */ + hasExpandedRegions: boolean; +}; + +export type PasteHandlingActions = { + /** + * Process pasted text. Short pastes (<1000 chars, ≤9 newlines) are inserted + * inline. Larger pastes receive a collapsible marker. + */ + handlePaste: (pastedText: string) => void; + /** Expand a collapsed paste marker at the cursor, or collapse an expanded region. */ + expandPasteMarkerAtCursor: () => void; + /** Reset all paste-related state. */ + resetPastes: () => void; +}; + +export function usePasteHandling( + buffer: PromptBufferState, + updateBuffer: (updater: (state: PromptBufferState) => PromptBufferState) => void, + setStatusMessage: (msg: string | null) => void +): PasteHandlingState & PasteHandlingActions { + const pastesRef = useRef>(new Map()); + const pasteCounterRef = useRef(0); + const expandedRegionsRef = useRef>(new Map()); + const [hasCollapsedMarkers, setHasCollapsedMarkers] = useState(false); + const [hasExpandedRegions, setHasExpandedRegions] = useState(false); + + function refreshDerivedFlags(): void { + setHasCollapsedMarkers(hasActivePasteMarkers(buffer.text, pastesRef.current)); + setHasExpandedRegions(expandedRegionsRef.current.size > 0); + } + + // Recompute derived flags whenever the buffer text changes, so they stay + // in sync after any state update (e.g. large paste, expand/collapse, undo). + useEffect(() => { + refreshDerivedFlags(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [buffer.text]); + + function handlePaste(pastedText: string): void { + const totalChars = pastedText.length; + + if (totalChars <= 1000) { + const newlineCount = (pastedText.match(/\n/g) ?? []).length; + if (newlineCount <= 9) { + const clean = pastedText + .replace(/\r\n|\r/g, "\n") + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .replace(/\t/g, " "); + updateBuffer((s) => insertText(s, clean)); + return; + } + } + + // Large paste: store raw text, insert marker. + const lineCount = (pastedText.match(/\n/g) ?? []).length + 1; + pasteCounterRef.current += 1; + const pasteId = pasteCounterRef.current; + pastesRef.current.set(pasteId, pastedText); + + const marker = + lineCount > 10 ? `[paste #${pasteId} +${lineCount} lines]` : `[paste #${pasteId} ${totalChars} chars]`; + + updateBuffer((s) => insertText(s, marker)); + refreshDerivedFlags(); + } + + function expandPasteMarkerAtCursor(): void { + // Collapse an already-expanded region at the cursor. + for (const [id, region] of expandedRegionsRef.current) { + if (buffer.cursor >= region.start && buffer.cursor <= region.end) { + expandedRegionsRef.current.delete(id); + pastesRef.current.set(id, region.content); + setTimeout(() => { + updateBuffer((s) => { + const text = s.text.slice(0, region.start) + region.marker + s.text.slice(region.end); + return { text, cursor: region.start + region.marker.length }; + }); + refreshDerivedFlags(); + }, 0); + refreshDerivedFlags(); + return; + } + } + + // Expand a paste marker. + const marker = findPasteMarkerContaining(buffer); + if (!marker) { + setStatusMessage("No paste marker at cursor"); + return; + } + const content = pastesRef.current.get(marker.id); + if (!content) { + setStatusMessage("Paste content not found"); + return; + } + + const pasteId = marker.id; + const originalMarker = buffer.text.slice(marker.start, marker.end); + pastesRef.current.delete(pasteId); + + setTimeout(() => { + updateBuffer((s) => { + const text = s.text.slice(0, marker.start) + cleanPasteContent(content) + s.text.slice(marker.end); + const newEnd = marker.start + content.length; + expandedRegionsRef.current.set(pasteId, { + start: marker.start, + end: newEnd, + content, + marker: originalMarker, + }); + return { text, cursor: marker.start }; + }); + refreshDerivedFlags(); + }, 0); + refreshDerivedFlags(); + } + + function resetPastes(): void { + pastesRef.current.clear(); + expandedRegionsRef.current.clear(); + pasteCounterRef.current = 0; + refreshDerivedFlags(); + } + + return { + pastesRef, + expandedRegionsRef, + pasteCounterRef, + hasCollapsedMarkers, + hasExpandedRegions, + handlePaste, + expandPasteMarkerAtCursor, + resetPastes, + }; +} diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts new file mode 100644 index 00000000..f84534ec --- /dev/null +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -0,0 +1,47 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { ResolvedStatusLineSettings } from "@vegamo/deepcode-core"; +import { StatusLineManager } from "../statusline"; +import type { SessionInfo, StatusSegment } from "../statusline"; + +/** + * Manages a StatusLineManager lifecycle and returns the current segments. + * Starts polling when the config is enabled, stops on unmount or config change. + */ +export function useStatusLine( + config: ResolvedStatusLineSettings, + projectRoot: string, + getSessionInfo?: () => SessionInfo | null +): StatusSegment[] { + const [segments, setSegments] = useState([]); + const managerRef = useRef(null); + const getSessionInfoRef = useRef(getSessionInfo); + getSessionInfoRef.current = getSessionInfo; + + const configKey = useMemo( + () => + JSON.stringify({ + enabled: config.enabled, + refreshMs: config.refreshMs, + separator: config.separator, + providers: config.providers, + }), + [config] + ); + + useEffect(() => { + const manager = new StatusLineManager(); + managerRef.current = manager; + + const unsub = manager.subscribe(setSegments); + void manager.start(config, projectRoot, () => (getSessionInfoRef.current ? getSessionInfoRef.current() : null)); + + return () => { + unsub(); + manager.stop(); + managerRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- config tracked via configKey + }, [configKey, projectRoot]); + + return segments; +} diff --git a/src/ui/prompt/useTerminalInput.ts b/packages/cli/src/ui/hooks/useTerminalInput.ts similarity index 55% rename from src/ui/prompt/useTerminalInput.ts rename to packages/cli/src/ui/hooks/useTerminalInput.ts index 8013ff60..9e985fa9 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/packages/cli/src/ui/hooks/useTerminalInput.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useLayoutEffect, useRef } from "react"; import { useStdin } from "ink"; export type InputKey = { @@ -20,6 +20,8 @@ export type InputKey = { meta: boolean; focusIn: boolean; focusOut: boolean; + /** True when the input came from a bracketed paste (ESC[200~ ... ESC[201~). */ + paste: boolean; }; const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); @@ -35,6 +37,13 @@ const META_RIGHT_SEQUENCES = new Set(["\u001B[1;3C", "\u001B[3C", "\u001Bf"]); const TERMINAL_FOCUS_IN = "\u001B[I"; const TERMINAL_FOCUS_OUT = "\u001B[O"; +// Bracketed paste mode markers (xterm-style). +// When the terminal supports bracketed paste, pasted text is wrapped with: +// ESC[200~ ...pasted content... ESC[201~ +const PASTE_START = "\u001B[200~"; +const PASTE_END = "\u001B[201~"; +const PASTE_END_LENGTH = 6; // length of PASTE_END + // Ctrl+- (minus) sequences in modifyOtherKeys mode. // \u001B[45;5u — standard format: keycode=45 ('-'), modifier=5 (Ctrl) // \u001B[27;5;45~ — extended format for function-like reporting @@ -73,6 +82,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: false, focusIn: false, focusOut: false, + paste: false, }; return { input, key }; } @@ -100,6 +110,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: false, focusIn: false, focusOut: false, + paste: false, }; return { input, key }; } @@ -123,6 +134,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: meta: META_LEFT_SEQUENCES.has(raw) || META_RIGHT_SEQUENCES.has(raw) || META_RETURN_SEQUENCES.has(raw), focusIn: raw === TERMINAL_FOCUS_IN, focusOut: raw === TERMINAL_FOCUS_OUT, + paste: false, }; if (input <= "\u001A" && !key.return) { @@ -169,6 +181,60 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: return { input, key }; } +export function dispatchTerminalInput( + data: Buffer | string, + inputHandler: (input: string, key: InputKey) => void +): void { + const raw = String(data); + + // Fix CJK composition bug on iOS terminals (Moshi, Blink, etc.). + // iOS keyboards can send composed characters as a single packet like: + // "가\x7f나" (character + backspace + replacement character) + // Do not split escape-prefixed sequences such as Alt+Backspace. + if (!raw.startsWith("\u001B") && raw.includes("\x7f") && raw.length > 1) { + const parts = raw.split("\x7f"); + if (parts[0]) { + const { input, key } = parseTerminalInput(parts[0]); + inputHandler(input, key); + } + for (let i = 1; i < parts.length; i++) { + const bs = parseTerminalInput("\x7f"); + inputHandler(bs.input, bs.key); + if (parts[i]) { + const { input, key } = parseTerminalInput(parts[i]); + inputHandler(input, key); + } + } + return; + } + + const { input, key } = parseTerminalInput(data); + inputHandler(input, key); +} + +/** An InputKey with all fields false (including paste). Used when dispatching paste events. */ +const EMPTY_KEY: InputKey = { + upArrow: false, + downArrow: false, + leftArrow: false, + rightArrow: false, + home: false, + end: false, + pageDown: false, + pageUp: false, + return: false, + escape: false, + ctrl: false, + shift: false, + tab: false, + backspace: false, + delete: false, + meta: false, + focusIn: false, + focusOut: false, + paste: false, +}; + export function useTerminalInput( inputHandler: (input: string, key: InputKey) => void, options: { isActive?: boolean } = {} @@ -178,8 +244,15 @@ export function useTerminalInput( const handlerRef = useRef(inputHandler); handlerRef.current = inputHandler; - useEffect(() => { + // Mutable paste-bracketing state shared across data events. + // Uses an array of chunks instead of string concatenation to avoid + // O(n²) copying when the terminal splits a large paste across many events. + const pasteRef = useRef({ active: false, chunks: [] as string[] }); + + useLayoutEffect(() => { if (!isActive) { + pasteRef.current.active = false; + pasteRef.current.chunks = []; return; } setRawMode(true); @@ -188,13 +261,80 @@ export function useTerminalInput( }; }, [isActive, setRawMode]); - useEffect(() => { + useLayoutEffect(() => { if (!isActive) { return; } + const handleData = (data: Buffer | string) => { - const { input, key } = parseTerminalInput(data); - handlerRef.current(input, key); + const raw = String(data); + + // ----- Bracketed paste handling ----- + // Most terminals send the start/end markers in the same chunk as + // the content. We handle both inline and multi-chunk scenarios. + + if (raw.includes(PASTE_START)) { + pasteRef.current.active = true; + pasteRef.current.chunks = []; + + // Extract content after the start marker. + const startIdx = raw.indexOf(PASTE_START); + const afterStart = raw.slice(startIdx + PASTE_START.length); + + // Check if the end marker is also in this same chunk. + const endIdx = afterStart.indexOf(PASTE_END); + if (endIdx !== -1) { + // Both markers in one chunk — process immediately. + const pasteContent = afterStart.slice(0, endIdx); + pasteRef.current.active = false; + const remaining = afterStart.slice(endIdx + PASTE_END_LENGTH); + + if (pasteContent.length > 0) { + handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); + } + if (remaining.length > 0) { + dispatchTerminalInput(remaining, handlerRef.current); + } + return; + } + + // Only start marker — buffer as first chunk. + if (afterStart) { + pasteRef.current.chunks.push(afterStart); + } + return; + } + + if (pasteRef.current.active) { + pasteRef.current.chunks.push(raw); + // Only join+search when this chunk might contain the end marker. + if (raw.includes("201~")) { + const combined = pasteRef.current.chunks.join(""); + const endIdx = combined.indexOf(PASTE_END); + if (endIdx !== -1) { + const pasteContent = combined.slice(0, endIdx); + pasteRef.current.active = false; + const remaining = combined.slice(endIdx + PASTE_END_LENGTH); + pasteRef.current.chunks = []; + + // Dispatch the pasted text as a single event. + if (pasteContent.length > 0) { + handlerRef.current(pasteContent, { ...EMPTY_KEY, paste: true }); + } + + // Handle any remaining input after the paste end marker. + if (remaining.length > 0) { + dispatchTerminalInput(remaining, handlerRef.current); + } + return; + } + return; + } + return; + } + + // ----- Normal (non-paste) input ----- + dispatchTerminalInput(data, handlerRef.current); }; stdin?.on("data", handleData); diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts new file mode 100644 index 00000000..65415464 --- /dev/null +++ b/packages/cli/src/ui/index.ts @@ -0,0 +1,93 @@ +import { + getThinkingOptionIndex, + MODEL_COMMAND_MODELS, + MODEL_COMMAND_THINKING_OPTIONS, +} from "./components/ModelsDropdown"; + +export { getThinkingOptionIndex, MODEL_COMMAND_MODELS, MODEL_COMMAND_THINKING_OPTIONS }; +export { buildPromptDraftFromSessionMessage } from "./utils"; +export { + disableTerminalExtendedKeys, + enableTerminalExtendedKeys, + getPromptCursorPlacement, + isPromptCursorAtWrapBoundary, + resolvePromptTerminalCursorPosition, +} from "./hooks/cursor"; +export { default as AppContainer } from "./views/AppContainer"; +export { AskUserQuestionPrompt } from "./views/AskUserQuestionPrompt"; +export { MessageView } from "./components"; +export { parseDiffPreview } from "./components/MessageView/utils"; +export { + PromptInput, + IMAGE_ATTACHMENT_CLEAR_HINT, + formatImageAttachmentStatus, + formatSelectedSkillsStatus, + addUniqueSkill, + toggleSkillSelection, + removeCurrentSlashToken, + isClearImageAttachmentsShortcut, + isRawModeShortcut, + getPromptReturnKeyAction, + renderBufferWithCursor, + buildInitPromptSubmission, + type PromptSubmission, + type PromptDraft, +} from "./views/PromptInput"; +export { SessionList, formatSessionTitle, filterSessions, formatSessionStatus } from "./views/SessionList"; +export { ThemedGradient } from "./views/ThemedGradient"; +export { UpdatePrompt, type UpdatePromptChoice } from "./views/UpdatePrompt"; +export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./views/WelcomeScreen"; +export { + findPendingAskUserQuestion, + formatAskUserQuestionAnswers, + formatAskUserQuestionDecline, + type AskUserQuestionOption, + type AskUserQuestionItem, + type PendingAskUserQuestion, + type AskUserQuestionAnswers, +} from "./core/ask-user-question"; +export { readClipboardImage, type ClipboardImage } from "./core/clipboard"; +export { buildLoadingText, type LoadingTextInput } from "./core/loading-text"; +export { renderMarkdown, renderMarkdownSegments, type MarkdownSegment } from "./components/MessageView/markdown"; +export { + EMPTY_BUFFER, + insertText, + backspace, + deleteForward, + moveLeft, + moveRight, + moveWordLeft, + moveWordRight, + moveUp, + moveDown, + moveLineStart, + moveLineEnd, + killLine, + deleteWordBefore, + deleteWordAfter, + reset, + isEmpty, + getCurrentSlashToken, + type PromptBufferState, +} from "./core/prompt-buffer"; +export { + BUILTIN_SLASH_COMMANDS, + buildSlashCommands, + filterSlashCommands, + findExactSlashCommand, + formatSlashCommandDescription, + formatSlashCommandLabel, + type SlashCommandKind, + type SlashCommandItem, +} from "./core/slash-commands"; +export { + filterFileMentionItems, + formatFileMentionPath, + getCurrentFileMentionToken, + replaceCurrentFileMentionToken, + scanFileMentionItems, + type FileMentionItem, + type FileMentionToken, +} from "./core/file-mentions"; +export { findExpandedThinkingId, isCollapsedThinking } from "./core/thinking-state"; +export { buildExitSummaryText, buildResumeHintText } from "./exit-summary"; diff --git a/packages/cli/src/ui/statusline/command-provider.ts b/packages/cli/src/ui/statusline/command-provider.ts new file mode 100644 index 00000000..c89545a4 --- /dev/null +++ b/packages/cli/src/ui/statusline/command-provider.ts @@ -0,0 +1,95 @@ +import { spawn } from "child_process"; +import * as path from "path"; +import type { StatusLineProviderConfig } from "@vegamo/deepcode-core"; +import type { StatusProvider, StatusProviderContext } from "./types"; + +const DEFAULT_TIMEOUT_MS = 1500; +const MIN_TIMEOUT_MS = 100; +const MAX_OUTPUT_BYTES = 4096; + +function resolveTimeout(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < MIN_TIMEOUT_MS) { + return DEFAULT_TIMEOUT_MS; + } + return Math.floor(value); +} + +function resolveCwd(configCwd: string | undefined, projectRoot: string): string { + if (!configCwd) { + return projectRoot; + } + return path.isAbsolute(configCwd) ? configCwd : path.resolve(projectRoot, configCwd); +} + +export function createCommandStatusProvider( + config: Extract, + projectRoot: string, + id: string +): StatusProvider { + const timeoutMs = resolveTimeout(config.timeoutMs); + const cwd = resolveCwd(config.cwd, projectRoot); + + return { + id, + color: config.color, + newLine: config.newLine, + maxLength: config.maxLength, + fetch: ({ signal }: StatusProviderContext) => + new Promise((resolve) => { + if (signal.aborted) { + resolve(""); + return; + } + const isWindows = process.platform === "win32"; + const child = spawn(config.command, { + cwd, + shell: isWindows ? true : "/bin/sh", + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stdoutBytes = 0; + let settled = false; + const finish = (value: string): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + if (!child.killed) { + child.kill(); + } + resolve(value); + }; + + const onAbort = (): void => finish(""); + signal.addEventListener("abort", onAbort, { once: true }); + + const timer = setTimeout(() => finish(""), timeoutMs); + + const cleanup = (): void => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + }; + + child.stdout?.on("data", (chunk: Buffer | string) => { + if (settled) { + return; + } + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + if (stdoutBytes >= MAX_OUTPUT_BYTES) { + return; + } + const remaining = MAX_OUTPUT_BYTES - stdoutBytes; + const slice = text.length > remaining ? text.slice(0, remaining) : text; + stdout += slice; + stdoutBytes += slice.length; + }); + // Drain stderr to avoid blocking, but ignore content. + child.stderr?.on("data", () => undefined); + child.on("error", () => finish("")); + child.on("close", () => finish(stdout)); + }), + }; +} diff --git a/packages/cli/src/ui/statusline/index.ts b/packages/cli/src/ui/statusline/index.ts new file mode 100644 index 00000000..1299c8ec --- /dev/null +++ b/packages/cli/src/ui/statusline/index.ts @@ -0,0 +1,4 @@ +export { StatusLineManager } from "./manager"; +export { sanitizeStatusText, STATUS_SEGMENT_MAX_LENGTH } from "./sanitize"; +export { validateModulePath } from "./module-provider"; +export type { StatusSegment, StatusProvider, StatusProviderContext, SessionInfo } from "./types"; diff --git a/packages/cli/src/ui/statusline/manager.ts b/packages/cli/src/ui/statusline/manager.ts new file mode 100644 index 00000000..9f4015ff --- /dev/null +++ b/packages/cli/src/ui/statusline/manager.ts @@ -0,0 +1,187 @@ +import type { ResolvedStatusLineSettings, StatusLineProviderConfig } from "@vegamo/deepcode-core"; +import { sanitizeStatusText } from "./sanitize"; +import { createCommandStatusProvider } from "./command-provider"; +import { loadModuleProvider, validateModulePath } from "./module-provider"; +import type { SessionInfo, StatusProvider, StatusSegment } from "./types"; + +type SegmentsListener = (segments: StatusSegment[]) => void; + +function segmentsEqual(a: StatusSegment[], b: StatusSegment[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if ( + a[i]?.id !== b[i]?.id || + a[i]?.text !== b[i]?.text || + a[i]?.color !== b[i]?.color || + a[i]?.newLine !== b[i]?.newLine + ) { + return false; + } + } + return true; +} + +export class StatusLineManager { + private providers: StatusProvider[] = []; + private ac: AbortController | null = null; + private timer: ReturnType | null = null; + private subscribers = new Set(); + private segments: StatusSegment[] = []; + private running = false; + private projectRoot = ""; + private getSessionInfo: (() => SessionInfo | null) | undefined; + + get currentSegments(): StatusSegment[] { + return this.segments; + } + + subscribe(fn: SegmentsListener): () => void { + this.subscribers.add(fn); + return () => { + this.subscribers.delete(fn); + }; + } + + private emit(segments: StatusSegment[]): void { + if (segmentsEqual(this.segments, segments)) { + return; + } + this.segments = segments; + for (const fn of this.subscribers) { + try { + fn(segments); + } catch { + // ignore subscriber errors + } + } + } + + async start( + config: ResolvedStatusLineSettings, + projectRoot: string, + getSessionInfo?: () => SessionInfo | null + ): Promise { + if (this.running) { + this.stop(); + } + if (!config.enabled || config.providers.length === 0) { + return; + } + + this.projectRoot = projectRoot; + this.getSessionInfo = getSessionInfo; + const { providers, refreshMs } = config; + this.ac = new AbortController(); + const { signal } = this.ac; + + // Build providers + const built: StatusProvider[] = []; + let nextId = 0; + for (const entry of providers) { + const providerId = entry.id || `${entry.type}-${nextId}`; + const provider = await this.buildProvider(entry, projectRoot, providerId); + if (provider) { + built.push(provider); + } + nextId += 1; + } + + if (built.length === 0) { + return; + } + + this.providers = built; + this.running = true; + + // Fetch immediately, then on interval. + void this.fetchAll(); + this.timer = setInterval(() => { + if (signal.aborted) { + return; + } + void this.fetchAll(); + }, refreshMs); + } + + stop(): void { + this.running = false; + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + if (this.ac) { + this.ac.abort(); + this.ac = null; + } + for (const provider of this.providers) { + provider.dispose?.(); + } + this.providers = []; + this.getSessionInfo = undefined; + } + + private async buildProvider( + config: StatusLineProviderConfig, + projectRoot: string, + providerId: string + ): Promise { + if (config.type === "command") { + return createCommandStatusProvider(config, projectRoot, providerId); + } + if (config.type === "module") { + const resolvedPath = validateModulePath(config.path, projectRoot); + if (!resolvedPath) { + return null; + } + const provider = await loadModuleProvider( + resolvedPath, + config.color, + providerId, + config.timeoutMs, + config.maxLength + ); + if (provider && config.newLine) { + provider.newLine = true; + } + return provider; + } + return null; + } + + private async fetchAll(): Promise { + if (!this.ac || this.ac.signal.aborted) { + return; + } + + const results = await Promise.all( + this.providers.map(async (provider) => { + try { + const text = await provider.fetch({ + projectRoot: this.projectRoot, + signal: this.ac!.signal, + getSessionInfo: this.getSessionInfo, + }); + const sanitized = sanitizeStatusText(text, provider.maxLength); + if (!sanitized) { + return null; + } + const segment: StatusSegment = { id: provider.id, text: sanitized }; + if (provider.color) { + segment.color = provider.color; + } + if (provider.newLine) { + segment.newLine = true; + } + return segment; + } catch { + return null; + } + }) + ); + + const segments = results.filter((s): s is StatusSegment => s !== null); + this.emit(segments); + } +} diff --git a/packages/cli/src/ui/statusline/module-provider.ts b/packages/cli/src/ui/statusline/module-provider.ts new file mode 100644 index 00000000..f45d6ab2 --- /dev/null +++ b/packages/cli/src/ui/statusline/module-provider.ts @@ -0,0 +1,98 @@ +import * as path from "path"; +import type { StatusProvider, StatusProviderContext } from "./types"; + +const DEFAULT_TIMEOUT_MS = 2000; + +/** + * Validate that the module path is within the allowed base directory. + * Only paths under or relative to the project root or home directory are allowed. + */ +export function validateModulePath(modulePath: string, projectRoot: string): string | null { + // Resolve relative to project root first. + const resolved = path.isAbsolute(modulePath) ? modulePath : path.resolve(projectRoot, modulePath); + const normalized = path.normalize(resolved); + + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const allowedBases = [projectRoot]; + if (homeDir) { + allowedBases.push(homeDir); + } + + for (const base of allowedBases) { + const normalizedBase = path.normalize(base); + // Check if the resolved path is under the allowed base. + if (normalized.startsWith(normalizedBase + path.sep) || normalized === normalizedBase) { + return normalized; + } + } + return null; +} + +export async function loadModuleProvider( + resolvedPath: string, + color: string | undefined, + id: string, + timeoutMs: number | undefined, + maxLength?: number +): Promise { + try { + const timeout = + typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs >= 100 + ? Math.floor(timeoutMs) + : DEFAULT_TIMEOUT_MS; + + let mod: unknown; + try { + mod = await import(resolvedPath); + } catch { + // Try with file:// protocol + const fileUrl = path.isAbsolute(resolvedPath) ? `file://${resolvedPath}` : resolvedPath; + mod = await import(fileUrl); + } + + const providerFn = (mod as Record).default ?? (mod as Record).provider; + if (typeof providerFn !== "function") { + return null; + } + + return { + id, + color, + maxLength, + fetch: async (ctx: StatusProviderContext): Promise => { + if (ctx.signal.aborted) { + return ""; + } + let timer: ReturnType | null = null; + let onAbort: (() => void) | null = null; + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), timeout); + onAbort = () => reject(new Error("aborted")); + ctx.signal.addEventListener("abort", onAbort, { once: true }); + }); + + try { + const result = await Promise.race([ + Promise.resolve().then(() => + providerFn({ + projectRoot: ctx.projectRoot, + session: ctx.getSessionInfo ? ctx.getSessionInfo() : null, + }) + ), + timeoutPromise, + ]); + return typeof result === "string" ? result : ""; + } finally { + if (timer) { + clearTimeout(timer); + } + if (onAbort) { + ctx.signal.removeEventListener("abort", onAbort); + } + } + }, + }; + } catch { + return null; + } +} diff --git a/packages/cli/src/ui/statusline/sanitize.ts b/packages/cli/src/ui/statusline/sanitize.ts new file mode 100644 index 00000000..3beb5f7b --- /dev/null +++ b/packages/cli/src/ui/statusline/sanitize.ts @@ -0,0 +1,21 @@ +export const STATUS_SEGMENT_MAX_LENGTH = 40; + +const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g; + +export function sanitizeStatusText(value: unknown, maxLength: number = STATUS_SEGMENT_MAX_LENGTH): string { + if (value === null || value === undefined) { + return ""; + } + const text = typeof value === "string" ? value : String(value); + if (!text) { + return ""; + } + // Take only first non-empty line, strip ANSI escapes, collapse whitespace. + const firstLine = text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? ""; + const stripped = firstLine.replace(ANSI_PATTERN, ""); + const collapsed = stripped.replace(/\s+/g, " ").trim(); + if (collapsed.length <= maxLength) { + return collapsed; + } + return collapsed.slice(0, Math.max(1, maxLength - 1)) + "…"; +} diff --git a/packages/cli/src/ui/statusline/types.ts b/packages/cli/src/ui/statusline/types.ts new file mode 100644 index 00000000..6f687e61 --- /dev/null +++ b/packages/cli/src/ui/statusline/types.ts @@ -0,0 +1,41 @@ +import type { StatusLineProviderConfig } from "@vegamo/deepcode-core"; + +export type StatusSegment = { + id: string; + text: string; + color?: string; + newLine?: boolean; +}; + +export type SessionInfo = { + activeSessionId: string | null; + messageCount: number; + requestCount: number; + totalTokens: number; + activeTokens: number; + maxContextTokens: number; + model: string; + thinkingEnabled: boolean; + reasoningEffort: string; + toolUsage: Record; +}; + +export type StatusProviderContext = { + projectRoot: string; + signal: AbortSignal; + getSessionInfo?: () => SessionInfo | null; +}; + +export type StatusProvider = { + id: string; + color?: string; + maxLength?: number; + newLine?: boolean; + fetch: (ctx: StatusProviderContext) => Promise; + dispose?: () => void; +}; + +export type StatusProviderFactory = ( + config: StatusLineProviderConfig, + projectRoot: string +) => Promise; diff --git a/packages/cli/src/ui/utils/index.ts b/packages/cli/src/ui/utils/index.ts new file mode 100644 index 00000000..6feb0306 --- /dev/null +++ b/packages/cli/src/ui/utils/index.ts @@ -0,0 +1,103 @@ +import chalk from "chalk"; +import { renderMessageToStdout } from "../components/MessageView/utils"; +import type { RawMode } from "../contexts"; +import type { PromptDraft } from "../views/PromptInput"; +import type { ModelConfigSelection } from "@vegamo/deepcode-core"; +import type { SessionEntry, SessionMessage } from "@vegamo/deepcode-core"; +import type { SessionManager } from "@vegamo/deepcode-core"; + +/** + * Render all messages directly to stdout for Raw mode display. + * Writes each message followed by the "Press ESC to exit raw mode" footer. + */ +export function renderRawModeMessages(allMessages: SessionMessage[], mode: string | RawMode): void { + for (const msg of allMessages) { + process.stdout.write("\n"); + process.stdout.write(renderMessageToStdout(msg, mode as RawMode) + "\n\n"); + } + if (allMessages.length > 0) { + process.stdout.write("\n\n"); + process.stdout.write(chalk.dim("Press ESC to exit raw mode")); + } else { + process.stdout.write("\n"); + process.stdout.write(chalk.dim("(No messages in this session yet. Start chatting to see them here.)")); + process.stdout.write("\n\n"); + process.stdout.write(chalk.dim("Press ESC to exit raw mode")); + } +} + +export function buildSyntheticUserMessage(content: string, imageCount: number): SessionMessage { + const now = new Date().toISOString(); + return { + id: `local-${Math.random().toString(36).slice(2)}`, + sessionId: "local", + role: "user", + content, + contentParams: + imageCount > 0 + ? Array.from({ length: imageCount }, () => ({ + type: "image_url", + image_url: { url: "" }, + })) + : null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + }; +} + +export function buildPromptDraftFromSessionMessage(message: SessionMessage, nonce: number): PromptDraft { + return { + nonce, + text: typeof message.content === "string" ? message.content : "", + imageUrls: extractImageUrlsFromContentParams(message.contentParams), + }; +} + +export function extractImageUrlsFromContentParams(contentParams: unknown): string[] { + const params = Array.isArray(contentParams) ? contentParams : contentParams ? [contentParams] : []; + const imageUrls: string[] = []; + for (const param of params) { + if (!param || typeof param !== "object") { + continue; + } + const record = param as { type?: unknown; image_url?: { url?: unknown } }; + const url = record.image_url?.url; + if (record.type === "image_url" && typeof url === "string" && url) { + imageUrls.push(url); + } + } + return imageUrls; +} + +export function isCurrentSessionEmpty(sessionManager: SessionManager): boolean { + const activeSessionId = sessionManager.getActiveSessionId(); + return !activeSessionId || !sessionManager.getSession(activeSessionId); +} + +export function buildStatusLine(entry: SessionEntry): string { + const parts: string[] = []; + parts.push(`status: ${entry.status}`); + if (typeof entry.activeTokens === "number" && entry.activeTokens > 0) { + parts.push(`tokens: ${entry.activeTokens}`); + } + if (entry.failReason) { + parts.push(`fail: ${entry.failReason}`); + } + return parts.join(" · "); +} + +export function formatThinkingMode( + settings: Pick +): string { + if (!settings.thinkingEnabled) { + return "no thinking"; + } + return `thinking ${settings.reasoningEffort}`; +} + +export function formatModelConfig(settings: ModelConfigSelection): string { + return `${settings.model}, ${formatThinkingMode(settings)}`; +} diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx new file mode 100644 index 00000000..3b2886cd --- /dev/null +++ b/packages/cli/src/ui/views/App.tsx @@ -0,0 +1,995 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; +import chalk from "chalk"; +import { createOpenAIClient } from "@vegamo/deepcode-core"; +import type { PermissionScope } from "@vegamo/deepcode-core"; +import { type ModelConfigSelection } from "@vegamo/deepcode-core"; +import { type PromptDraft, PromptInput, type PromptSubmission } from "./PromptInput"; +import { MessageView, RawModeExitPrompt } from "../components"; +import { SessionList } from "./SessionList"; +import { type UndoRestoreMode, UndoSelector } from "./UndoSelector"; +import { buildLoadingText } from "../core/loading-text"; +import { findExpandedThinkingId } from "../core/thinking-state"; +import { WelcomeScreen } from "./WelcomeScreen"; +import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; +import { McpStatusList } from "./McpStatusList"; +import { ProcessStdoutView } from "./ProcessStdoutView"; +import { + type AskUserQuestionAnswers, + findPendingAskUserQuestion, + formatAskUserQuestionAnswers, +} from "../core/ask-user-question"; +import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt"; +import { buildExitSummaryText, buildResumeHintText } from "../exit-summary"; +import { RawMode, useRawModeContext } from "../contexts"; +import { renderMessageToStdout } from "../components/MessageView/utils"; +import { + buildPromptDraftFromSessionMessage, + buildStatusLine, + buildSyntheticUserMessage, + formatModelConfig, + isCurrentSessionEmpty, + renderRawModeMessages, +} from "../utils"; +import { resolveCurrentSettings, writeModelConfigSelection } from "@vegamo/deepcode-core"; +import { useStatusLine } from "../hooks"; +import type { SessionInfo } from "../statusline"; +import { isCollapsedThinking } from "../core/thinking-state"; +import { ANSI_CLEAR_SCREEN } from "../constants"; +import type { + LlmStreamProgress, + MessageMeta, + SessionEntry, + SessionMessage, + SessionStatus, + SkillInfo, + UndoTarget, + UserPromptContent, +} from "@vegamo/deepcode-core"; +import { SessionManager } from "@vegamo/deepcode-core"; +import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; +import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers"; + +type View = "chat" | "session-list" | "undo" | "mcp-status"; + +const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +type AppProps = { + projectRoot: string; + initialPrompt?: string; + resumeSessionId?: string | true; + onRestart?: () => void; +}; + +const StatusLine = React.memo(function StatusLine({ + busy, + text, +}: { + busy: boolean; + text?: string; +}): React.ReactElement { + const [spinnerIndex, setSpinnerIndex] = useState(0); + + useEffect(() => { + if (!busy) { + setSpinnerIndex(0); + return; + } + + const timer = setInterval(() => { + setSpinnerIndex((index) => (index + 1) % STATUS_SPINNER_FRAMES.length); + }, 80); + return () => clearInterval(timer); + }, [busy]); + + return ( + + {busy ? ( + + {STATUS_SPINNER_FRAMES[spinnerIndex]} + + ) : null} + {text ? {text} : null} + + ); +}); + +function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProps): React.ReactElement { + const { exit } = useApp(); + const { stdout, write } = useStdout(); + const { columns, rows } = useWindowSize(); + const { mode, setMode } = useRawModeContext(); + const initialPromptSubmittedRef = useRef(false); + const resumeSessionIdRef = useRef(false); + const startupDoneRef = useRef(false); + const processStdoutRef = useRef>(new Map()); + const rawModeRef = useRef(mode); + const writeRef = useRef(write); + const lastRenderedColumnsRef = useRef(null); + const messagesRef = useRef([]); + const [view, setView] = useState("chat"); + const [busy, setBusy] = useState(false); + const [skills, setSkills] = useState([]); + const [messages, setMessages] = useState([]); + const [sessions, setSessions] = useState([]); + const [undoTargets, setUndoTargets] = useState([]); + const [promptDraft, setPromptDraft] = useState(null); + const [statusLine, setStatusLine] = useState(""); + const [errorLine, setErrorLine] = useState(null); + const [streamProgress, setStreamProgress] = useState(null); + const [runningProcesses, setRunningProcesses] = useState(null); + const [activeStatus, setActiveStatus] = useState(null); + const [activeAskPermissions, setActiveAskPermissions] = useState(undefined); + const [pendingPermissionReply, setPendingPermissionReply] = useState<{ + sessionId: string; + permissions: PermissionPromptResult["permissions"]; + alwaysAllows: PermissionScope[]; + } | null>(null); + const [dismissedQuestionIds, setDismissedQuestionIds] = useState>(() => new Set()); + const [isExiting, setIsExiting] = useState(false); + const [showWelcome, setShowWelcome] = useState(true); + const [welcomeNonce, setWelcomeNonce] = useState(0); + const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); + const [nowTick, setNowTick] = useState(0); + const [mcpStatuses, setMcpStatuses] = useState>([]); + const [showProcessStdout, setShowProcessStdout] = useState(false); + + rawModeRef.current = mode; + messagesRef.current = messages; + + const sessionManager = useMemo(() => { + return new SessionManager({ + projectRoot, + createOpenAIClient: () => createOpenAIClient(projectRoot), + getResolvedSettings: () => resolveCurrentSettings(projectRoot), + renderMarkdown: (text) => text, + onAssistantMessage: (message: SessionMessage) => { + setMessages((prev) => [...prev, message]); + if (rawModeRef.current === RawMode.Raw) { + writeStdoutLine("\n"); + writeStdoutLine(renderMessageToStdout(message, rawModeRef.current) + "\n\n"); + } + }, + onSessionEntryUpdated: (entry) => { + setStatusLine(buildStatusLine(entry)); + setRunningProcesses(entry.processes); + setActiveStatus(entry.status); + setActiveAskPermissions(entry.askPermissions); + }, + onLlmStreamProgress: (progress) => { + if (progress.phase === "end") { + setStreamProgress(null); + return; + } + setStreamProgress(progress); + }, + onMcpStatusChanged: () => { + // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 + setMcpStatuses(sessionManager.getMcpStatus()); + }, + onProcessStdout: (pid, chunk) => { + const buf = processStdoutRef.current; + const current = buf.get(pid) ?? ""; + // Cap at 1 MB per process to avoid unbounded memory growth + // on noisy or long-running commands like `yes` or verbose builds. + const MAX_STDOUT_BUFFER = 1_000_000; + if (current.length >= MAX_STDOUT_BUFFER) { + return; + } + const text = typeof chunk === "string" ? chunk : String(chunk); + const available = MAX_STDOUT_BUFFER - current.length; + buf.set(pid, current + text.slice(0, available)); + }, + }); + }, [projectRoot]); + + /** + * Navigate to a sub-view. + */ + const navigateToSubView = useCallback((targetView: View) => { + setShowWelcome(false); + setView(targetView); + }, []); + + /** + * Reset the static view to the welcome screen. + */ + const resetStaticView = useCallback( + (loadedMessages: SessionMessage[], options?: { clearScreen?: boolean }): Promise => { + if (options?.clearScreen) { + writeStdout(ANSI_CLEAR_SCREEN); + } + setMessages([]); + setWelcomeNonce((n) => n + 1); + navigateToSubView("chat"); + return new Promise((resolve) => { + setTimeout(() => { + setMessages(loadedMessages); + setShowWelcome(true); + resolve(); + }, 0); + }); + }, + [navigateToSubView] + ); + + useEffect(() => { + if (!busy) { + return; + } + const id = setInterval(() => setNowTick((tick) => tick + 1), 500); + return () => clearInterval(id); + }, [busy]); + + function loadVisibleMessages(manager: SessionManager, sessionId: string): SessionMessage[] { + return manager.listSessionMessages(sessionId).filter((m) => m.visible); + } + + const refreshSessionsList = useCallback((): void => { + setSessions(sessionManager.listSessions()); + }, [sessionManager]); + + const refreshSkills = useCallback( + async (sessionId?: string): Promise => { + try { + const list = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); + setSkills(list); + } catch { + // ignore + } + }, + [sessionManager] + ); + + /** + * Reset the app to the welcome screen. + */ + const resetToWelcome = useCallback(async () => { + writeRef.current(ANSI_CLEAR_SCREEN); + sessionManager.setActiveSessionId(null); + setStatusLine(""); + setErrorLine(null); + setRunningProcesses(null); + setActiveStatus(null); + setActiveAskPermissions(undefined); + setPendingPermissionReply(null); + setDismissedQuestionIds(new Set()); + await resetStaticView([]); + await refreshSkills(); + }, [sessionManager, resetStaticView, refreshSkills]); + + /** + * Refresh the list of sessions. + */ + useEffect(() => { + refreshSessionsList(); + void refreshSkills(); + }, [refreshSessionsList, refreshSkills]); + + // Eagerly create the OpenAI client on mount so the TCP+TLS connection + // warmup (fire-and-forget inside createOpenAIClient) starts before the + // user sends their first prompt. + useEffect(() => { + createOpenAIClient(projectRoot); + }, [projectRoot]); + + /** + * Initialize MCP servers. + */ + useLayoutEffect(() => { + const settings = resolveCurrentSettings(projectRoot); + void sessionManager.initMcpServers(settings.mcpServers); + }, [projectRoot, sessionManager]); + + /** + * Dispose the session manager on unmount. + */ + useEffect(() => { + return () => { + sessionManager.dispose(); + }; + }, [sessionManager]); + + writeRef.current = write; + const handleExit = useCallback( + ({ showCommand, showSummary }: { showCommand: boolean; showSummary: boolean }) => { + setIsExiting(true); + setTimeout(() => { + const activeSessionId = sessionManager.getActiveSessionId(); + const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; + const resumeHint = buildResumeHintText(activeSessionId ?? undefined); + + writeStdoutLine("\n"); + if (showCommand) { + writeStdoutLine(chalk.rgb(34, 154, 195)(" > /exit ")); + writeStdoutLine("\n"); + } + if (showSummary) { + const summary = buildExitSummaryText({ session, sessionId: activeSessionId ?? undefined }); + writeStdoutLine(summary); + writeStdoutLine("\n"); + } + if (resumeHint) { + writeStdoutLine(resumeHint); + writeStdoutLine("\n"); + } + + sessionManager.dispose(); + exit(); + }, 0); + }, + [exit, sessionManager] + ); + + const handlePrompt = useCallback( + async (submission: PromptSubmission) => { + if (submission.command === "exit") { + handleExit({ showCommand: true, showSummary: true }); + return; + } + if (submission.command === "new") { + if (onRestart) { + onRestart(); + } else { + await resetToWelcome(); + refreshSessionsList(); + } + return; + } + if (submission.command === "resume") { + refreshSessionsList(); + navigateToSubView("session-list"); + return; + } + if (submission.command === "continue" && isCurrentSessionEmpty(sessionManager)) { + refreshSessionsList(); + navigateToSubView("session-list"); + return; + } + if (submission.command === "undo") { + const activeSessionId = sessionManager.getActiveSessionId(); + if (!activeSessionId) { + setErrorLine("No active session to undo."); + return; + } + setUndoTargets(sessionManager.listUndoTargets(activeSessionId)); + navigateToSubView("undo"); + return; + } + if (submission.command === "mcp") { + setMcpStatuses(sessionManager.getMcpStatus()); + navigateToSubView("mcp-status"); + return; + } + + const prompt: UserPromptContent = { + text: submission.text, + imageUrls: submission.imageUrls, + skills: + submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, + permissions: submission.permissions, + alwaysAllows: submission.alwaysAllows, + }; + const activeSessionId = sessionManager.getActiveSessionId(); + const permissionReply = + pendingPermissionReply && activeSessionId === pendingPermissionReply.sessionId ? pendingPermissionReply : null; + if (permissionReply) { + prompt.permissions = permissionReply.permissions; + prompt.alwaysAllows = permissionReply.alwaysAllows; + } + + const trimmedText = (submission.text ?? "").trim(); + const selectedSkillNames = submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; + const userDisplayContent = + trimmedText || + (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") || + (submission.imageUrls.length > 0 ? "[Image]" : ""); + + if (userDisplayContent && submission.command !== "continue") { + setMessages((prev) => [...prev, buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length)]); + } + + setBusy(true); + setErrorLine(null); + const activeProcesses = activeSessionId ? (sessionManager.getSession(activeSessionId)?.processes ?? null) : null; + setRunningProcesses(activeProcesses); + setShowProcessStdout(false); + if (!activeProcesses || activeProcesses.size === 0) { + processStdoutRef.current.clear(); + } + try { + await sessionManager.handleUserPrompt(prompt); + if (permissionReply) { + setPendingPermissionReply(null); + } + await refreshSkills(); + refreshSessionsList(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setErrorLine(message); + } finally { + setBusy(false); + setStreamProgress(null); + const finalActiveSessionId = sessionManager.getActiveSessionId(); + setRunningProcesses( + finalActiveSessionId ? (sessionManager.getSession(finalActiveSessionId)?.processes ?? null) : null + ); + } + }, + [ + sessionManager, + pendingPermissionReply, + handleExit, + onRestart, + refreshSkills, + refreshSessionsList, + navigateToSubView, + resetToWelcome, + ] + ); + + const handleInterrupt = useCallback(() => { + sessionManager.interruptActiveSession(); + }, [sessionManager]); + + const handleToggleProcessStdout = useCallback(() => { + setShowProcessStdout(true); + }, []); + + const handleDismissProcessStdout = useCallback(() => { + setShowProcessStdout(false); + }, []); + + const handleAdjustBashTimeout = useCallback( + (deltaMs: number) => sessionManager.adjustActiveBashTimeout(deltaMs), + [sessionManager] + ); + + const handleModelConfigChange = useCallback( + (selection: ModelConfigSelection): string => { + const current = resolveCurrentSettings(projectRoot); + const { changed } = writeModelConfigSelection(selection, current, projectRoot); + const next = resolveCurrentSettings(projectRoot); + setResolvedSettings(next); + + if (!changed) { + return "Model settings unchanged"; + } + + const activeSessionId = sessionManager.getActiveSessionId(); + const meta: MessageMeta = { + isModelChange: true, + }; + const content = `/model\n└ Set model to ${selection.model} (${selection?.thinkingEnabled ? selection?.reasoningEffort : "no thinking"})`; + + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, content, true, meta); + } else { + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + sessionId: "local", + role: "system" as const, + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta, + }, + ]); + } + + return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; + }, + [projectRoot, sessionManager] + ); + + const handleSubmit = useCallback( + (submission: PromptSubmission) => { + void handlePrompt(submission); + }, + [handlePrompt] + ); + + const handleExitShortcut = useCallback(() => { + handleExit({ showCommand: false, showSummary: false }); + }, [handleExit]); + + const reloadActiveSessionView = useCallback( + (sessionId: string): void => { + resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + }, + [resetStaticView, sessionManager] + ); + + const handleSelectSession = useCallback( + async (sessionId: string) => { + sessionManager.setActiveSessionId(sessionId); + // Clear first so resets its index to 0. + await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); + const session = sessionManager.getSession(sessionId); + setStatusLine(session ? buildStatusLine(session) : ""); + setRunningProcesses(session?.processes ?? null); + setActiveStatus(session?.status ?? null); + setActiveAskPermissions(session?.askPermissions); + if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) { + setPendingPermissionReply(null); + } + await refreshSkills(sessionId); + }, + [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills] + ); + + /** + * Coordinated startup effect: handle --resume and --prompt together. + * When both are present, resume the session first, then submit the prompt. + */ + useEffect(() => { + if (startupDoneRef.current) { + return; + } + startupDoneRef.current = true; + + async function run() { + // Step 1: Resume session if requested + if (resumeSessionId) { + resumeSessionIdRef.current = true; + if (resumeSessionId === true) { + // Bare --resume — show session picker; prompt makes no sense here + refreshSessionsList(); + navigateToSubView("session-list"); + return; + } + await handleSelectSession(resumeSessionId); + } + + // Step 2: Submit prompt if provided + if (initialPrompt && initialPrompt.trim()) { + initialPromptSubmittedRef.current = true; + handleSubmit({ + text: initialPrompt, + imageUrls: [], + selectedSkills: undefined, + }); + } + } + + void run(); + }, [handleSubmit, handleSelectSession, initialPrompt, navigateToSubView, refreshSessionsList, resumeSessionId]); + + const handleDeleteSession = useCallback( + async (id: string): Promise => { + const isActiveSession = sessionManager.getActiveSessionId() === id; + + // If the deleted session is the active one, clear the active session first + if (isActiveSession) { + sessionManager.setActiveSessionId(null); + } + + sessionManager.deleteSession(id); + refreshSessionsList(); + + if (isActiveSession) { + await resetToWelcome(); + } + }, + [sessionManager, refreshSessionsList, resetToWelcome] + ); + + const handleUndoRestore = useCallback( + async (target: UndoTarget, restoreMode: UndoRestoreMode): Promise => { + const sessionId = sessionManager.getActiveSessionId(); + if (!sessionId) { + setErrorLine("No active session to undo."); + setView("chat"); + setShowWelcome(true); + return; + } + + const errors: string[] = []; + if (restoreMode === "code-and-conversation") { + try { + sessionManager.restoreSessionCode(sessionId, target.message.id); + } catch (error) { + errors.push(`Code restore failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + let conversationRestored = false; + try { + sessionManager.restoreSessionConversation(sessionId, target.message.id); + conversationRestored = true; + } catch (error) { + errors.push(`Conversation restore failed: ${error instanceof Error ? error.message : String(error)}`); + } + + refreshSessionsList(); + await refreshSkills(sessionId); + setView("chat"); + setErrorLine(errors.length > 0 ? errors.join(" ") : null); + if (conversationRestored) { + setPromptDraft(buildPromptDraftFromSessionMessage(target.message, Date.now())); + } + reloadActiveSessionView(sessionId); + }, + [reloadActiveSessionView, refreshSessionsList, refreshSkills, sessionManager] + ); + + const handleRawModeChange = useCallback( + (nextMode: string) => { + const activeSessionId = sessionManager.getActiveSessionId(); + setMode(nextMode as RawMode); + // Reset chat view state synchronously so the transition frame does not + // re-render a stale welcome screen before handleSelectSession runs. + setShowWelcome(false); + setMessages([]); + // Clear screen to remove stale formatted text. + writeStdout(ANSI_CLEAR_SCREEN); + + setTimeout(() => { + if (nextMode === RawMode.Raw) { + // Write all messages directly to stdout for raw scrollback mode. + const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; + renderRawModeMessages(allMessages, nextMode); + } else if (activeSessionId) { + // Switch to chat view to render messages. + handleSelectSession(activeSessionId); + } else { + // No active session: just show the welcome screen once. + setWelcomeNonce((n) => n + 1); + setShowWelcome(true); + } + }, 200); + }, + [handleSelectSession, sessionManager, setMode] + ); + + useEffect(() => { + if (!stdout?.isTTY) { + return; + } + if (columns <= 0) { + return; + } + if (lastRenderedColumnsRef.current === null) { + lastRenderedColumnsRef.current = columns; + return; + } + if (lastRenderedColumnsRef.current === columns) { + return; + } + lastRenderedColumnsRef.current = columns; + + if (mode === RawMode.Raw) { + // In raw mode, re-render all messages directly to stdout at the new width. + // Use direct stdout instead of writeRef to avoid Ink interference. + writeStdout(ANSI_CLEAR_SCREEN); + const activeSessionId = sessionManager.getActiveSessionId(); + const allMessages = activeSessionId ? loadVisibleMessages(sessionManager, activeSessionId) : []; + renderRawModeMessages(allMessages, mode); + return; + } + + // Force full redraw on terminal resize to avoid stale wrapped rows. + writeRef.current("\u001B[2J\u001B[H"); + + setMessages([]); + setShowWelcome(false); + setWelcomeNonce((n) => n + 1); + + const activeSessionId = sessionManager.getActiveSessionId(); + const nextMessages = + activeSessionId && !busy ? loadVisibleMessages(sessionManager, activeSessionId) : messagesRef.current; + setTimeout(() => { + setMessages(nextMessages); + setShowWelcome(true); + }, 0); + }, [busy, mode, sessionManager, columns, stdout]); + + const screenWidth = useMemo(() => columns ?? stdout?.columns ?? 80, [columns, stdout]); + const screenHeight = useMemo(() => rows ?? stdout?.rows ?? 24, [rows, stdout]); + const getSessionInfo = useCallback((): SessionInfo | null => { + const activeSessionId = sessionManager.getActiveSessionId(); + const settings = resolveCurrentSettings(projectRoot); + const model = settings.model || ""; + const thinkingEnabled = settings.thinkingEnabled; + const reasoningEffort = settings.reasoningEffort; + const maxContextTokens = getCompactPromptTokenThreshold(model); + if (!activeSessionId) { + return { + activeSessionId: null, + messageCount: 0, + requestCount: 0, + totalTokens: 0, + activeTokens: 0, + maxContextTokens, + model, + thinkingEnabled, + reasoningEffort, + toolUsage: {}, + }; + } + const session = sessionManager.getSession(activeSessionId); + const messages = sessionManager.listSessionMessages(activeSessionId); + const usage = session?.usage; + const totalTokens = + usage && typeof (usage as { total_tokens?: unknown }).total_tokens === "number" + ? ((usage as { total_tokens: number }).total_tokens ?? 0) + : 0; + const requestCount = + usage && typeof (usage as { total_reqs?: unknown }).total_reqs === "number" + ? ((usage as { total_reqs: number }).total_reqs ?? 0) + : 0; + const toolUsage: Record = {}; + for (const msg of messages) { + if (msg.role === "tool" && msg.meta?.function) { + const fn = msg.meta.function as { name?: string }; + if (fn.name) { + toolUsage[fn.name] = (toolUsage[fn.name] || 0) + 1; + } + } + } + return { + activeSessionId, + messageCount: messages.length, + requestCount, + totalTokens, + activeTokens: session?.activeTokens ?? 0, + maxContextTokens, + model, + thinkingEnabled, + reasoningEffort, + toolUsage, + }; + }, [sessionManager, projectRoot]); + const statusLineSegments = useStatusLine(resolvedSettings.statusline, projectRoot, getSessionInfo); + const promptHistory = useMemo(() => { + return messages + .filter((message) => message.role === "user" && typeof message.content === "string") + .map((message) => (message.content ?? "").trim()) + .filter((content) => content.length > 0); + }, [messages]); + const expandedThinkingId = findExpandedThinkingId(messages); + const pendingQuestion = useMemo(() => findPendingAskUserQuestion(messages, activeStatus), [activeStatus, messages]); + const shouldShowQuestionPrompt = Boolean(pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId)); + const loadingText = useMemo( + () => (busy ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation + [busy, streamProgress, runningProcesses, nowTick] + ); + + const welcomeItem: SessionMessage = useMemo( + () => ({ + id: `__welcome__${welcomeNonce}`, + sessionId: "", + role: "system", + content: "", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: "", + updateTime: "", + }), + [welcomeNonce] + ); + const staticItems = useMemo(() => { + if (mode === RawMode.Raw) { + return []; + } + if (showWelcome && view === "chat") { + return [welcomeItem, ...messages]; + } + return messages; + }, [mode, showWelcome, view, messages, welcomeItem]); + const promptCursorLayoutKey = useMemo(() => { + const lastStaticItem = staticItems.at(-1); + return [ + view, + busy ? "busy" : "idle", + statusLine, + errorLine ?? "", + showProcessStdout ? "stdout" : "main", + activeStatus ?? "", + staticItems.length, + lastStaticItem?.id ?? "", + lastStaticItem?.updateTime ?? "", + shouldShowQuestionPrompt ? (pendingQuestion?.messageId ?? "") : "", + activeAskPermissions?.length ?? 0, + pendingPermissionReply ? "pending-permission-reply" : "no-pending-permission-reply", + ].join("\u001E"); + }, [ + activeAskPermissions, + activeStatus, + busy, + errorLine, + pendingPermissionReply, + pendingQuestion, + shouldShowQuestionPrompt, + showProcessStdout, + staticItems, + statusLine, + view, + ]); + + const handleQuestionAnswers = useCallback( + (answers: AskUserQuestionAnswers) => { + void handlePrompt({ + text: formatAskUserQuestionAnswers(answers), + imageUrls: [], + }); + }, + [handlePrompt] + ); + + const handleQuestionCancel = useCallback(() => { + if (!pendingQuestion) { + return; + } + setDismissedQuestionIds((prev) => new Set(prev).add(pendingQuestion.messageId)); + }, [pendingQuestion]); + + const handlePermissionResult = useCallback( + (result: PermissionPromptResult) => { + const sessionId = sessionManager.getActiveSessionId(); + if (!sessionId) { + return; + } + setPromptDraft(null); + if (result.hasDeny) { + setPendingPermissionReply({ + sessionId, + permissions: result.permissions, + alwaysAllows: result.alwaysAllows, + }); + setStatusLine("Permission denied. Add a reply, then press Enter to continue."); + sessionManager.denySessionPermission(sessionId); + return; + } + void handlePrompt({ + text: "/continue", + imageUrls: [], + command: "continue", + permissions: result.permissions, + alwaysAllows: result.alwaysAllows, + }); + }, + [handlePrompt, sessionManager] + ); + + const handlePermissionCancel = useCallback(() => { + sessionManager.interruptActiveSession(); + setActiveStatus("interrupted"); + setActiveAskPermissions(undefined); + setPromptDraft(null); + refreshSessionsList(); + }, [refreshSessionsList, sessionManager]); + + if (mode === RawMode.Raw) { + return handleRawModeChange(prev)} />; + } + + return ( + + + {(item) => { + if (item.id.startsWith("__welcome__")) { + return ( + + ); + } + return ( + + ); + }} + + {(busy || statusLine) && !isExiting ? : null} + {errorLine ? ( + + Error: {errorLine} + + ) : null} + {showProcessStdout ? ( + + ) : view === "session-list" ? ( + void handleSelectSession(id)} + onCancel={() => setView("chat")} + onDelete={(id) => { + void handleDeleteSession(id); + }} + onRename={(id, newName) => { + if (sessionManager.renameSession(id, newName)) { + refreshSessionsList(); + setStatusLine(`Session renamed to "${newName}".`); + } else { + setErrorLine("Failed to rename session."); + } + }} + /> + ) : view === "undo" ? ( + void handleUndoRestore(target, restoreMode)} + onCancel={() => { + setPromptDraft(null); + setView("chat"); + }} + /> + ) : view === "mcp-status" ? ( + setView("chat")} + onReconnect={(name) => { + const latest = resolveCurrentSettings(projectRoot); + void sessionManager.reconnectMcpServer(name, latest.mcpServers?.[name]); + }} + /> + ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( + + ) : activeStatus === "ask_permission" && + activeAskPermissions && + activeAskPermissions.length > 0 && + !pendingPermissionReply && + !busy ? ( + + ) : isExiting ? null : ( + + )} + + ); +} + +export default App; diff --git a/packages/cli/src/ui/views/AppContainer.tsx b/packages/cli/src/ui/views/AppContainer.tsx new file mode 100644 index 00000000..555588f1 --- /dev/null +++ b/packages/cli/src/ui/views/AppContainer.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { AppContext } from "../contexts"; +import App from "./App"; +import { RawModeProvider } from "../contexts"; + +const AppContainer: React.FC<{ + projectRoot: string; + version: string; + initialPrompt: string | undefined; + resumeSessionId: string | true | undefined; + onRestart: () => void; +}> = ({ version, projectRoot, initialPrompt, resumeSessionId, onRestart }) => { + return ( + + + + + + ); +}; + +export default AppContainer; diff --git a/src/ui/AskUserQuestionPrompt.tsx b/packages/cli/src/ui/views/AskUserQuestionPrompt.tsx similarity index 96% rename from src/ui/AskUserQuestionPrompt.tsx rename to packages/cli/src/ui/views/AskUserQuestionPrompt.tsx index 7c76ae38..ccce5f7e 100644 --- a/src/ui/AskUserQuestionPrompt.tsx +++ b/packages/cli/src/ui/views/AskUserQuestionPrompt.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Box, Text } from "ink"; -import type { AskUserQuestionAnswers, AskUserQuestionItem } from "./askUserQuestion"; -import { useTerminalInput } from "./PromptInput"; +import type { AskUserQuestionAnswers, AskUserQuestionItem } from "../core/ask-user-question"; +import { useTerminalInput } from "../hooks"; type Props = { questions: AskUserQuestionItem[]; @@ -206,7 +206,11 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): )} ) : null} - {option.description ? {option.description} : null} + {option.description ? ( + + {option.description} + + ) : null} ); })} diff --git a/src/ui/McpStatusList.tsx b/packages/cli/src/ui/views/McpStatusList.tsx similarity index 76% rename from src/ui/McpStatusList.tsx rename to packages/cli/src/ui/views/McpStatusList.tsx index a09039db..5a68832b 100644 --- a/src/ui/McpStatusList.tsx +++ b/packages/cli/src/ui/views/McpStatusList.tsx @@ -1,13 +1,14 @@ import React, { useState, useMemo, useCallback } from "react"; import { Box, Text, useInput, useWindowSize } from "ink"; -import type { McpServerStatus } from "../mcp/mcp-manager"; +import type { McpServerStatus } from "@vegamo/deepcode-core"; type Props = { statuses: McpServerStatus[]; onCancel: () => void; + onReconnect: (name: string) => void; }; -export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement { +export function McpStatusList({ statuses, onCancel, onReconnect }: Props): React.ReactElement { const { columns, rows } = useWindowSize(); // 视图模式:server-list(服务器列表) 或 server-detail(服务器详情) @@ -20,10 +21,10 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement setViewMode("server-list"); }, []); - // 进入服务器详情 + // 进入服务器详情(允许 ready、failed、reconnecting 状态) const enterDetail = useCallback(() => { const server = statuses[selectedServerIndex]; - if (server && server.status === "ready") { + if (server && (server.status === "ready" || server.status === "failed" || server.status === "reconnecting")) { setViewMode("server-detail"); } }, [statuses, selectedServerIndex]); @@ -59,6 +60,7 @@ export function McpStatusList({ statuses, onCancel }: Props): React.ReactElement server={statuses[selectedServerIndex]} onBack={goBack} onCancel={onCancel} + onReconnect={onReconnect} rows={rows} columns={columns} /> @@ -173,6 +175,7 @@ function ServerListView({ const readyCount = statuses.filter((s) => s.status === "ready").length; const startingCount = statuses.filter((s) => s.status === "starting").length; + const reconnectingCount = statuses.filter((s) => s.status === "reconnecting").length; const failedCount = statuses.filter((s) => s.status === "failed").length; return ( @@ -192,15 +195,10 @@ function ServerListView({ ( - - {readyCount} ready, - - - {startingCount} starting, - - - {failedCount} failed - + {readyCount} ready, + {startingCount} starting, + {reconnectingCount > 0 && {reconnectingCount} reconnecting,} + {failedCount} failed ) @@ -257,15 +255,23 @@ function ServerRow({ selected: boolean; labelColumnWidth: number; }): React.ReactElement { - const icon = status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : "●"; - const color = status.status === "ready" ? "green" : status.status === "failed" ? "red" : "yellow"; + const icon = + status.status === "ready" ? "✓" : status.status === "failed" ? "✗" : status.status === "reconnecting" ? "↻" : "●"; + const color = + status.status === "ready" + ? "green" + : status.status === "failed" + ? "red" + : status.status === "reconnecting" + ? "#ff9900" + : "yellow"; // 加载动画:循环显示 (空) → . → .. → ... → (空) → ... const [dots, setDots] = React.useState(0); React.useEffect(() => { - if (status.status !== "starting") return; + if (status.status !== "starting" && status.status !== "reconnecting") return; const interval = setInterval(() => { - setDots((d) => (d + 1) % 4); // 0 → 1 → 2 → 3 → 0 ... + setDots((d) => (d + 1) % 4); }, 500); return () => clearInterval(interval); }, [status.status]); @@ -275,7 +281,9 @@ function ServerRow({ ? `Ready (${status.toolCount} tools, ${status.promptCount} prompts, ${status.resourceCount} resources)` : status.status === "failed" ? `Failed` - : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); // 动态显示 (空) / . / .. / ... + : status.status === "reconnecting" + ? `Reconnecting${dots > 0 ? ".".repeat(dots) : " "}` + : "Starting" + (dots > 0 ? ".".repeat(dots) : " "); return ( @@ -293,8 +301,10 @@ function ServerRow({ - {/* Error message for failed servers */} - {status.status === "failed" && status.error ? : null} + {/* Error message for failed or reconnecting servers */} + {(status.status === "failed" || status.status === "reconnecting") && status.error ? ( + + ) : null} ); } @@ -304,59 +314,54 @@ function ServerDetailView({ server, onBack, onCancel, + onReconnect, rows, columns, }: { server: McpServerStatus; onBack: () => void; onCancel: () => void; + onReconnect: (name: string) => void; rows: number; columns: number; }): React.ReactElement { - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndex] = React.useState(0); + const hasReconnect = server.status === "failed"; + const canScroll = server.status === "ready"; - // 合并所有 items(tools, prompts, resources) + // 合并所有 items(tools, prompts, resources)+ Reconnect 选项 const allItems = useMemo(() => { const items: { type: string; name: string }[] = []; + if (hasReconnect) { + items.push({ type: "action", name: "Reconnect" }); + } server.tools.forEach((tool) => items.push({ type: "tool", name: tool })); server.prompts.forEach((prompt) => items.push({ type: "prompt", name: prompt })); server.resources.forEach((resource) => items.push({ type: "resource", name: resource })); return items; - }, [server]); + }, [server, hasReconnect]); const totalItems = allItems.length; const maxVisible = useMemo(() => { - const reservedLines = 10; // header + title + stats + footer + borders + const reservedLines = 12; // header + title + stats + error + footer + borders const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); return Math.max(1, availableLines); }, [rows]); - // 使用 ref 跟踪 visibleStart,避免循环依赖 const visibleStartRef = React.useRef(0); - // 计算可见窗口起始位置:当 activeIndex 超出可见区域时才滚动(类似终端光标行为) const visibleStart = useMemo(() => { if (totalItems === 0) return 0; - const currentStart = visibleStartRef.current; let newStart = currentStart; - - // 如果 activeIndex 在当前可见窗口之前,滚动到 activeIndex if (activeIndex < currentStart) { newStart = activeIndex; - } - // 如果 activeIndex 在当前可见窗口之后,滚动到 activeIndex - else if (activeIndex >= currentStart + maxVisible) { + } else if (activeIndex >= currentStart + maxVisible) { newStart = activeIndex - maxVisible + 1; } - - // 限制在合法范围内 newStart = Math.max(0, Math.min(newStart, Math.max(0, totalItems - maxVisible))); - - // 更新 ref visibleStartRef.current = newStart; - return newStart; }, [activeIndex, maxVisible, totalItems]); @@ -371,11 +376,16 @@ function ServerDetailView({ onBack(); return; } - // Space 或 Enter 键返回一级菜单 - if (input === " " || key.return) { + if (key.return || input === " ") { + if (activeIndex === 0 && hasReconnect) { + onReconnect(server.name); + onBack(); + return; + } onBack(); return; } + if (!canScroll && !hasReconnect) return; if (key.upArrow) { setActiveIndex((prev) => Math.max(0, prev - 1)); return; @@ -384,25 +394,33 @@ function ServerDetailView({ setActiveIndex((prev) => Math.min(totalItems - 1, prev + 1)); return; } - if (key.pageUp) { + if (key.pageUp && canScroll) { setActiveIndex((prev) => Math.max(0, prev - maxVisible)); return; } - if (key.pageDown) { + if (key.pageDown && canScroll) { setActiveIndex((prev) => Math.min(totalItems - 1, prev + maxVisible)); return; } - if (key.home) { + if (key.home && canScroll) { setActiveIndex(0); return; } - if (key.end) { + if (key.end && canScroll) { setActiveIndex(totalItems - 1); } }); - const icon = "✓"; - const color = "green"; + const statusIcon = + server.status === "ready" ? "✓" : server.status === "failed" ? "✗" : server.status === "reconnecting" ? "↻" : "●"; + const statusColor = + server.status === "ready" + ? "green" + : server.status === "failed" + ? "red" + : server.status === "reconnecting" + ? "#ff9900" + : "yellow"; return ( {/* Header row */} - {icon} + {statusIcon} {server.name} - — Details + — {server.status === "ready" ? "Details" : "Status"} {/* Server info */} - {server.toolCount} tools, {server.promptCount} prompts, {server.resourceCount} resources + {server.status === "ready" + ? `${server.toolCount} tools, ${server.promptCount} prompts, ${server.resourceCount} resources` + : `Status: ${server.status}`} + {/* Error for failed/reconnecting */} + {server.error && (server.status === "failed" || server.status === "reconnecting") ? ( + + + + ) : null} {/* Items list */} {/* Footer */} - ↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close + + {hasReconnect + ? "Enter to reconnect · Esc back · Ctrl+C close" + : canScroll + ? "↑/↓ scroll · Space/Enter back · Esc back · Ctrl+C close" + : "Space/Enter back · Esc back · Ctrl+C close"} + @@ -481,13 +513,16 @@ function ServerDetailView({ } function ItemRow({ item, selected }: { item: { type: string; name: string }; selected: boolean }): React.ReactElement { - const icon = item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + const isAction = item.type === "action"; + const icon = isAction ? "↻" : item.type === "tool" ? "🔧" : item.type === "prompt" ? "📝" : "📦"; + const color = isAction && selected ? "#ff9900" : selected ? "#229ac3" : undefined; return ( + {selected ? "> " : " "} {icon} - - {item.name} + + {isAction ? `[${item.name}]` : item.name} ); diff --git a/packages/cli/src/ui/views/PermissionPrompt.tsx b/packages/cli/src/ui/views/PermissionPrompt.tsx new file mode 100644 index 00000000..c90f5e68 --- /dev/null +++ b/packages/cli/src/ui/views/PermissionPrompt.tsx @@ -0,0 +1,275 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Box, Text } from "ink"; +import { useTerminalInput } from "../hooks"; +import type { AskPermissionRequest, AskPermissionScope, UserToolPermission } from "@vegamo/deepcode-core"; +import type { PermissionScope } from "@vegamo/deepcode-core"; + +export type PermissionPromptResult = { + permissions: UserToolPermission[]; + alwaysAllows: PermissionScope[]; + hasDeny: boolean; +}; + +type Props = { + requests: AskPermissionRequest[]; + onSubmit: (result: PermissionPromptResult) => void; + onCancel: () => void; +}; + +type ScopePrompt = { + request: AskPermissionRequest; + scope: AskPermissionScope; +}; + +type PromptOption = { + kind: "allow" | "always" | "deny"; + label: string; + scopeDescription?: string; + scopeColor?: string; +}; + +const ALWAYS_ALLOWED_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +export function PermissionPrompt({ requests, onSubmit, onCancel }: Props): React.ReactElement | null { + const prompts = useMemo(() => buildScopePrompts(requests), [requests]); + const [index, setIndex] = useState(0); + const [cursor, setCursor] = useState(0); + const [decisions, setDecisions] = useState>({}); + const [alwaysAllows, setAlwaysAllows] = useState([]); + + const effectiveIndex = findNextPromptIndex(prompts, index, alwaysAllows); + const prompt = prompts[effectiveIndex] ?? null; + const options = prompt ? buildOptions(prompt.scope) : []; + + useEffect(() => { + setIndex(0); + setCursor(0); + setDecisions({}); + setAlwaysAllows([]); + }, [requests]); + + useEffect(() => { + if (!prompt) { + onSubmit(buildResult(requests, decisions, alwaysAllows)); + } + }, [alwaysAllows, decisions, onSubmit, prompt, requests]); + + useEffect(() => { + if (cursor >= options.length) { + setCursor(Math.max(0, options.length - 1)); + } + }, [cursor, options.length]); + + useTerminalInput((input, key) => { + if (!prompt) { + return; + } + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (key.upArrow) { + setCursor((value) => Math.max(0, value - 1)); + return; + } + if (key.downArrow) { + setCursor((value) => Math.min(options.length - 1, value + 1)); + return; + } + if (input && /^[1-3]$/.test(input)) { + const nextCursor = Number(input) - 1; + if (nextCursor >= 0 && nextCursor < options.length) { + commit(options[nextCursor]!.kind); + } + return; + } + if (key.return) { + commit(options[cursor]?.kind ?? "allow"); + } + }); + + if (!prompt) { + return null; + } + + function commit(kind: "allow" | "always" | "deny"): void { + if (!prompt) { + return; + } + if (kind === "always" && isAlwaysAllowedScope(prompt.scope)) { + const scope = prompt.scope; + setAlwaysAllows((prev) => (prev.includes(scope) ? prev : [...prev, scope])); + setDecisions((prev) => ({ + ...prev, + [prompt.request.toolCallId]: prev[prompt.request.toolCallId] === "deny" ? "deny" : "allow", + })); + } else { + setDecisions((prev) => ({ + ...prev, + [prompt.request.toolCallId]: + kind === "deny" ? "deny" : prev[prompt.request.toolCallId] === "deny" ? "deny" : "allow", + })); + } + setIndex(effectiveIndex + 1); + setCursor(0); + } + + return ( + + + + Permission required + + + {" "} + {Math.min(effectiveIndex + 1, prompts.length)}/{prompts.length} + + + {prompt.request.name} + {prompt.request.command} + {prompt.request.description ? {prompt.request.description} : null} + + Do you want to proceed? + + + {options.map((option, optionIndex) => ( + + {optionIndex === cursor ? "> " : " "} + {optionIndex + 1}. {renderOptionLabel(option)} + + ))} + + + ↑/↓ move · Enter select · Esc interrupt + + + ); +} + +function renderOptionLabel(option: PromptOption): React.ReactNode { + if (option.scopeDescription && option.scopeColor) { + return ( + <> + {option.label} + {option.scopeDescription} + + ); + } + return option.label; +} + +function buildScopePrompts(requests: AskPermissionRequest[]): ScopePrompt[] { + const prompts: ScopePrompt[] = []; + for (const request of requests) { + for (const scope of request.scopes.length > 0 ? request.scopes : ["unknown" as const]) { + prompts.push({ request, scope }); + } + } + return prompts; +} + +function buildOptions(scope: AskPermissionScope): PromptOption[] { + const options: PromptOption[] = [{ kind: "allow", label: "Yes" }]; + if (isAlwaysAllowedScope(scope)) { + options.push({ + kind: "always", + label: "Yes, and always allow ", + scopeDescription: describeScope(scope), + scopeColor: getScopeRiskColor(scope), + }); + } + options.push({ kind: "deny", label: "No" }); + return options; +} + +function findNextPromptIndex(prompts: ScopePrompt[], startIndex: number, alwaysAllows: PermissionScope[]): number { + let index = startIndex; + while (index < prompts.length) { + const scope = prompts[index]!.scope; + if (isAlwaysAllowedScope(scope) && alwaysAllows.includes(scope)) { + index += 1; + continue; + } + return index; + } + return prompts.length; +} + +function buildResult( + requests: AskPermissionRequest[], + decisions: Record, + alwaysAllows: PermissionScope[] +): PermissionPromptResult { + const permissions = requests.map((request) => ({ + toolCallId: request.toolCallId, + permission: decisions[request.toolCallId] === "deny" ? ("deny" as const) : ("allow" as const), + })); + return { + permissions, + alwaysAllows, + hasDeny: permissions.some((permission) => permission.permission === "deny"), + }; +} + +function isAlwaysAllowedScope(scope: AskPermissionScope): scope is PermissionScope { + return ALWAYS_ALLOWED_SCOPES.has(scope); +} + +export function getScopeRiskColor(scope: AskPermissionScope): string { + switch (scope) { + case "read-in-cwd": + case "query-git-log": + return "#22c55e"; + case "read-out-cwd": + case "write-in-cwd": + case "network": + case "mcp": + return "#f59e0b"; + case "write-out-cwd": + case "delete-in-cwd": + case "delete-out-cwd": + case "mutate-git-log": + case "unknown": + return "#ef4444"; + default: + return "#ef4444"; + } +} + +function describeScope(scope: PermissionScope): string { + switch (scope) { + case "read-in-cwd": + return "reads inside this workspace"; + case "read-out-cwd": + return "reads outside this workspace"; + case "write-in-cwd": + return "writes inside this workspace"; + case "write-out-cwd": + return "writes outside this workspace"; + case "delete-in-cwd": + return "deletes inside this workspace"; + case "delete-out-cwd": + return "deletes outside this workspace"; + case "query-git-log": + return "Git history queries"; + case "mutate-git-log": + return "Git history changes"; + case "network": + return "network access"; + case "mcp": + return "MCP tool access"; + default: + return scope; + } +} diff --git a/packages/cli/src/ui/views/ProcessStdoutView.tsx b/packages/cli/src/ui/views/ProcessStdoutView.tsx new file mode 100644 index 00000000..f341eb82 --- /dev/null +++ b/packages/cli/src/ui/views/ProcessStdoutView.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text } from "ink"; +import { BASH_TIMEOUT_DECREMENT_MS, BASH_TIMEOUT_INCREMENT_MS } from "@vegamo/deepcode-core"; +import type { BashTimeoutAdjustment, SessionEntry, SessionProcessEntry } from "@vegamo/deepcode-core"; +import { useTerminalInput } from "../hooks"; + +type RunningProcesses = SessionEntry["processes"]; + +type ProcessStdoutViewProps = { + processStdoutRef: React.RefObject>; + runningProcesses: RunningProcesses; + onDismiss: () => void; + onAdjustTimeout: (deltaMs: number) => BashTimeoutAdjustment | null; + screenWidth: number; + screenHeight: number; +}; + +const REFRESH_INTERVAL_MS = 150; +const MAX_PANEL_HEIGHT = 30; +const MIN_PANEL_HEIGHT = 5; + +export const ProcessStdoutView = React.memo(function ProcessStdoutView({ + processStdoutRef, + runningProcesses, + onDismiss, + onAdjustTimeout, + screenWidth, + screenHeight, +}: ProcessStdoutViewProps): React.ReactElement { + const [stdoutText, setStdoutText] = useState(""); + const [scrollOffset, setScrollOffset] = useState(0); + const [statusMessage, setStatusMessage] = useState(""); + const statusTimerRef = useRef | null>(null); + + const panelHeight = Math.max(MIN_PANEL_HEIGHT, Math.min(screenHeight - 1, MAX_PANEL_HEIGHT)); + const reservedRows = statusMessage ? 2 : 1; + const visibleLineLimit = Math.max(1, panelHeight - reservedRows); + + useEffect(() => { + const updateStdout = () => { + let text = ""; + if (runningProcesses && runningProcesses.size > 0) { + for (const [pid, proc] of runningProcesses.entries()) { + const pidNum = Number(pid); + const stdout = processStdoutRef.current.get(pidNum) ?? ""; + if (text) { + text += "\n"; + } + if (runningProcesses.size > 1) { + text += `── Process ${pid} [${proc.command}] ──\n`; + } + text += stdout || "(no output yet)"; + } + } else { + text = "(no running processes)"; + } + setStdoutText(text); + }; + + updateStdout(); + const interval = setInterval(updateStdout, REFRESH_INTERVAL_MS); + return () => clearInterval(interval); + }, [processStdoutRef, runningProcesses]); + + useEffect(() => { + return () => { + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current); + } + }; + }, []); + + const lines = useMemo(() => stdoutText.split("\n"), [stdoutText]); + const timeoutProcess = useMemo(() => getLatestTimeoutProcess(runningProcesses), [runningProcesses]); + + const visibleLines = useMemo(() => { + if (lines.length <= visibleLineLimit) { + return lines; + } + const outputLineLimit = Math.max(1, visibleLineLimit - 1); + const start = Math.max(0, lines.length - outputLineLimit - scrollOffset); + const slice = lines.slice(start, start + outputLineLimit); + if (lines.length > visibleLineLimit) { + slice.unshift(`... (${start} lines above · ↑/↓ to scroll · ${lines.length} total lines) ...`); + } + return slice; + }, [lines, scrollOffset, visibleLineLimit]); + + const setTemporaryStatus = (message: string) => { + setStatusMessage(message); + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current); + } + statusTimerRef.current = setTimeout(() => setStatusMessage(""), 2000); + }; + + useTerminalInput( + (input, key) => { + if ((key.ctrl && (input === "o" || input === "O")) || key.escape) { + onDismiss(); + return; + } + if (input === "+") { + const adjustment = onAdjustTimeout(BASH_TIMEOUT_INCREMENT_MS); + setTemporaryStatus(formatAdjustmentStatus(adjustment)); + return; + } + if (input === "-") { + const adjustment = onAdjustTimeout(-BASH_TIMEOUT_DECREMENT_MS); + setTemporaryStatus(formatAdjustmentStatus(adjustment)); + return; + } + if (key.upArrow) { + setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - visibleLineLimit))); + return; + } + if (key.downArrow) { + setScrollOffset((s) => Math.max(s - 10, 0)); + return; + } + if (key.pageUp) { + setScrollOffset((s) => Math.min(s + visibleLineLimit, Math.max(0, lines.length - visibleLineLimit))); + return; + } + if (key.pageDown) { + setScrollOffset((s) => Math.max(s - visibleLineLimit, 0)); + return; + } + }, + { isActive: true } + ); + + return ( + + + 📟 Process Output + {` (${formatTimeoutHint( + timeoutProcess?.entry + )} · +/- adjust · Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll)`} + + + {visibleLines.map((line, index) => ( + {line} + ))} + + {statusMessage ? ( + + {statusMessage} + + ) : null} + + ); +}); + +function getLatestTimeoutProcess( + runningProcesses: RunningProcesses +): { pid: string; entry: SessionProcessEntry } | null { + if (!runningProcesses) { + return null; + } + let latest: { pid: string; entry: SessionProcessEntry } | null = null; + for (const [pid, entry] of runningProcesses.entries()) { + if (typeof entry.timeoutMs !== "number") { + continue; + } + latest = { pid, entry }; + } + return latest; +} + +function formatTimeoutHint(entry?: SessionProcessEntry): string { + if (!entry || typeof entry.timeoutMs !== "number") { + return "timeout unavailable"; + } + return `timeout ${formatDuration(entry.timeoutMs)}`; +} + +function formatAdjustmentStatus(adjustment: BashTimeoutAdjustment | null): string { + if (!adjustment) { + return "No adjustable Bash timeout"; + } + return `Timeout set to ${formatDuration(adjustment.timeoutMs)}`; +} + +function formatDuration(ms: number): string { + const totalMinutes = Math.max(1, Math.round(ms / 60000)); + return `${totalMinutes}m`; +} diff --git a/src/ui/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx similarity index 58% rename from src/ui/PromptInput.tsx rename to packages/cli/src/ui/views/PromptInput.tsx index 0387ceb5..3f548def 100644 --- a/src/ui/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -1,12 +1,18 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { Box, Text, useApp, useStdout } from "ink"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useStdout } from "ink"; +import type { DOMElement } from "ink"; import chalk from "chalk"; +import { ARGS_SEPARATOR } from "../constants"; import { EMPTY_BUFFER, + PASTE_MARKER_REGEX, backspace, deleteForward, + deletePasteMarkerBackward, + deletePasteMarkerForward, deleteWordBefore, deleteWordAfter, + expandPasteMarkers, getCurrentSlashToken, insertText, isEmpty, @@ -19,43 +25,60 @@ import { moveWordLeft, moveWordRight, moveUp, -} from "./promptBuffer"; -import type { PromptBufferState } from "./promptBuffer"; +} from "../core/prompt-buffer"; +import type { PromptBufferState } from "../core/prompt-buffer"; import { clearPromptUndoRedoState, createPromptUndoRedoState, recordPromptEdit, redoPromptEdit, undoPromptEdit, -} from "./promptUndoRedo"; -import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "./slashCommands"; -import type { SlashCommandItem } from "./slashCommands"; +} from "../core/prompt-undo-redo"; +import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "../core/slash-commands"; +import type { SlashCommandItem } from "../core/slash-commands"; import { filterFileMentionItems, getCurrentFileMentionToken, replaceCurrentFileMentionToken, scanFileMentionItems, -} from "./fileMentions"; -import type { FileMentionItem } from "./fileMentions"; -import { readClipboardImageAsync } from "./clipboard"; -import type { SkillInfo } from "../session"; - -// Re-exported from prompt modules for backward compatibility -export { useTerminalInput, parseTerminalInput } from "./prompt"; -export type { InputKey } from "./prompt"; - -import { useTerminalInput } from "./prompt"; -import type { InputKey } from "./prompt"; -import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; -import SlashCommandMenu from "./SlashCommandMenu"; -import type { ModelConfigSelection, ReasoningEffort } from "../settings"; -import DropdownMenu from "./DropdownMenu"; +} from "../core/file-mentions"; +import type { FileMentionItem } from "../core/file-mentions"; +import { readClipboardImageAsync } from "../core/clipboard"; +import { + useTerminalInput, + usePasteHandling, + useHistoryNavigation, + getPromptCursorPlacement, + isPromptCursorAtWrapBoundary, + usePromptTerminalCursor, +} from "../hooks"; +import type { InputKey } from "../hooks"; +import { + useHiddenTerminalCursor, + useTerminalExtendedKeys, + useBracketedPaste, + useTerminalFocusReporting, +} from "../hooks"; +import SlashCommandMenu, { isSkillSelected } from "./SlashCommandMenu"; +import type { ModelConfigSelection, PermissionScope } from "@vegamo/deepcode-core"; +import { FileMentionMenu, ModelsDropdown, RawModelDropdown, SkillsDropdown } from "../components"; +import type { SessionEntry, SkillInfo } from "@vegamo/deepcode-core"; +import type { UserToolPermission } from "@vegamo/deepcode-core"; +import type { StatusSegment } from "../statusline"; export type PromptSubmission = { text: string; imageUrls: string[]; selectedSkills?: SkillInfo[]; - command?: "new" | "resume" | "continue" | "mcp" | "exit"; + permissions?: UserToolPermission[]; + alwaysAllows?: PermissionScope[]; + command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; +}; + +export type PromptDraft = { + nonce: number; + text: string; + imageUrls: string[]; }; type Props = { @@ -65,49 +88,30 @@ type Props = { screenWidth: number; promptHistory: string[]; busy: boolean; + cursorLayoutKey?: string; loadingText?: string | null; disabled?: boolean; placeholder?: string; - runningProcesses?: Map | null; + runningProcesses?: SessionEntry["processes"]; + promptDraft?: PromptDraft | null; + statusLineSegments?: StatusSegment[]; + statusLineSeparator?: string; onSubmit: (submission: PromptSubmission) => void; onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; + onRawModeChange?: (mode: string) => void; onInterrupt: () => void; onToggleProcessStdout?: () => void; + onExitShortcut?: () => void; }; -const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; +const PROMPT_PREFIX_WIDTH = 2; -type ThinkingModeOption = { - label: string; - thinkingEnabled: boolean; - reasoningEffort?: ReasoningEffort; -}; - -export const MODEL_COMMAND_THINKING_OPTIONS: ThinkingModeOption[] = [ - { label: "Thinking mode [max]", thinkingEnabled: true, reasoningEffort: "max" }, - { label: "Thinking mode [high]", thinkingEnabled: true, reasoningEffort: "high" }, - { label: "No thinking", thinkingEnabled: false }, -]; - -type ModelDropdownStep = "model" | "thinking"; - -const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: boolean }): React.ReactElement { - const [spinnerIndex, setSpinnerIndex] = useState(0); - - useEffect(() => { - if (!busy) { - setSpinnerIndex(0); - return; - } - const timer = setInterval(() => { - setSpinnerIndex((index) => (index + 1) % SPINNER_FRAMES.length); - }, 80); - return () => clearInterval(timer); - }, [busy]); - - const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; +const PromptPrefixLine = React.memo(function PromptPrefixLine(): React.ReactElement { + return ( + + {"> "} + + ); }); export const PromptInput = React.memo(function PromptInput({ @@ -117,17 +121,23 @@ export const PromptInput = React.memo(function PromptInput({ screenWidth, promptHistory, busy, + cursorLayoutKey, loadingText, disabled, placeholder, runningProcesses, + promptDraft, + statusLineSegments, + statusLineSeparator, onSubmit, onModelConfigChange, onInterrupt, onToggleProcessStdout, + onExitShortcut, + onRawModeChange, }: Props): React.ReactElement { - const { exit } = useApp(); const { stdout } = useStdout(); + const inputTextRef = useRef(null); const [buffer, setBuffer] = useState(EMPTY_BUFFER); const [imageUrls, setImageUrls] = useState([]); const [selectedSkills, setSelectedSkills] = useState([]); @@ -135,20 +145,25 @@ export const PromptInput = React.memo(function PromptInput({ const [pendingExit, setPendingExit] = useState(false); const [menuIndex, setMenuIndex] = useState(0); const [showSkillsDropdown, setShowSkillsDropdown] = useState(false); - const [skillsDropdownIndex, setSkillsDropdownIndex] = useState(0); - const [modelDropdownStep, setModelDropdownStep] = useState(null); - const [modelDropdownIndex, setModelDropdownIndex] = useState(0); - const [pendingModel, setPendingModel] = useState(null); + const [openRawModelDropdown, setOpenRawModelDropdown] = useState(false); + const [showModelDropdown, setShowModelDropdown] = useState(false); const [fileMentionItems, setFileMentionItems] = useState(() => scanFileMentionItems(projectRoot)); - const [fileMentionIndex, setFileMentionIndex] = useState(0); const [dismissedFileMentionKey, setDismissedFileMentionKey] = useState(null); - const [historyCursor, setHistoryCursor] = useState(-1); - const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); const lastCtrlDAt = React.useRef(0); const undoRedoRef = React.useRef(createPromptUndoRedoState()); const wasBusyRef = React.useRef(busy); const hadFileMentionTokenRef = React.useRef(false); + const appliedDraftNonceRef = React.useRef(null); + + const { historyCursor, navigateHistory, exitHistoryBrowsing } = useHistoryNavigation( + buffer, + setBuffer, + promptHistory + ); + + const { pastesRef, handlePaste, expandPasteMarkerAtCursor, resetPastes, hasCollapsedMarkers, hasExpandedRegions } = + usePasteHandling(buffer, updateBuffer, setStatusMessage); const fileMentionToken = getCurrentFileMentionToken(buffer); const hasFileMentionToken = fileMentionToken !== null; @@ -159,34 +174,72 @@ export const PromptInput = React.memo(function PromptInput({ ); const showFileMentionMenu = !showSkillsDropdown && - !modelDropdownStep && + !showModelDropdown && + !openRawModelDropdown && fileMentionToken !== null && fileMentionKey !== dismissedFileMentionKey; const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); const slashMenu = React.useMemo( () => - showSkillsDropdown || modelDropdownStep || showFileMentionMenu + showSkillsDropdown || showModelDropdown || openRawModelDropdown || showFileMentionMenu ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : [], - [showSkillsDropdown, modelDropdownStep, showFileMentionMenu, slashToken, slashItems] + [showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu, slashToken, slashItems] ); const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); const hasRunningProcess = runningProcesses && runningProcesses.size > 0; - const processHint = hasRunningProcess ? " · ctrl+o view output" : ""; + const processOrPasteHint = hasRunningProcess + ? " · ctrl+o view output" + : hasCollapsedMarkers + ? " · ctrl+o expand" + : hasExpandedRegions + ? " · ctrl+o collapse" + : ""; + const busyStatusText = + loadingText && loadingText.trim() + ? `${loadingText}${processOrPasteHint}` + : `esc to interrupt · ctrl+c to cancel input${processOrPasteHint}`; const footerText = statusMessage ? statusMessage : busy - ? loadingText && loadingText.trim() - ? `${loadingText}${processHint}` - : `esc to interrupt · ctrl+c to cancel input${processHint}` - : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processHint}`; + ? busyStatusText + : `enter send · shift+enter newline · @ files · ctrl+v image · / commands · ctrl+d exit${processOrPasteHint}`; + const showFooterText = useMemo( + () => showMenu || showSkillsDropdown || openRawModelDropdown || showModelDropdown || showFileMentionMenu, + [showMenu, showSkillsDropdown, showModelDropdown, openRawModelDropdown, showFileMentionMenu] + ); + const inputContentWidth = Math.max(1, screenWidth - PROMPT_PREFIX_WIDTH); + + const cursorPlacement = useMemo( + () => getPromptCursorPlacement(buffer, inputContentWidth), + [buffer, inputContentWidth] + ); + const useInlineCursor = isPromptCursorAtWrapBoundary(buffer, inputContentWidth); + const usePositionedCursor = !disabled && hasTerminalFocus && !showFooterText && stdout.isTTY && !useInlineCursor; + const promptCursorLayoutKey = useMemo( + () => + [ + screenWidth, + cursorLayoutKey ?? "default", + imageUrls.length, + selectedSkills.map((skill) => skill.name).join("\u001F"), + ].join("\u001E"), + [cursorLayoutKey, imageUrls.length, screenWidth, selectedSkills] + ); useTerminalFocusReporting(stdout, !disabled); useTerminalExtendedKeys(stdout, !disabled); - useHiddenTerminalCursor(stdout, !disabled); + useBracketedPaste(stdout, !disabled); + const terminalCursorActive = usePromptTerminalCursor( + inputTextRef, + cursorPlacement, + !busy && usePositionedCursor, + promptCursorLayoutKey + ); + useHiddenTerminalCursor(stdout, !disabled && (busy || !terminalCursorActive)); const refreshFileMentionItems = React.useCallback(() => { setFileMentionItems(scanFileMentionItems(projectRoot)); @@ -226,33 +279,6 @@ export const PromptInput = React.memo(function PromptInput({ } }, [fileMentionKey]); - useEffect(() => { - if (!showFileMentionMenu) { - setFileMentionIndex(0); - return; - } - if (fileMentionIndex >= fileMentionMatches.length) { - setFileMentionIndex(Math.max(0, fileMentionMatches.length - 1)); - } - }, [fileMentionMatches.length, fileMentionIndex, showFileMentionMenu]); - - useEffect(() => { - if (skillsDropdownIndex >= skills.length) { - setSkillsDropdownIndex(Math.max(0, skills.length - 1)); - } - }, [skills.length, skillsDropdownIndex]); - - useEffect(() => { - if (!modelDropdownStep) { - return; - } - const optionCount = - modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; - if (modelDropdownIndex >= optionCount) { - setModelDropdownIndex(Math.max(0, optionCount - 1)); - } - }, [modelDropdownIndex, modelDropdownStep]); - useEffect(() => { if (!statusMessage) { return; @@ -262,9 +288,23 @@ export const PromptInput = React.memo(function PromptInput({ }, [statusMessage]); useEffect(() => { - setHistoryCursor(-1); - setDraftBeforeHistory(null); - }, [promptHistoryKey]); + if (!promptDraft || appliedDraftNonceRef.current === promptDraft.nonce) { + return; + } + appliedDraftNonceRef.current = promptDraft.nonce; + setBuffer({ text: promptDraft.text, cursor: promptDraft.text.length }); + setImageUrls(promptDraft.imageUrls); + setSelectedSkills([]); + setShowSkillsDropdown(false); + setOpenRawModelDropdown(false); + exitHistoryBrowsing(); + clearPromptUndoRedoState(undoRedoRef.current); + resetPastes(); + }, [promptDraft, exitHistoryBrowsing, resetPastes]); + + useEffect(() => { + exitHistoryBrowsing(); + }, [promptHistoryKey, exitHistoryBrowsing]); useTerminalInput( (input, key) => { @@ -282,16 +322,10 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.escape) { - if (modelDropdownStep) { - closeModelDropdown(); - return; - } - if (showSkillsDropdown) { - setShowSkillsDropdown(false); + if (openRawModelDropdown) { return; } - if (showFileMentionMenu && fileMentionKey) { - setDismissedFileMentionKey(fileMentionKey); + if (showFileMentionMenu) { return; } if (busy) { @@ -301,11 +335,18 @@ export const PromptInput = React.memo(function PromptInput({ return; } + if (isRawModeShortcut(input, key)) { + setShowSkillsDropdown(false); + setShowModelDropdown(false); + setOpenRawModelDropdown(true); + return; + } + if (key.ctrl && (input === "o" || input === "O")) { if (runningProcesses && runningProcesses.size > 0 && onToggleProcessStdout) { onToggleProcessStdout(); } else { - setStatusMessage("No running process to inspect"); + expandPasteMarkerAtCursor(); } return; } @@ -317,7 +358,7 @@ export const PromptInput = React.memo(function PromptInput({ } const now = Date.now(); if (pendingExit && now - lastCtrlDAt.current < 2000) { - exit(); + onExitShortcut?.(); return; } lastCtrlDAt.current = now; @@ -333,6 +374,7 @@ export const PromptInput = React.memo(function PromptInput({ } else if (!isEmpty(buffer)) { setBuffer(EMPTY_BUFFER); clearUndoRedoStacks(); + resetPastes(); } else { setStatusMessage("press ctrl+d to exit"); } @@ -343,55 +385,17 @@ export const PromptInput = React.memo(function PromptInput({ setPendingExit(false); } - if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { - exitHistoryBrowsing(); + if (openRawModelDropdown || showSkillsDropdown || showModelDropdown) { + return; } - if (showSkillsDropdown) { - if (skills.length === 0) { - setShowSkillsDropdown(false); - } else { - if (key.upArrow) { - setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); - return; - } - if (key.downArrow) { - setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); - return; - } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - const skill = skills[skillsDropdownIndex]; - if (skill) { - toggleSelectedSkill(skill); - } - return; - } - if (key.tab) { - setShowSkillsDropdown(false); - return; - } - } + if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { + exitHistoryBrowsing(); } - if (modelDropdownStep) { - const optionCount = - modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.length : MODEL_COMMAND_THINKING_OPTIONS.length; - if (key.upArrow) { - setModelDropdownIndex((idx) => (idx - 1 + optionCount) % optionCount); - return; - } - if (key.downArrow) { - setModelDropdownIndex((idx) => (idx + 1) % optionCount); - return; - } - if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - selectModelDropdownItem(); - return; - } - if (key.tab) { - closeModelDropdown(); - return; - } + if (key.paste) { + handlePaste(input); + return; } if (key.ctrl && (input === "v" || input === "V")) { @@ -426,32 +430,9 @@ export const PromptInput = React.memo(function PromptInput({ const isPlainReturn = returnAction === "submit"; if (showFileMentionMenu) { - if (key.upArrow) { - if (fileMentionMatches.length > 0) { - setFileMentionIndex((idx) => (idx - 1 + fileMentionMatches.length) % fileMentionMatches.length); - } - return; - } - if (key.downArrow) { - if (fileMentionMatches.length > 0) { - setFileMentionIndex((idx) => (idx + 1) % fileMentionMatches.length); - } + if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") { return; } - if (key.tab || returnAction === "submit") { - const selected = fileMentionMatches[fileMentionIndex]; - if (selected && fileMentionToken) { - insertFileMentionSelection(selected); - return; - } - if (key.tab) { - setDismissedFileMentionKey(fileMentionKey); - return; - } - if (fileMentionKey) { - setDismissedFileMentionKey(fileMentionKey); - } - } } if (showMenu) { @@ -488,12 +469,12 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.delete) { - updateBuffer((s) => deleteForward(s)); + updateBuffer((s) => deletePasteMarkerForward(s, pastesRef.current) ?? deleteForward(s)); return; } if (key.backspace) { - updateBuffer((s) => backspace(s)); + updateBuffer((s) => deletePasteMarkerBackward(s, pastesRef.current) ?? backspace(s)); return; } @@ -583,6 +564,7 @@ export const PromptInput = React.memo(function PromptInput({ } if (key.ctrl && (input === "u" || input === "U")) { updateBuffer(() => EMPTY_BUFFER); + resetPastes(); return; } if (key.ctrl && (input === "w" || input === "W")) { @@ -646,11 +628,6 @@ export const PromptInput = React.memo(function PromptInput({ clearPromptUndoRedoState(undoRedoRef.current); } - function exitHistoryBrowsing(): void { - setHistoryCursor(-1); - setDraftBeforeHistory(null); - } - function updateBuffer(updater: (state: PromptBufferState) => PromptBufferState): void { exitHistoryBrowsing(); setBuffer((current) => { @@ -660,32 +637,6 @@ export const PromptInput = React.memo(function PromptInput({ }); } - function navigateHistory(direction: -1 | 1): void { - if (promptHistory.length === 0) { - return; - } - - const previousCursor = historyCursor === -1 ? promptHistory.length : historyCursor; - const nextCursor = Math.max(0, Math.min(promptHistory.length, previousCursor + direction)); - const draft = historyCursor === -1 ? buffer.text : draftBeforeHistory; - - if (historyCursor === -1) { - setDraftBeforeHistory(buffer.text); - } - - if (nextCursor === promptHistory.length) { - const text = draft ?? ""; - setBuffer({ text, cursor: text.length }); - setHistoryCursor(-1); - setDraftBeforeHistory(null); - return; - } - - const text = promptHistory[nextCursor] ?? ""; - setBuffer({ text, cursor: text.length }); - setHistoryCursor(nextCursor); - } - function insertFileMentionSelection(item: FileMentionItem): void { if (!fileMentionToken) { return; @@ -694,6 +645,16 @@ export const PromptInput = React.memo(function PromptInput({ setDismissedFileMentionKey(null); } + function resetPromptInput(): void { + setBuffer(EMPTY_BUFFER); + clearUndoRedoStacks(); + setImageUrls([]); + setSelectedSkills([]); + setShowSkillsDropdown(false); + exitHistoryBrowsing(); + resetPastes(); + } + function handleSlashSelection(item: SlashCommandItem): void { if (busy && item.kind !== "exit") { setStatusMessage("wait for the current response or press esc to interrupt"); @@ -713,52 +674,43 @@ export const PromptInput = React.memo(function PromptInput({ } if (item.kind === "model") { clearSlashToken(); - openModelDropdown(); + setShowSkillsDropdown(false); + setShowModelDropdown(true); + return; + } + if (item.kind === "raw") { + clearSlashToken(); + setOpenRawModelDropdown(true); return; } if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "init") { onSubmit(buildInitPromptSubmission(selectedSkills)); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "resume") { onSubmit({ text: "", imageUrls: [], command: "resume" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "continue") { onSubmit({ text: "/continue", imageUrls: [], command: "continue" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); + return; + } + if (item.kind === "undo") { + onSubmit({ text: "/undo", imageUrls: [], command: "undo" }); + resetPromptInput(); return; } if (item.kind === "mcp") { onSubmit({ text: "/mcp", imageUrls: [], command: "mcp" }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); return; } if (item.kind === "exit") { @@ -789,15 +741,11 @@ export const PromptInput = React.memo(function PromptInput({ } onSubmit({ - text: buffer.text, + text: expandPasteMarkers(buffer.text, pastesRef.current), imageUrls, selectedSkills, }); - setBuffer(EMPTY_BUFFER); - clearUndoRedoStacks(); - setImageUrls([]); - setSelectedSkills([]); - setShowSkillsDropdown(false); + resetPromptInput(); } function addSelectedSkill(skill: SkillInfo): void { @@ -814,64 +762,8 @@ export const PromptInput = React.memo(function PromptInput({ clearUndoRedoStacks(); } - function openModelDropdown(): void { - const currentModelIndex = MODEL_COMMAND_MODELS.findIndex((model) => model === modelConfig.model); - setPendingModel(null); - setModelDropdownStep("model"); - setModelDropdownIndex(currentModelIndex >= 0 ? currentModelIndex : 0); - setShowSkillsDropdown(false); - } - - function closeModelDropdown(): void { - setModelDropdownStep(null); - setPendingModel(null); - } - - function selectModelDropdownItem(): void { - if (modelDropdownStep === "model") { - const model = MODEL_COMMAND_MODELS[modelDropdownIndex] ?? modelConfig.model; - setPendingModel(model); - setModelDropdownStep("thinking"); - setModelDropdownIndex(getThinkingOptionIndex(modelConfig)); - return; - } - - const option = MODEL_COMMAND_THINKING_OPTIONS[modelDropdownIndex] ?? MODEL_COMMAND_THINKING_OPTIONS[0]; - const selection: ModelConfigSelection = { - model: pendingModel ?? modelConfig.model, - thinkingEnabled: option.thinkingEnabled, - reasoningEffort: option.reasoningEffort ?? modelConfig.reasoningEffort, - }; - closeModelDropdown(); - Promise.resolve(onModelConfigChange(selection)) - .then((message) => { - if (message) { - setStatusMessage(message); - } - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setStatusMessage(`Failed to update model settings: ${message}`); - }); - } - - const modelDropdownItems = - modelDropdownStep === "model" - ? MODEL_COMMAND_MODELS.map((model) => ({ - label: model, - selected: model === (pendingModel ?? modelConfig.model), - description: model === modelConfig.model ? "current model" : "", - })) - : MODEL_COMMAND_THINKING_OPTIONS.map((option) => ({ - label: option.label, - selected: getThinkingOptionIndex(modelConfig) === MODEL_COMMAND_THINKING_OPTIONS.indexOf(option), - description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", - })); - - const showFooterText = useMemo( - () => showMenu || showSkillsDropdown || modelDropdownStep !== null || showFileMentionMenu, - [showMenu, showSkillsDropdown, modelDropdownStep, showFileMentionMenu] - ); + const matchedCommand = slashToken ? findExactSlashCommand(slashItems, slashToken) : null; + const inlineHint = matchedCommand?.args ? ` ${matchedCommand.args.join(ARGS_SEPARATOR)}` : ""; return ( @@ -891,6 +783,7 @@ export const PromptInput = React.memo(function PromptInput({ ) : null} {/* Input */} - - {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} + + + + {renderBufferWithCursor( + buffer, + !disabled && hasTerminalFocus, + placeholder, + pastesRef.current, + !busy && !terminalCursorActive + )} + + {inlineHint ? {inlineHint} : null} + - {showSkillsDropdown ? ( - ({ - key: skill.path || skill.name, - label: skill.name, - description: skill.path, - selected: isSkillSelected(selectedSkills, skill), - statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, - }))} - activeIndex={skillsDropdownIndex} - activeColor="#229ac3" - maxVisible={6} - /> - ) : null} - {modelDropdownStep ? ( - onRawModeChange?.(mode)} + screenWidth={screenWidth} + /> + + setShowModelDropdown(false)} + onModelConfigChange={onModelConfigChange} + onStatusMessage={setStatusMessage} + /> + { + if (fileMentionKey) { + setDismissedFileMentionKey(fileMentionKey); } - items={modelDropdownItems.map((item) => ({ - key: item.label, - label: item.label, - description: item.description, - selected: item.selected, - }))} - activeIndex={modelDropdownIndex} - activeColor="#229ac3" - maxVisible={6} - /> - ) : null} - {showFileMentionMenu ? ( - ({ - key: item.path, - label: item.path, - description: item.type === "directory" ? "directory" : "file", - }))} - activeIndex={fileMentionIndex} - activeColor="#229ac3" - maxVisible={8} - renderItem={(item, isActive) => ( - - {isActive ? "> " : " "} - - - {item.label} - - - {item.description ? ( - - {item.description} - - ) : null} - - )} - /> - ) : null} + }} + onSelect={insertFileMentionSelection} + /> {!showFooterText && ( {footerText} )} + {statusLineSegments && statusLineSegments.length > 0 && ( + + {(() => { + const lines: StatusSegment[][] = []; + let currentLine: StatusSegment[] = []; + for (const segment of statusLineSegments) { + if (segment.newLine && currentLine.length > 0) { + lines.push(currentLine); + currentLine = []; + } + currentLine.push(segment); + } + if (currentLine.length > 0) { + lines.push(currentLine); + } + return lines.map((line, lineIndex) => ( + + {line.map((segment, index) => ( + + {index > 0 && {statusLineSeparator ?? " · "}} + + {segment.text} + + + ))} + + )); + })()} + + )} ); }); @@ -997,10 +896,6 @@ export function formatSelectedSkillsStatus(skills: SkillInfo[]): string { return `⚡ ${names.join(", ")}`; } -export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean { - return skills.some((item) => item.name === skill.name); -} - export function addUniqueSkill(skills: SkillInfo[], skill: SkillInfo): SkillInfo[] { if (isSkillSelected(skills, skill)) { return skills; @@ -1020,18 +915,6 @@ export function buildInitPromptSubmission(selectedSkills: SkillInfo[]): PromptSu }; } -export function getThinkingOptionIndex( - config: Pick -): number { - const index = MODEL_COMMAND_THINKING_OPTIONS.findIndex((option) => { - if (!config.thinkingEnabled) { - return !option.thinkingEnabled; - } - return option.thinkingEnabled && option.reasoningEffort === config.reasoningEffort; - }); - return index >= 0 ? index : 0; -} - export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { let start = state.cursor; while (start > 0 && !/\s/.test(state.text[start - 1] ?? "")) { @@ -1051,6 +934,10 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick): boolean { + return key.ctrl && (input === "r" || input === "R"); +} + export type PromptReturnKeyAction = "submit" | "newline" | null; export function getPromptReturnKeyAction(key: Pick): PromptReturnKeyAction { @@ -1063,30 +950,128 @@ export function getPromptReturnKeyAction(key: Pick, + showSimulatedCursor = true +): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); - const before = text.slice(0, cursor); - const at = text[cursor]; - const after = text.slice(cursor + 1); + const validIds = validPastes ?? new Map(); if (text.length === 0 && placeholder) { - if (!isFocused) { + if (!isFocused || !showSimulatedCursor) { return chalk.dim(` ${placeholder}`); } return renderCursorCell(" ") + chalk.dim(` ${placeholder}`); } - if (!isFocused) { - return text.endsWith("\n") ? `${text} ` : text; + if (text.length === 0) { + if (!isFocused) { + return ""; + } + return showSimulatedCursor ? renderCursorCell(" ") : " "; + } + + if (!isFocused || !showSimulatedCursor) { + return highlightPasteMarkersInText(text, validIds); + } + + return renderFocusedText(text, cursor, validIds); +} + +function highlightPasteMarkersInText(s: string, validIds: Map): string { + if (!s.includes("[paste #")) return s.endsWith("\n") ? `${s} ` : s; + PASTE_MARKER_REGEX.lastIndex = 0; + let result = ""; + let pos = 0; + let match: RegExpExecArray | null; + while ((match = PASTE_MARKER_REGEX.exec(s)) !== null) { + result += s.slice(pos, match.index); + const id = Number.parseInt(match[1]!, 10); + result += validIds.has(id) ? chalk.yellow(match[0]) : match[0]; + pos = match.index + match[0].length; + } + result += s.slice(pos); + return result.endsWith("\n") ? `${result} ` : result; +} + +/** + * Render focused text with paste-marker highlighting and cursor insertion. + * Scans through the entire string in one pass, so the cursor can land + * anywhere (including inside or at the boundary of a paste marker) and the + * marker will still be highlighted correctly. + */ +function renderFocusedText(text: string, cursor: number, validIds: Map): string { + let result = ""; + let pos = 0; + PASTE_MARKER_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = PASTE_MARKER_REGEX.exec(text)) !== null) { + const markerStart = match.index; + const markerEnd = match.index + match[0].length; + const id = Number.parseInt(match[1]!, 10); + const isReal = validIds.has(id); + + // 1. Non-marker segment before this marker. + result += renderTextSegmentWithCursor(text, pos, markerStart, cursor, false); + pos = markerStart; + + // 2. Marker segment — highlighted only if it corresponds to a real paste. + result += renderTextSegmentWithCursor(text, pos, markerEnd, cursor, isReal); + pos = markerEnd; + } + + // 3. Remainder after the last marker. + result += renderTextSegmentWithCursor(text, pos, text.length, cursor, false); + + return result; +} + +/** + * Render a segment of `text` from `start` to `end`. + * The cursor (if it falls inside this segment) is rendered as an inverse-video cell. + */ +function renderTextSegmentWithCursor( + text: string, + start: number, + end: number, + cursor: number, + highlighted: boolean +): string { + if (start >= end) return ""; + + const segText = text.slice(start, end); + const cursorRel = cursor - start; // relative cursor position inside this segment + + // Cursor not in this segment – just return the text. + if (cursorRel < 0 || cursorRel > segText.length) { + return highlighted ? chalk.yellow(segText) : segText; } - if (typeof at === "undefined") { - return before + renderCursorCell(" "); + // Cursor is exactly at `end` (which equals `segText.length`). + if (cursorRel === segText.length) { + return highlighted ? chalk.yellow(segText) + renderCursorCell(" ") : segText + renderCursorCell(" "); } + + // Cursor is somewhere inside the segment. + const at = segText[cursorRel]; + if (at === "\n") { + // Render newline as a space in the cursor cell, then output the actual newline. + const before = segText.slice(0, cursorRel); + const after = segText.slice(cursorRel + 1); return before + renderCursorCell(" ") + "\n" + after; } + + const before = segText.slice(0, cursorRel); + const after = segText.slice(cursorRel + 1); + if (highlighted) { + return chalk.yellow(before) + renderCursorCell(at) + chalk.yellow(after); + } return before + renderCursorCell(at) + after; } diff --git a/packages/cli/src/ui/views/SessionList.tsx b/packages/cli/src/ui/views/SessionList.tsx new file mode 100644 index 00000000..a41cae3a --- /dev/null +++ b/packages/cli/src/ui/views/SessionList.tsx @@ -0,0 +1,437 @@ +import React, { useState, useMemo, useCallback } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { SessionEntry, SessionStatus } from "@vegamo/deepcode-core"; +import { truncate } from "../components/MessageView/utils"; + +type Props = { + sessions: SessionEntry[]; + onSelect: (sessionId: string) => void; + onCancel: () => void; + onDelete?: (sessionId: string) => void; + onRename?: (sessionId: string, newName: string) => void; +}; + +/** + * Filter sessions by a search query. + * Matches against summary, status, and failReason fields (case-insensitive). + * Returns all sessions when query is empty. + */ +export function filterSessions(sessions: SessionEntry[], query: string): SessionEntry[] { + if (!query.trim()) { + return sessions; + } + + const lowerQuery = query.toLowerCase().trim(); + return sessions.filter((session) => { + if (session.summary && session.summary.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.status.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.failReason && session.failReason.toLowerCase().includes(lowerQuery)) { + return true; + } + if (session.assistantReply && session.assistantReply.toLowerCase().includes(lowerQuery)) { + return true; + } + return false; + }); +} + +export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename }: Props): React.ReactElement { + const [index, setIndex] = useState(0); + const [searchQuery, setSearchQuery] = useState(""); + const [confirmDeleteSessionId, setConfirmDeleteSessionId] = useState(null); + const [renameSessionId, setRenameSessionId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [renameCursor, setRenameCursor] = useState(0); + const { columns, rows } = useWindowSize(); + + // Filter sessions by search query + const filteredSessions = useMemo(() => filterSessions(sessions, searchQuery), [sessions, searchQuery]); + + // Reset index when filtered list changes (e.g., query changes) + const safeIndex = useMemo(() => { + if (filteredSessions.length === 0) return 0; + return Math.max(0, Math.min(index, filteredSessions.length - 1)); + }, [index, filteredSessions.length]); + + // Dynamically calculate the number of visible sessions based on terminal height + const maxVisibleSessions = useMemo(() => { + // Subtract space used by borders, header (2 lines with search bar), footer, scroll indicator, etc. + // Outer container height=rows-1, outer border 2 + header 2 + search bar 1 + inner border 2 + footer 1 + scroll indicator 1 = 9 + const reservedLines = searchQuery ? 12 : 9; + const linesPerSession = 3; // height=2 + marginBottom=1 + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); + return Math.max(1, Math.floor(availableLines / linesPerSession)); + }, [rows, searchQuery]); + + // Calculate scroll offset to keep the selected item visible + const scrollOffset = useMemo(() => { + if (safeIndex < maxVisibleSessions) return 0; + return safeIndex - maxVisibleSessions + 1; + }, [safeIndex, maxVisibleSessions]); + + // Get the currently visible session list + const visibleSessions = useMemo(() => { + return filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); + }, [filteredSessions, scrollOffset, maxVisibleSessions]); + + // Handle backspace for search query + const handleBackspace = useCallback(() => { + setSearchQuery((prev) => prev.slice(0, -1)); + setIndex(0); + }, []); + + const selectedSession = filteredSessions[safeIndex]; + + useInput((input, key) => { + // If in rename mode, handle rename editing + if (renameSessionId) { + if (key.return) { + if (renameValue.trim()) { + onRename?.(renameSessionId, renameValue.trim()); + } + setRenameSessionId(null); + setRenameValue(""); + setRenameCursor(0); + return; + } + if (key.escape) { + setRenameSessionId(null); + setRenameValue(""); + setRenameCursor(0); + return; + } + if (key.leftArrow) { + setRenameCursor((c) => Math.max(0, c - 1)); + return; + } + if (key.rightArrow) { + setRenameCursor((c) => Math.min(renameValue.length, c + 1)); + return; + } + if (key.home) { + setRenameCursor(0); + return; + } + if (key.end) { + setRenameCursor(renameValue.length); + return; + } + if (key.delete) { + if (renameCursor < renameValue.length) { + setRenameValue((prev) => prev.slice(0, renameCursor) + prev.slice(renameCursor + 1)); + // cursor stays at same position (next char shifts left) + } + return; + } + if (key.backspace) { + if (renameCursor > 0) { + setRenameValue((prev) => prev.slice(0, renameCursor - 1) + prev.slice(renameCursor)); + setRenameCursor((c) => c - 1); + } + return; + } + // Printable character: insert at cursor position + if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab) { + if (key.upArrow || key.downArrow) { + return; + } + setRenameValue((prev) => prev.slice(0, renameCursor) + input + prev.slice(renameCursor)); + setRenameCursor((c) => c + input.length); + return; + } + return; + } + + // If in delete confirmation mode, handle confirm/cancel + if (confirmDeleteSessionId) { + if (key.return) { + onDelete?.(confirmDeleteSessionId); + setConfirmDeleteSessionId(null); + return; + } + if (key.escape) { + setConfirmDeleteSessionId(null); + return; + } + return; + } + + // ESC: clear search first, then cancel + if (key.escape) { + if (searchQuery) { + setSearchQuery(""); + setIndex(0); + return; + } + onCancel(); + return; + } + + // Ctrl+C also cancels + if (key.ctrl && (input === "c" || input === "C")) { + onCancel(); + return; + } + + // Ctrl+R: start rename on selected session + if (key.ctrl && (input === "r" || input === "R")) { + if (selectedSession && onRename) { + const name = selectedSession.summary || ""; + setRenameSessionId(selectedSession.id); + setRenameValue(name); + setRenameCursor(name.length); + return; + } + } + + // Delete key: remove search character, or start delete confirmation + if (key.delete || key.backspace) { + if (searchQuery) { + // remove last search character + handleBackspace(); + return; + } + // No search query: start delete confirmation if session is selected + if (selectedSession && onDelete) { + setConfirmDeleteSessionId(selectedSession.id); + return; + } + } + + // Printable character: append to search query + if (input && input.length > 0 && !key.meta && !key.ctrl && !key.tab && !key.return) { + // Ignore if it's a named key that happens to have input (safety check) + if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) { + return; + } + setSearchQuery((prev) => prev + input); + setIndex(0); + return; + } + + if (filteredSessions.length === 0) { + return; + } + + if (key.upArrow) { + setIndex((i) => Math.max(0, i - 1)); + return; + } + if (key.downArrow) { + setIndex((i) => Math.min(filteredSessions.length - 1, i + 1)); + return; + } + if (key.pageUp) { + setIndex((i) => Math.max(0, i - maxVisibleSessions)); + return; + } + if (key.pageDown) { + setIndex((i) => Math.min(filteredSessions.length - 1, i + maxVisibleSessions)); + return; + } + if (key.home) { + setIndex(0); + return; + } + if (key.end) { + setIndex(filteredSessions.length - 1); + return; + } + if (key.return) { + const session = filteredSessions[safeIndex]; + if (session) { + onSelect(session.id); + } + } + }); + + const hasActiveSearch = searchQuery.trim().length > 0; + + if (sessions.length === 0) { + return ( + + No previous sessions found. + Press Esc to go back. + + ); + } + + return ( + + + {/* Header row */} + + + + Resume a session + + + {" "} + ({sessions.length} total + {hasActiveSearch ? `, ${filteredSessions.length} matched` : ""}) + + + {/* Search bar */} + + {searchQuery ? `Search: ${searchQuery}` : "Type to search\u2026"} + {searchQuery ? | : null} + + + + {/* Session list */} + + {filteredSessions.length === 0 ? ( + + No sessions match "{searchQuery}". + + ) : ( + visibleSessions.map((session, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + const isConfirming = confirmDeleteSessionId === session.id; + const isRenaming = renameSessionId === session.id; + return ( + + + {isSelected ? "> " : " "} + + + + {isRenaming ? ( + + Rename: {renameValue.slice(0, renameCursor)} + | + {renameValue.slice(renameCursor)} + + ) : ( + + {formatSessionTitle(session.summary || "Untitled")} + + )} + {isConfirming ? ( + [Delete? Enter=yes, Esc=no] + ) : isRenaming ? null : ( + ({formatSessionStatus(session.status)}) + )} + + + {formatTimestamp(session.updateTime)} + + + + ); + }) + )} + {scrollOffset > 0 || scrollOffset + maxVisibleSessions < filteredSessions.length ? ( + + {scrollOffset > 0 ? … {scrollOffset} sessions above. : null} + {scrollOffset + maxVisibleSessions < filteredSessions.length ? ( + … {filteredSessions.length - scrollOffset - maxVisibleSessions} sessions below. + ) : null} + + ) : null} + + {/* Footer */} + + {renameSessionId ? ( + + Input new session name, + + Enter + + to save · + + Esc + + to cancel + + ) : confirmDeleteSessionId ? ( + + Delete this session? + + Enter + + to confirm · + + Esc + + to cancel + + ) : hasActiveSearch ? ( + + Esc clear search · + ↑/↓ navigate · Enter select · Esc again to cancel + + ) : ( + + + Type to search · ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel · Del delete · Ctrl+r rename + + + )} + + + + ); +} + +function formatTimestamp(value: string): string { + try { + const date = new Date(value); + if (Number.isNaN(date.valueOf())) { + return value; + } + return date.toLocaleString(); + } catch { + return value; + } +} + +export function formatSessionTitle(value: string, max = 70): string { + return truncate(value.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim(), max); +} + +export function formatSessionStatus(status: SessionStatus): string { + switch (status) { + case "completed": + return "done"; + case "processing": + return "running"; + case "pending": + return "pending"; + case "waiting_for_user": + return "waiting"; + case "failed": + return "failed"; + case "interrupted": + return "stopped"; + case "ask_permission": + return "waiting"; + case "permission_denied": + return "denied"; + default: + return status; + } +} diff --git a/src/ui/SlashCommandMenu.tsx b/packages/cli/src/ui/views/SlashCommandMenu.tsx similarity index 79% rename from src/ui/SlashCommandMenu.tsx rename to packages/cli/src/ui/views/SlashCommandMenu.tsx index 436be835..c138bec8 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/packages/cli/src/ui/views/SlashCommandMenu.tsx @@ -1,7 +1,9 @@ -import { formatSlashCommandDescription, formatSlashCommandLabel } from "./slashCommands"; -import type { SlashCommandItem } from "./slashCommands"; +import { formatSlashCommandDescription, formatSlashCommandLabel } from "../core/slash-commands"; +import type { SlashCommandItem } from "../core/slash-commands"; +import { ARGS_SEPARATOR } from "../constants"; import React from "react"; import { Box, Text } from "ink"; +import type { SkillInfo } from "@vegamo/deepcode-core"; type SlashCommandMenuProps = { items: SlashCommandItem[]; @@ -9,7 +11,9 @@ type SlashCommandMenuProps = { width: number; maxVisible?: number; }; - +export function isSkillSelected(skills: SkillInfo[], skill: SkillInfo): boolean { + return skills.some((item) => item.name === skill.name); +} const SlashCommandMenu = React.memo(function SlashCommandMenu({ items, activeIndex, @@ -21,7 +25,9 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ if (items.length === 0) { return 0; } - const longestLabel = Math.max(...items.map((s) => s.label.length)); + const longestLabel = Math.max( + ...items.map((s) => s.label.length + (s.args ? s.args?.join(ARGS_SEPARATOR)?.length + 4 : 0)) + ); const contentWidth = longestLabel + 2; // +2 for prefix "> " or " " const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 return Math.min(contentWidth, maxAllowed); @@ -49,11 +55,12 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ const actualIndex = visibleStart + idx; return ( - + {actualIndex === activeIndex ? "> " : " "} {formatSlashCommandLabel(item)} + {item.args ? {item.args.join(ARGS_SEPARATOR)} : null} diff --git a/src/ui/ThemedGradient.tsx b/packages/cli/src/ui/views/ThemedGradient.tsx similarity index 100% rename from src/ui/ThemedGradient.tsx rename to packages/cli/src/ui/views/ThemedGradient.tsx diff --git a/packages/cli/src/ui/views/UndoSelector.tsx b/packages/cli/src/ui/views/UndoSelector.tsx new file mode 100644 index 00000000..50a99977 --- /dev/null +++ b/packages/cli/src/ui/views/UndoSelector.tsx @@ -0,0 +1,195 @@ +import React, { useMemo, useState } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { UndoTarget } from "@vegamo/deepcode-core"; + +export type UndoRestoreMode = "code-and-conversation" | "conversation"; + +type Props = { + targets: UndoTarget[]; + onSelect: (target: UndoTarget, mode: UndoRestoreMode) => void; + onCancel: () => void; +}; + +type Phase = "message" | "mode"; + +const MAX_VISIBLE_TARGETS = 7; + +export function UndoSelector({ targets, onSelect, onCancel }: Props): React.ReactElement { + const [phase, setPhase] = useState("message"); + const [targetIndex, setTargetIndex] = useState(Math.max(0, targets.length - 1)); + const [modeIndex, setModeIndex] = useState(0); + const { columns, rows } = useWindowSize(); + + const safeTargetIndex = useMemo(() => { + if (targets.length === 0) { + return 0; + } + return Math.max(0, Math.min(targetIndex, targets.length - 1)); + }, [targetIndex, targets.length]); + + const selectedTarget = targets[safeTargetIndex] ?? null; + const maxVisible = Math.max(1, Math.min(MAX_VISIBLE_TARGETS, rows - 8)); + const scrollOffset = Math.max(0, Math.min(safeTargetIndex - Math.floor(maxVisible / 2), targets.length - maxVisible)); + const visibleTargets = targets.slice(scrollOffset, scrollOffset + maxVisible); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + if (phase === "mode") { + setPhase("message"); + return; + } + onCancel(); + return; + } + + if (targets.length === 0) { + return; + } + + if (phase === "message") { + if (key.upArrow) { + setTargetIndex((index) => Math.max(0, index - 1)); + return; + } + if (key.downArrow) { + setTargetIndex((index) => Math.min(targets.length - 1, index + 1)); + return; + } + if (key.home) { + setTargetIndex(0); + return; + } + if (key.end) { + setTargetIndex(targets.length - 1); + return; + } + if (key.return) { + setModeIndex(selectedTarget?.canRestoreCode ? 0 : 1); + setPhase("mode"); + } + return; + } + + if (key.upArrow || key.downArrow) { + setModeIndex((index) => (index === 0 ? 1 : 0)); + return; + } + if (key.return && selectedTarget) { + onSelect(selectedTarget, modeIndex === 0 ? "code-and-conversation" : "conversation"); + } + }); + + if (targets.length === 0) { + return ( + + Nothing to undo yet. + Press Esc to go back. + + ); + } + + return ( + + + + + Undo + + restore to the point before a prompt + + {phase === "message" ? ( + + {visibleTargets.map((target, visibleIndex) => { + const actualIndex = scrollOffset + visibleIndex; + const isActive = actualIndex === safeTargetIndex; + return ( + + {isActive ? "> " : " "} + + + {formatUndoMessage(target.message.content)} + + + {formatTimestamp(target.message.createTime)} + {target.canRestoreCode ? " · code checkpoint available" : " · conversation only"} + + + + ); + })} + + ) : ( + + Selected prompt: + {formatUndoMessage(selectedTarget?.message.content ?? "")} + + + {modeIndex === 0 ? "> " : " "}Restore code and conversation + + + {" "} + {selectedTarget?.canRestoreCode + ? "Restore files from the recorded Git checkpoint, then fork the conversation." + : "No code checkpoint is recorded for this prompt."} + + + {modeIndex === 1 ? "> " : " "}Restore conversation + + {" "}Fork the conversation without changing files. + + + )} + + + {phase === "message" + ? "↑/↓ navigate · Enter choose · Esc cancel" + : "↑/↓ choose restore mode · Enter restore · Esc back"} + + + + + ); +} + +function formatUndoMessage(content: unknown): string { + const text = typeof content === "string" && content.trim() ? content.trim() : "(empty message)"; + const singleLine = text.replace(/\r?\n/g, " ").replace(/\s+/g, " "); + return singleLine.length > 90 ? `${singleLine.slice(0, 89)}…` : singleLine; +} + +function formatTimestamp(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.valueOf())) { + return value; + } + return date.toLocaleString(); +} diff --git a/src/ui/UpdatePrompt.tsx b/packages/cli/src/ui/views/UpdatePrompt.tsx similarity index 100% rename from src/ui/UpdatePrompt.tsx rename to packages/cli/src/ui/views/UpdatePrompt.tsx diff --git a/src/ui/WelcomeScreen.tsx b/packages/cli/src/ui/views/WelcomeScreen.tsx similarity index 87% rename from src/ui/WelcomeScreen.tsx rename to packages/cli/src/ui/views/WelcomeScreen.tsx index 3d82eed0..e465a2f6 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/packages/cli/src/ui/views/WelcomeScreen.tsx @@ -2,17 +2,17 @@ import React, { useMemo, useState } from "react"; import { Box, Text } from "ink"; import * as os from "node:os"; import path from "node:path"; -import type { SkillInfo } from "../session"; -import type { ResolvedDeepcodingSettings } from "../settings"; -import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription } from "./slashCommands"; +import type { SkillInfo } from "@vegamo/deepcode-core"; +import type { ResolvedDeepcodingSettings } from "@vegamo/deepcode-core"; +import { buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription } from "../core/slash-commands"; import { ThemedGradient } from "./ThemedGradient"; -import { AsciiLogo } from "../AsciiArt"; +import { AsciiLogo } from "../ascii-art"; +import { useAppContext } from "../contexts"; type WelcomeScreenProps = { projectRoot: string; settings: ResolvedDeepcodingSettings; skills: SkillInfo[]; - version: string; width: number; }; @@ -23,18 +23,14 @@ const SHORTCUT_TIPS = [ { label: "Enter", description: "Send the prompt" }, { label: "Shift+Enter", description: "Insert a newline" }, { label: "Ctrl+V", description: "Paste an image from the clipboard" }, + { label: "Ctrl+R", description: "Open raw display mode selection" }, { label: "Esc", description: "Interrupt the current model turn" }, { label: "/", description: "Open the skills and commands menu" }, { label: "Ctrl+D twice", description: "Quit Deep Code CLI" }, ]; -export function WelcomeScreen({ - projectRoot, - settings, - skills, - version, - width, -}: WelcomeScreenProps): React.ReactElement { +export function WelcomeScreen({ projectRoot, settings, skills, width }: WelcomeScreenProps): React.ReactElement { + const { version } = useAppContext(); const tips = useMemo(() => buildWelcomeTips(skills), [skills]); const [tipIndex] = useState(() => randomTipIndex(tips.length)); const compact = width < TITLE_PANEL_WIDTH + 42; @@ -62,7 +58,9 @@ export function WelcomeScreen({ paddingX={1} > - {">"}_ Deep Code + + {">"}_ Deep Code{" "} + (v{version || "unknown"}) {!compact ? : null} diff --git a/packages/cli/src/utils/package.ts b/packages/cli/src/utils/package.ts new file mode 100644 index 00000000..3c1401af --- /dev/null +++ b/packages/cli/src/utils/package.ts @@ -0,0 +1,25 @@ +import { readPackageUp, type PackageJson as BasePackageJson } from "read-package-up"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { CLI_VERSION } from "../generated/git-commit"; + +export type PackageJson = BasePackageJson; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let packageJson: PackageJson; + +export async function getPackageJson(): Promise { + if (packageJson) { + return packageJson; + } + + const result = await readPackageUp({ cwd: __dirname }); + if (!result) { + return { name: "@vegamo/deepcode-cli", version: CLI_VERSION ?? "" }; + } + + packageJson = result.packageJson; + return packageJson; +} diff --git a/packages/cli/src/utils/stdio-helpers.ts b/packages/cli/src/utils/stdio-helpers.ts new file mode 100644 index 00000000..3f117267 --- /dev/null +++ b/packages/cli/src/utils/stdio-helpers.ts @@ -0,0 +1,33 @@ +/** + * Writes a message to stdout exactly as provided. + * Use for terminal control sequences or output that manages its own spacing. + */ +export const writeStdout = (message: string): void => { + process.stdout.write(message); +}; + +/** + * Writes a message to stdout with a trailing newline. + * Use for normal command output that the user expects to see. + * Avoids double newlines if the message already ends with one. + */ +export const writeStdoutLine = (message: string): void => { + process.stdout.write(message.endsWith("\n") ? message : `${message}\n`); +}; + +/** + * Writes a message to stderr with a trailing newline. + * Use for error messages in CLI commands. + * Avoids double newlines if the message already ends with one. + */ +export const writeStderrLine = (message: string): void => { + process.stderr.write(message.endsWith("\n") ? message : `${message}\n`); +}; + +/** + * Clears the terminal screen. + * Use instead of console.clear() to satisfy no-console lint rules. + */ +export const clearScreen = (): void => { + console.clear(); +}; diff --git a/packages/cli/src/utils/version.ts b/packages/cli/src/utils/version.ts new file mode 100644 index 00000000..f41a5c1f --- /dev/null +++ b/packages/cli/src/utils/version.ts @@ -0,0 +1,6 @@ +import { getPackageJson } from "./package.js"; + +export async function getCliVersion(): Promise { + const pkgJson = await getPackageJson(); + return process.env["CLI_VERSION"] || pkgJson?.version || "unknown"; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..44d2799d --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "ignoreDeprecations": "6.0", + "lib": ["ES2022"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "types": ["node"], + "baseUrl": ".", + "paths": { + "@vegamo/deepcode-core": ["../core/src/index.ts"], + "@vegamo/deepcode-core/*": ["../core/src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "../core/src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 00000000..bac0f126 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,39 @@ +{ + "name": "@vegamo/deepcode-core", + "version": "0.1.33", + "description": "Deep Code core library — LLM session management, tool execution, and shared utilities", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "repository": { + "type": "git", + "url": "git+https://github.com/lessweb/deepcode-cli.git" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist/**", + "templates/**" + ], + "scripts": { + "typecheck": "tsc -p ./ --noEmit", + "build": "tsc -p ./", + "prepublishOnly": "npm run build", + "format": "prettier --write .", + "test": "node src/tests/run-tests.mjs" + }, + "dependencies": { + "chalk": "^5.6.2", + "ejs": "^5.0.2", + "gray-matter": "^4.0.3", + "ignore": "^7.0.5", + "openai": "^6.35.0", + "undici": "^7.25.0", + "zod": "^4.4.3" + } +} diff --git a/packages/core/src/common/bash-timeout.ts b/packages/core/src/common/bash-timeout.ts new file mode 100644 index 00000000..0a76d21a --- /dev/null +++ b/packages/core/src/common/bash-timeout.ts @@ -0,0 +1,12 @@ +export const DEFAULT_BASH_TIMEOUT_MS = 10 * 60 * 1000; +export const MIN_BASH_TIMEOUT_MS = 60 * 1000; +export const BASH_TIMEOUT_INCREMENT_MS = 5 * 60 * 1000; +export const BASH_TIMEOUT_DECREMENT_MS = 60 * 1000; + +export function clampBashTimeoutMs(timeoutMs: number, minTimeoutMs: number = MIN_BASH_TIMEOUT_MS): number { + if (!Number.isFinite(timeoutMs)) { + return DEFAULT_BASH_TIMEOUT_MS; + } + const minimum = Number.isFinite(minTimeoutMs) ? Math.max(1, Math.round(minTimeoutMs)) : MIN_BASH_TIMEOUT_MS; + return Math.max(minimum, Math.round(timeoutMs)); +} diff --git a/src/common/debug-logger.ts b/packages/core/src/common/debug-logger.ts similarity index 100% rename from src/common/debug-logger.ts rename to packages/core/src/common/debug-logger.ts diff --git a/src/common/error-logger.ts b/packages/core/src/common/error-logger.ts similarity index 100% rename from src/common/error-logger.ts rename to packages/core/src/common/error-logger.ts diff --git a/packages/core/src/common/file-history.ts b/packages/core/src/common/file-history.ts new file mode 100644 index 00000000..c137fe6f --- /dev/null +++ b/packages/core/src/common/file-history.ts @@ -0,0 +1,398 @@ +import * as childProcess from "child_process"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; + +const FILE_HISTORY_AUTHOR_NAME = "DeepCode Checkpoint"; +const FILE_HISTORY_AUTHOR_EMAIL = "deepcode-checkpoint@localhost"; +const MANIFEST_PATH = ".deepcode-file-history.json"; + +type FileHistoryEntry = { + path: string; + blob: string | null; + mode: "100644"; +}; + +type FileHistoryManifest = { + version: 1 | 2; + files: Record; +}; + +export type FileHistoryCheckpointResult = { + checkpointHash: string | undefined; + changedFilePaths: string[]; +}; + +export class GitFileHistory { + constructor( + _projectRoot: string, + private readonly gitDir: string + ) {} + + ensureSession(sessionId: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef) { + return undefined; + } + + try { + if (!fs.existsSync(this.gitDir)) { + fs.mkdirSync(path.dirname(this.gitDir), { recursive: true }); + this.runGit(["init"]); + } + + const current = this.getCurrentCheckpointHash(sessionId); + if (current) { + return current; + } + + const treeHash = this.createTree(emptyManifest()); + const commitHash = this.createCommit(treeHash, null, "Initial checkpoint"); + this.runGit(["update-ref", branchRef, commitHash]); + return commitHash; + } catch { + return undefined; + } + } + + getCurrentCheckpointHash(sessionId: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(this.gitDir)) { + return undefined; + } + + try { + const hash = this.runGit(["rev-parse", "--verify", `${branchRef}^{commit}`]).trim(); + return isCommitHash(hash) ? hash : undefined; + } catch { + return undefined; + } + } + + recordCheckpoint(sessionId: string, filePaths: string[], message: string): string | undefined { + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef) { + return undefined; + } + + const absolutePaths = uniqueAbsolutePaths(filePaths); + if (absolutePaths.length === 0) { + return this.getCurrentCheckpointHash(sessionId); + } + + try { + const parentHash = this.ensureSession(sessionId); + if (!parentHash) { + return undefined; + } + + const manifest = this.readManifest(parentHash); + for (const filePath of absolutePaths) { + const key = this.getFileKey(filePath); + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + manifest.files[key] = { + path: filePath, + blob: null, + mode: "100644", + }; + continue; + } + + manifest.files[key] = { + path: filePath, + blob: this.hashFile(filePath), + mode: "100644", + }; + } + + const treeHash = this.createTree(manifest); + const parentTreeHash = this.runGit(["rev-parse", `${parentHash}^{tree}`]).trim(); + if (treeHash === parentTreeHash) { + return parentHash; + } + + const commitHash = this.createCommit(treeHash, parentHash, message); + this.runGit(["update-ref", branchRef, commitHash, parentHash]); + return commitHash; + } catch { + return undefined; + } + } + + recordTrackedFilesCheckpoint(sessionId: string, message: string): FileHistoryCheckpointResult { + const currentHash = this.ensureSession(sessionId); + if (!currentHash) { + return { checkpointHash: undefined, changedFilePaths: [] }; + } + + try { + const manifest = this.readManifest(currentHash); + const trackedPaths = Object.values(manifest.files) + .map((entry) => entry.path) + .sort((left, right) => left.localeCompare(right)); + if (trackedPaths.length === 0) { + return { checkpointHash: currentHash, changedFilePaths: [] }; + } + const nextHash = this.recordCheckpoint(sessionId, trackedPaths, message); + if (!nextHash) { + return { checkpointHash: undefined, changedFilePaths: [] }; + } + + const nextManifest = this.readManifest(nextHash); + const changedFilePaths = Object.entries(manifest.files) + .filter(([key, entry]) => !isSameFileHistoryEntry(entry, nextManifest.files[key])) + .map(([key, entry]) => nextManifest.files[key]?.path ?? entry.path) + .sort((left, right) => left.localeCompare(right)); + return { checkpointHash: nextHash, changedFilePaths }; + } catch { + return { checkpointHash: undefined, changedFilePaths: [] }; + } + } + + canRestore(sessionId: string, checkpointHash: string): boolean { + if (!isCommitHash(checkpointHash)) { + return false; + } + if (!this.getSessionBranchRef(sessionId)) { + return false; + } + if (!fs.existsSync(this.gitDir)) { + return false; + } + + try { + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]); + this.readManifest(checkpointHash); + return true; + } catch { + return false; + } + } + + restore(sessionId: string, checkpointHash: string): void { + if (!isCommitHash(checkpointHash)) { + throw new Error("Invalid checkpoint hash."); + } + const branchRef = this.getSessionBranchRef(sessionId); + if (!branchRef || !fs.existsSync(this.gitDir)) { + throw new Error("File history Git repository was not found for this project."); + } + this.runGit(["cat-file", "-e", `${checkpointHash}^{commit}`]); + + const currentHash = this.getCurrentCheckpointHash(sessionId); + const currentManifest = currentHash ? this.readManifest(currentHash) : emptyManifest(); + const targetManifest = this.readManifest(checkpointHash); + + for (const [key, entry] of Object.entries(currentManifest.files)) { + if (!targetManifest.files[key]) { + this.restoreFirstKnownEntry(currentHash, key, entry.path); + } + } + + for (const entry of Object.values(targetManifest.files)) { + if (!entry.blob) { + removeTrackedFile(entry.path); + continue; + } + fs.mkdirSync(path.dirname(entry.path), { recursive: true }); + fs.writeFileSync(entry.path, this.readBlob(entry.blob)); + } + + this.runGit(["update-ref", branchRef, checkpointHash]); + } + + private restoreFirstKnownEntry(currentHash: string | undefined, key: string, fallbackPath: string): void { + const firstEntry = currentHash ? this.findFirstKnownEntry(currentHash, key) : undefined; + const entry = firstEntry ?? { path: fallbackPath, blob: null, mode: "100644" as const }; + if (!entry.blob) { + removeTrackedFile(entry.path); + return; + } + + fs.mkdirSync(path.dirname(entry.path), { recursive: true }); + fs.writeFileSync(entry.path, this.readBlob(entry.blob)); + } + + private findFirstKnownEntry(currentHash: string, key: string): FileHistoryEntry | undefined { + const commitHashes = this.runGit(["rev-list", "--reverse", currentHash]) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(isCommitHash); + + for (const commitHash of commitHashes) { + const entry = this.readManifest(commitHash).files[key]; + if (entry) { + return entry; + } + } + return undefined; + } + + private getSessionBranchRef(sessionId: string): string | null { + if (!/^[A-Za-z0-9._-]+$/.test(sessionId)) { + return null; + } + return `refs/heads/${sessionId}`; + } + + private createCommit(treeHash: string, parentHash: string | null, message: string): string { + const args = ["commit-tree", treeHash]; + if (parentHash) { + args.push("-p", parentHash); + } + args.push("-m", message); + return this.runGit(args, { + env: getFileHistoryGitEnv(), + }).trim(); + } + + private createTree(manifest: FileHistoryManifest): string { + const normalizedManifest = normalizeManifest(manifest); + const manifestBlob = this.hashContent(`${JSON.stringify(normalizedManifest, null, 2)}\n`); + const entries: string[] = [`100644 blob ${manifestBlob}\t${MANIFEST_PATH}\0`]; + + for (const [key, entry] of Object.entries(normalizedManifest.files)) { + if (!entry.blob) { + continue; + } + entries.push(`${entry.mode} blob ${entry.blob}\t${key}\0`); + } + + return this.runGit(["mktree", "-z"], { input: entries.join("") }).trim(); + } + + private readManifest(commitHash: string): FileHistoryManifest { + const buffer = this.runGitBuffer(["cat-file", "blob", `${commitHash}:${MANIFEST_PATH}`]); + const parsed = JSON.parse(buffer.toString("utf8")) as FileHistoryManifest; + if ( + !parsed || + (parsed.version !== 1 && parsed.version !== 2) || + !parsed.files || + typeof parsed.files !== "object" + ) { + throw new Error("Invalid file history manifest."); + } + return normalizeManifest(parsed); + } + + private readBlob(blobHash: string): Buffer { + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); + } + return this.runGitBuffer(["cat-file", "blob", blobHash]); + } + + private hashFile(filePath: string): string { + const blobHash = this.runGit(["hash-object", "-w", "--", filePath]).trim(); + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); + } + return blobHash; + } + + private hashContent(content: string): string { + const blobHash = this.runGit(["hash-object", "-w", "--stdin"], { input: content }).trim(); + if (!isCommitHash(blobHash)) { + throw new Error("Invalid file history blob hash."); + } + return blobHash; + } + + private getFileKey(filePath: string): string { + const hash = crypto.createHash("sha256").update(filePath).digest("hex"); + return `files-${hash}`; + } + + private runGit(args: string[], options: { input?: string | Buffer; env?: NodeJS.ProcessEnv } = {}): string { + return this.spawnGit(args, options, "utf8") as string; + } + + private runGitBuffer(args: string[], options: { input?: string | Buffer; env?: NodeJS.ProcessEnv } = {}): Buffer { + return this.spawnGit(args, options, "buffer") as Buffer; + } + + private spawnGit( + args: string[], + options: { input?: string | Buffer; env?: NodeJS.ProcessEnv }, + encoding: BufferEncoding | "buffer" + ): string | Buffer { + const gitArgs = ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${this.gitDir}`, ...args]; + const result = childProcess.spawnSync("git", gitArgs, { + encoding, + input: options.input, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + }); + if (result.status !== 0) { + const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8") : result.stderr; + const stdout = Buffer.isBuffer(result.stdout) ? result.stdout.toString("utf8") : result.stdout; + const detail = (stderr || stdout || "").trim(); + throw new Error(detail || `git ${args.join(" ")} failed`); + } + return result.stdout ?? (encoding === "buffer" ? Buffer.alloc(0) : ""); + } +} + +function emptyManifest(): FileHistoryManifest { + return { version: 2, files: {} }; +} + +function normalizeManifest(manifest: FileHistoryManifest): FileHistoryManifest { + const files: Record = {}; + for (const [key, entry] of Object.entries(manifest.files).sort(([left], [right]) => left.localeCompare(right))) { + if ( + !isValidStoredPath(key) || + !entry || + entry.mode !== "100644" || + (entry.blob !== null && !isCommitHash(entry.blob)) + ) { + throw new Error("Invalid file history manifest."); + } + files[key] = { + path: path.resolve(entry.path), + blob: entry.blob, + mode: "100644", + }; + } + return { version: 2, files }; +} + +function isSameFileHistoryEntry(left: FileHistoryEntry, right: FileHistoryEntry | undefined): boolean { + if (!right) { + return false; + } + return left.path === right.path && left.blob === right.blob && left.mode === right.mode; +} + +function uniqueAbsolutePaths(filePaths: string[]): string[] { + return Array.from(new Set(filePaths.map((filePath) => path.resolve(filePath)))); +} + +function isValidStoredPath(value: string): boolean { + return /^files-[0-9a-f]{64}$/.test(value); +} + +function removeTrackedFile(filePath: string): void { + if (!fs.existsSync(filePath)) { + return; + } + const stat = fs.lstatSync(filePath); + if (stat.isDirectory()) { + return; + } + fs.unlinkSync(filePath); +} + +function getFileHistoryGitEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || FILE_HISTORY_AUTHOR_NAME, + GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, + GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || FILE_HISTORY_AUTHOR_NAME, + GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || FILE_HISTORY_AUTHOR_EMAIL, + }; +} + +function isCommitHash(value: string): boolean { + return /^[0-9a-f]{40}$/i.test(value); +} diff --git a/src/common/file-utils.ts b/packages/core/src/common/file-utils.ts similarity index 100% rename from src/common/file-utils.ts rename to packages/core/src/common/file-utils.ts diff --git a/src/common/model-capabilities.ts b/packages/core/src/common/model-capabilities.ts similarity index 100% rename from src/common/model-capabilities.ts rename to packages/core/src/common/model-capabilities.ts diff --git a/src/common/notify.ts b/packages/core/src/common/notify.ts similarity index 70% rename from src/common/notify.ts rename to packages/core/src/common/notify.ts index 8878c508..d1b541b5 100644 --- a/src/common/notify.ts +++ b/packages/core/src/common/notify.ts @@ -16,11 +16,40 @@ export function formatDurationSeconds(durationMs: number): string { return String(Math.floor(safeMs / 1000)); } -export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return { +export type NotifyContext = { + status?: string; + failReason?: string; + body?: string; + title?: string; +}; + +export function buildNotifyEnv( + durationMs: number, + baseEnv: NodeJS.ProcessEnv = process.env, + context: NotifyContext = {} +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...baseEnv, DURATION: formatDurationSeconds(durationMs), }; + delete env.STATUS; + delete env.FAIL_REASON; + delete env.BODY; + delete env.TITLE; + + if (context.status) { + env.STATUS = context.status; + } + if (context.failReason) { + env.FAIL_REASON = context.failReason; + } + if (context.body) { + env.BODY = context.body; + } + if (context.title) { + env.TITLE = context.title; + } + return env; } export function launchNotifyScript( @@ -28,7 +57,8 @@ export function launchNotifyScript( durationMs: number, workingDirectory?: string, spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn, - configuredEnv: Record = {} + configuredEnv: Record = {}, + context: NotifyContext = {} ): void { const commandPath = notifyPath?.trim(); if (!commandPath) { @@ -38,7 +68,7 @@ export function launchNotifyScript( const options = { cwd: workingDirectory, detached: process.platform !== "win32", - env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }), + env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context), stdio: "ignore" as const, }; diff --git a/packages/core/src/common/openai-client.ts b/packages/core/src/common/openai-client.ts new file mode 100644 index 00000000..d3b56c08 --- /dev/null +++ b/packages/core/src/common/openai-client.ts @@ -0,0 +1,125 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import OpenAI from "openai"; +import { Agent, fetch as undiciFetch } from "undici"; +import { resolveCurrentSettings } from "../settings"; + +// Custom undici Agent with a 180-second keepAlive timeout. The default +// global fetch (undici) only keeps connections alive for 4 seconds, which +// is too short for a CLI where the user may spend 10–30 seconds reading +// output between prompts. By passing a dedicated Agent to undiciFetch we +// keep connections reusable for three minutes after the last request. +const keepAliveAgent = new Agent({ keepAliveTimeout: 180_000 }); + +// Module-level cache for the OpenAI client instance. The client itself is +// a stateless fetch wrapper, so it is safe to share across calls as long as +// the apiKey + baseURL stay the same. Model, thinking-mode and other +// settings are always read fresh from the project / user config files. +let cachedOpenAI: OpenAI | null = null; +let cachedOpenAIKey = ""; + +export function createOpenAIClient(projectRoot: string = process.cwd()): { + client: OpenAI | null; + model: string; + baseURL: string; + temperature?: number; + thinkingEnabled: boolean; + reasoningEffort: "high" | "max"; + debugLogEnabled: boolean; + telemetryEnabled: boolean; + notify?: string; + webSearchTool?: string; + env: Record; + machineId?: string; +} { + const settings = resolveCurrentSettings(projectRoot); + if (!settings.apiKey) { + return { + client: null, + model: settings.model, + baseURL: settings.baseURL, + temperature: settings.temperature, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + telemetryEnabled: settings.telemetryEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; + } + + const cacheKey = `${settings.apiKey}::${settings.baseURL}`; + if (cachedOpenAI && cachedOpenAIKey === cacheKey) { + return { + client: cachedOpenAI, + model: settings.model, + baseURL: settings.baseURL, + temperature: settings.temperature, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + telemetryEnabled: settings.telemetryEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; + } + + cachedOpenAI = new OpenAI({ + apiKey: settings.apiKey, + baseURL: settings.baseURL || undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fetch: (url: any, init: any) => undiciFetch(url, { ...init, dispatcher: keepAliveAgent }), + }); + cachedOpenAIKey = cacheKey; + + // Fire-and-forget warmup: pre-establish TCP+TLS connection to the API + // server while the user is composing their first prompt. Bounded by a + // short timeout so a slow / unreachable API never blocks process exit. + void (async () => { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + try { + await cachedOpenAI.models.list({ signal: ac.signal }).catch(() => {}); + } finally { + clearTimeout(timer); + } + })(); + + return { + client: cachedOpenAI, + model: settings.model, + baseURL: settings.baseURL, + temperature: settings.temperature, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + debugLogEnabled: settings.debugLogEnabled, + telemetryEnabled: settings.telemetryEnabled, + notify: settings.notify, + webSearchTool: settings.webSearchTool, + env: settings.env, + machineId: getMachineId(), + }; +} + +function getMachineId(): string | undefined { + try { + const idPath = path.join(os.homedir(), ".deepcode", "machine-id"); + if (fs.existsSync(idPath)) { + const raw = fs.readFileSync(idPath, "utf8").trim(); + if (raw) { + return raw; + } + } + const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`; + fs.mkdirSync(path.dirname(idPath), { recursive: true }); + fs.writeFileSync(idPath, generated, "utf8"); + return generated; + } catch { + return undefined; + } +} diff --git a/packages/core/src/common/openai-message-converter.ts b/packages/core/src/common/openai-message-converter.ts new file mode 100644 index 00000000..97999045 --- /dev/null +++ b/packages/core/src/common/openai-message-converter.ts @@ -0,0 +1,278 @@ +import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; +import { supportsMultimodal } from "./model-capabilities"; +import type { SessionMessage } from "../session"; + +export type OpenAIMessageConverterOptions = { + /** Optional callback to render the /init command prompt template. */ + renderInitPrompt?: () => string; +}; + +/** + * Converts internal SessionMessage arrays into OpenAI ChatCompletionMessageParam arrays. + * + * Handles: + * - Tool-call / tool-result pairing with interrupt backfill + * - Thinking-mode reasoning_content injection + * - Multimodal content (images) filtering by model capability + * - Compaction filtering + */ +export class OpenAIMessageConverter { + constructor(private readonly options: OpenAIMessageConverterOptions = {}) {} + + /** + * Build the OpenAI messages array from session messages, applying compaction + * filtering, tool pairing, and format conversion. + */ + buildMessages(messages: SessionMessage[], thinkingEnabled: boolean, model: string): ChatCompletionMessageParam[] { + const activeMessages = messages.filter((message) => !message.compacted); + const toolPairings = this.pairToolMessages(activeMessages); + const openAIMessages: ChatCompletionMessageParam[] = []; + + for (let index = 0; index < activeMessages.length; index += 1) { + const message = activeMessages[index]; + if (message.role === "tool") { + continue; + } + + openAIMessages.push(this.convertMessage(message, thinkingEnabled, model)); + + const toolCalls = this.getAssistantToolCalls(message); + if (toolCalls.length === 0) { + continue; + } + + for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { + const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); + if (!toolCallId) { + continue; + } + + const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex)); + if (pairedToolIndex != null) { + openAIMessages.push(this.convertMessage(activeMessages[pairedToolIndex], thinkingEnabled, model)); + continue; + } + + openAIMessages.push(this.buildInterruptedOpenAIToolMessage(toolCalls, toolCallId)); + } + } + + return openAIMessages; + } + + /** + * Returns the trailing assistant message with pending (unexecuted) tool calls, + * if one exists at the end of the conversation. + */ + getTrailingPendingToolCallMessage( + messages: SessionMessage[] + ): { message: SessionMessage; toolCalls: unknown[] } | { message: null; toolCalls: [] } { + const activeMessages = messages.filter((message) => !message.compacted); + const latestMessage = activeMessages[activeMessages.length - 1]; + if (!latestMessage || latestMessage.role !== "assistant") { + return { message: null, toolCalls: [] }; + } + + const toolCalls = this.getAssistantToolCalls(latestMessage); + if (toolCalls.length === 0) { + return { message: null, toolCalls: [] }; + } + return { + message: latestMessage, + toolCalls: toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))), + }; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private convertMessage(message: SessionMessage, thinkingEnabled: boolean, model: string): ChatCompletionMessageParam { + const content = this.renderContent(message); + const base: ChatCompletionMessageParam = { + role: message.role, + content, + } as ChatCompletionMessageParam; + + const messageParams = message.messageParams as + | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } + | null + | undefined; + if (messageParams?.tool_calls) { + (base as { tool_calls?: unknown[] }).tool_calls = messageParams.tool_calls; + } + if (messageParams?.tool_call_id) { + (base as { tool_call_id?: string }).tool_call_id = messageParams.tool_call_id; + } + if (typeof messageParams?.reasoning_content === "string") { + (base as { reasoning_content?: string }).reasoning_content = messageParams.reasoning_content; + } else if (thinkingEnabled && message.role === "assistant") { + // Thinking-mode providers require every replayed assistant message + // to include the reasoning_content field, even when it is empty. + (base as { reasoning_content?: string }).reasoning_content = ""; + } + + if ((message.role === "user" || message.role === "system") && message.contentParams) { + const contentParts: ChatCompletionContentPart[] = []; + if (content) { + contentParts.push({ type: "text", text: content }); + } + const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; + for (const param of params) { + const part = param as ChatCompletionContentPart; + if (part && (part.type !== "image_url" || supportsMultimodal(model))) { + contentParts.push(part); + } + } + const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content; + (base as { content: string | ChatCompletionContentPart[] }).content = contentValue; + } + + return base; + } + + private renderContent(message: SessionMessage): string { + if (message.role === "user" && message.content === "/init") { + return this.options.renderInitPrompt?.() ?? ""; + } + return message.content ?? ""; + } + + private pairToolMessages(messages: SessionMessage[]): Map { + const pairings = new Map(); + const usedToolMessageIndexes = new Set(); + + for (let assistantIndex = 0; assistantIndex < messages.length; assistantIndex += 1) { + const toolCalls = this.getAssistantToolCalls(messages[assistantIndex]); + for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { + const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); + if (!toolCallId) { + continue; + } + + const toolIndex = this.findPairableToolMessageIndex( + messages, + assistantIndex, + toolCallId, + usedToolMessageIndexes + ); + if (toolIndex == null) { + continue; + } + + usedToolMessageIndexes.add(toolIndex); + pairings.set(this.buildToolPairingKey(assistantIndex, toolCallIndex), toolIndex); + } + } + + return pairings; + } + + private findPairableToolMessageIndex( + messages: SessionMessage[], + assistantIndex: number, + toolCallId: string, + usedToolMessageIndexes: Set + ): number | null { + let firstMatchingIndex: number | null = null; + for (let index = assistantIndex + 1; index < messages.length; index += 1) { + const message = messages[index]; + if (message.role !== "tool" || usedToolMessageIndexes.has(index)) { + continue; + } + + const candidateToolCallId = this.getToolMessageCallId(message); + if (candidateToolCallId !== toolCallId) { + continue; + } + + if (firstMatchingIndex == null) { + firstMatchingIndex = index; + } + if (!this.isInterruptedToolMessage(message)) { + return index; + } + } + return firstMatchingIndex; + } + + private getAssistantToolCalls(message: SessionMessage): unknown[] { + if (message.role !== "assistant") { + return []; + } + const messageParams = message.messageParams as { tool_calls?: unknown[] } | null; + return Array.isArray(messageParams?.tool_calls) ? messageParams.tool_calls : []; + } + + private getToolCallId(toolCall: unknown): string | null { + if (!toolCall || typeof toolCall !== "object") { + return null; + } + const id = (toolCall as { id?: unknown }).id; + return typeof id === "string" && id ? id : null; + } + + private getToolMessageCallId(message: SessionMessage): string | null { + const messageParams = message.messageParams as { tool_call_id?: unknown } | null; + const toolCallId = messageParams?.tool_call_id; + return typeof toolCallId === "string" && toolCallId ? toolCallId : null; + } + + private buildToolPairingKey(assistantIndex: number, toolCallIndex: number): string { + return `${assistantIndex}:${toolCallIndex}`; + } + + private isInterruptedToolMessage(message: SessionMessage): boolean { + if (typeof message.content !== "string" || !message.content.trim()) { + return false; + } + try { + const parsed = JSON.parse(message.content) as { metadata?: { interrupted?: unknown } }; + return parsed.metadata?.interrupted === true; + } catch { + return false; + } + } + + private buildInterruptedOpenAIToolMessage(toolCalls: unknown[], toolCallId: string): ChatCompletionMessageParam { + const toolFunction = this.findToolFunction(toolCalls, toolCallId); + return { + role: "tool", + content: this.buildInterruptedToolResult(toolFunction, "Previous tool call did not complete."), + tool_call_id: toolCallId, + } as ChatCompletionMessageParam; + } + + /** Exposed for use by appendToolMessages in SessionManager. */ + findToolFunction(toolCalls: unknown[], toolCallId: string): unknown | null { + for (const toolCall of toolCalls) { + if (!toolCall || typeof toolCall !== "object") { + continue; + } + const record = toolCall as { id?: unknown; function?: unknown }; + if (record.id === toolCallId) { + return record.function ?? null; + } + } + return null; + } + + private buildInterruptedToolResult(toolFunction: unknown | null, reason: string): string { + const toolName = + toolFunction && typeof toolFunction === "object" && typeof (toolFunction as { name?: unknown }).name === "string" + ? (toolFunction as { name: string }).name + : "tool"; + return JSON.stringify( + { + ok: false, + name: toolName, + error: reason, + metadata: { + interrupted: true, + }, + }, + null, + 2 + ); + } +} diff --git a/src/common/openai-thinking.ts b/packages/core/src/common/openai-thinking.ts similarity index 100% rename from src/common/openai-thinking.ts rename to packages/core/src/common/openai-thinking.ts diff --git a/packages/core/src/common/permissions.ts b/packages/core/src/common/permissions.ts new file mode 100644 index 00000000..f50d485c --- /dev/null +++ b/packages/core/src/common/permissions.ts @@ -0,0 +1,556 @@ +import * as fs from "fs"; +import * as path from "path"; +import type { DeepcodingSettings, PermissionScope, PermissionSettings } from "../settings"; +import { isAbsoluteFilePath, normalizeFilePath } from "./state"; + +export type BashPermissionScope = Exclude | "unknown"; + +export type PermissionDecision = "allow" | "deny" | "ask"; + +export type UserToolPermission = { + toolCallId: string; + permission: "allow" | "deny"; +}; + +export type MessageToolPermission = { + toolCallId: string; + permission: PermissionDecision; +}; + +export type AskPermissionScope = PermissionScope | "unknown"; + +export type AskPermissionRequest = { + toolCallId: string; + scopes: AskPermissionScope[]; + name: string; + command: string; + description?: string; +}; + +export type PermissionToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type PermissionToolExecution = { + toolCallId: string; + content: string; + result: { + ok: boolean; + name: string; + output?: string; + error?: string; + metadata?: Record; + awaitUserResponse?: boolean; + followUpMessages?: Array<{ role: "system"; content: string; contentParams?: unknown | null }>; + }; +}; + +export type PermissionPlan = { + permissions: MessageToolPermission[]; + askPermissions: AskPermissionRequest[]; +}; + +export type ComputeToolCallPermissionsOptions = { + sessionId: string; + projectRoot: string; + toolCalls: unknown[]; + settings?: Required; + readPermissionExemptPaths?: string[]; + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined; +}; + +export function parseToolCallForPermissions(toolCall: unknown): PermissionToolCall | null { + if (!toolCall || typeof toolCall !== "object") { + return null; + } + const record = toolCall as { + id?: unknown; + type?: unknown; + function?: { name?: unknown; arguments?: unknown }; + }; + if (typeof record.id !== "string" || !record.function || typeof record.function !== "object") { + return null; + } + if (typeof record.function.name !== "string") { + return null; + } + return { + id: record.id, + type: "function", + function: { + name: record.function.name, + arguments: typeof record.function.arguments === "string" ? record.function.arguments : "", + }, + }; +} + +export function buildPermissionToolExecution( + toolCall: PermissionToolCall, + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } +): PermissionToolExecution | null { + const permission = resolveToolCallPermission(toolCall.id, options); + if (permission === "allow") { + return null; + } + if (permission === "deny") { + return buildSyntheticToolExecution( + toolCall, + "User denied the required permission for this tool call. Do not try to bypass this decision." + ); + } + return buildSyntheticToolExecution( + toolCall, + "The user has not authorized this tool call yet. Retry only if the permission is still necessary." + ); +} + +export function resolveToolCallPermission( + toolCallId: string, + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } +): PermissionDecision { + const override = options.permissionOverrides?.find((item) => item.toolCallId === toolCallId); + if (override?.permission === "allow" || override?.permission === "deny") { + return override.permission; + } + const messagePermission = options.messagePermissions?.find((item) => item.toolCallId === toolCallId); + if ( + messagePermission?.permission === "allow" || + messagePermission?.permission === "deny" || + messagePermission?.permission === "ask" + ) { + return messagePermission.permission; + } + return "allow"; +} + +export function buildSyntheticToolExecution(toolCall: PermissionToolCall, error: string): PermissionToolExecution { + const result = { + ok: false, + name: toolCall.function.name, + error, + }; + return { + toolCallId: toolCall.id, + content: JSON.stringify(result, null, 2), + result, + }; +} + +export function computeToolCallPermissions(options: ComputeToolCallPermissionsOptions): PermissionPlan { + const permissions: MessageToolPermission[] = []; + const askPermissions: AskPermissionRequest[] = []; + + for (const rawToolCall of options.toolCalls) { + const toolCall = parseToolCallForPermissions(rawToolCall); + if (!toolCall) { + continue; + } + const request = describeToolPermissionRequest({ + sessionId: options.sessionId, + projectRoot: options.projectRoot, + toolCall, + readPermissionExemptPaths: options.readPermissionExemptPaths, + resolveSnippetPath: options.resolveSnippetPath, + }); + const permission = evaluatePermissionScopes(request.scopes, options.settings); + permissions.push({ toolCallId: toolCall.id, permission }); + if (permission === "ask") { + const askScopes = getPermissionScopesRequiringAsk(request.scopes, options.settings); + askPermissions.push({ + toolCallId: toolCall.id, + scopes: askScopes.length > 0 ? askScopes : request.scopes, + name: request.name, + command: request.command, + description: request.description, + }); + } + } + + return { permissions, askPermissions }; +} + +export function describeToolPermissionRequest(options: { + sessionId: string; + projectRoot: string; + toolCall: PermissionToolCall; + readPermissionExemptPaths?: string[]; + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined; +}): AskPermissionRequest { + const name = options.toolCall.function.name; + const args = parseToolArgumentsForPermissions(options.toolCall.function.arguments); + + if (name === "read" || name === "Read") { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("read", filePath), + scopes: + filePath && !isPathInAnyDirectory(options.projectRoot, filePath, options.readPermissionExemptPaths) + ? [isPathInProject(options.projectRoot, filePath) ? "read-in-cwd" : "read-out-cwd"] + : [], + }; + } + + if (name === "write" || name === "Write") { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("write", filePath), + scopes: filePath ? [isPathInProject(options.projectRoot, filePath) ? "write-in-cwd" : "write-out-cwd"] : [], + }; + } + + if (name === "edit" || name === "Edit") { + const filePath = resolveEditPermissionPath(options.sessionId, args, options.resolveSnippetPath); + return { + toolCallId: options.toolCall.id, + name, + command: formatToolPathCommand("edit", filePath), + scopes: filePath + ? [isPathInProject(options.projectRoot, filePath) ? "write-in-cwd" : "write-out-cwd"] + : ["write-out-cwd"], + }; + } + + if (name === "bash" || name === "Bash") { + const command = typeof args.command === "string" ? args.command : "bash"; + const description = typeof args.description === "string" ? args.description : undefined; + return { + toolCallId: options.toolCall.id, + name: "bash", + command, + description, + scopes: parseBashSideEffects(args.sideEffects), + }; + } + + if (name === "WebSearch") { + const query = typeof args.query === "string" ? args.query : "WebSearch"; + return { + toolCallId: options.toolCall.id, + name, + command: query, + scopes: ["network"], + }; + } + + if (name.startsWith("mcp__")) { + return { + toolCallId: options.toolCall.id, + name, + command: name, + scopes: ["mcp"], + }; + } + + return { + toolCallId: options.toolCall.id, + name, + command: name, + scopes: [], + }; +} + +export function evaluatePermissionScopes( + scopes: AskPermissionScope[], + settings: Required = { + allow: [], + deny: [], + ask: [], + defaultMode: "allowAll", + } +): PermissionDecision { + if (scopes.includes("unknown") && settings.defaultMode !== "allowAll") { + return "ask"; + } + if (scopes.length === 0) { + return "allow"; + } + const permissionScopes = scopes.filter((scope): scope is PermissionScope => scope !== "unknown"); + if (permissionScopes.some((scope) => settings.deny.includes(scope))) { + return "deny"; + } + if (permissionScopes.some((scope) => settings.ask.includes(scope))) { + return "ask"; + } + if (permissionScopes.every((scope) => settings.allow.includes(scope))) { + return "allow"; + } + return settings.defaultMode === "askAll" ? "ask" : "allow"; +} + +export function getPermissionScopesRequiringAsk( + scopes: AskPermissionScope[], + settings: Required = { + allow: [], + deny: [], + ask: [], + defaultMode: "allowAll", + } +): AskPermissionScope[] { + const result: AskPermissionScope[] = []; + for (const scope of scopes) { + if (scope === "unknown") { + if (settings.defaultMode !== "allowAll") { + result.push(scope); + } + continue; + } + if (settings.deny.includes(scope)) { + continue; + } + if (settings.ask.includes(scope)) { + result.push(scope); + continue; + } + if (settings.allow.includes(scope)) { + continue; + } + if (settings.defaultMode === "askAll") { + result.push(scope); + } + } + return result; +} + +export function parseBashSideEffects(value: unknown): AskPermissionScope[] { + const validScopes = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown", + ]); + if (!Array.isArray(value)) { + return ["unknown"]; + } + const scopes: AskPermissionScope[] = []; + for (const item of value) { + if (typeof item !== "string" || !validScopes.has(item as AskPermissionScope)) { + return ["unknown"]; + } + const scope = item as AskPermissionScope; + if (!scopes.includes(scope)) { + scopes.push(scope); + } + } + if (scopes.includes("unknown")) { + return ["unknown"]; + } + return scopes; +} + +export function parseToolArgumentsForPermissions(rawArguments: string): Record { + if (!rawArguments) { + return {}; + } + try { + const parsed = JSON.parse(rawArguments); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +export function resolveEditPermissionPath( + sessionId: string, + args: Record, + resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined +): string { + const filePath = typeof args.file_path === "string" ? args.file_path : ""; + if (filePath) { + return filePath; + } + const snippetId = typeof args.snippet_id === "string" ? args.snippet_id : ""; + return snippetId ? (resolveSnippetPath?.(sessionId, snippetId) ?? "") : ""; +} + +export function formatToolPathCommand(toolName: string, filePath: string): string { + return filePath ? `${toolName} ${filePath}` : toolName; +} + +export function isPathInProject(projectRoot: string, filePath: string): boolean { + const normalized = normalizeFilePath(filePath); + const absolutePath = isAbsoluteFilePath(normalized) ? normalized : path.resolve(projectRoot, normalized); + const relative = path.relative(path.resolve(projectRoot), path.resolve(absolutePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function isPathInAnyDirectory( + projectRoot: string, + filePath: string, + directories: string[] | undefined +): boolean { + if (!directories?.length) { + return false; + } + + const normalized = normalizeFilePath(filePath); + const absolutePath = isAbsoluteFilePath(normalized) ? normalized : path.resolve(projectRoot, normalized); + for (const directory of directories) { + const normalizedDirectory = normalizeFilePath(directory); + const absoluteDirectory = isAbsoluteFilePath(normalizedDirectory) + ? normalizedDirectory + : path.resolve(projectRoot, normalizedDirectory); + const relative = path.relative(path.resolve(absoluteDirectory), path.resolve(absolutePath)); + if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) { + return true; + } + } + return false; +} + +export function hasUserPermissionReplies(value: { permissions?: unknown; alwaysAllows?: unknown }): boolean { + return Boolean( + (Array.isArray(value.permissions) && value.permissions.length > 0) || + (Array.isArray(value.alwaysAllows) && value.alwaysAllows.length > 0) + ); +} + +export function appendProjectPermissionAllows( + projectRoot: string, + scopes: PermissionScope[] | undefined, + options: { inheritedPermissions?: Required } = {} +): void { + if (!Array.isArray(scopes) || scopes.length === 0) { + return; + } + const validScopes = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", + ]); + const nextScopes = scopes.filter((scope) => validScopes.has(scope)); + if (nextScopes.length === 0) { + return; + } + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + let settings: DeepcodingSettings = {}; + try { + if (fs.existsSync(settingsPath)) { + const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + settings = parsed as DeepcodingSettings; + } + } + } catch { + settings = {}; + } + + const existingPermissions = settings.permissions; + const permissions: PermissionSettings = existingPermissions + ? { ...existingPermissions } + : options.inheritedPermissions + ? { + allow: [...options.inheritedPermissions.allow], + deny: [...options.inheritedPermissions.deny], + ask: [...options.inheritedPermissions.ask], + defaultMode: options.inheritedPermissions.defaultMode, + } + : {}; + + const currentAllow = Array.isArray(permissions.allow) ? permissions.allow : []; + const allow = [...currentAllow]; + for (const scope of nextScopes) { + if (!allow.includes(scope)) { + allow.push(scope); + } + } + const currentDeny = Array.isArray(permissions.deny) ? permissions.deny : undefined; + const currentAsk = Array.isArray(permissions.ask) ? permissions.ask : undefined; + const deny = currentDeny ? currentDeny.filter((scope) => !nextScopes.includes(scope)) : permissions.deny; + const ask = currentAsk ? currentAsk.filter((scope) => !nextScopes.includes(scope)) : permissions.ask; + const changed = + allow.length !== currentAllow.length || + (currentDeny ? (deny as PermissionScope[]).length !== currentDeny.length : false) || + (currentAsk ? (ask as PermissionScope[]).length !== currentAsk.length : false); + if (existingPermissions && !changed) { + return; + } + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + `${JSON.stringify( + { + ...settings, + permissions: { + ...permissions, + deny, + ask, + allow, + }, + }, + null, + 2 + )}\n`, + "utf8" + ); +} + +export function normalizeAskPermissions(value: unknown): AskPermissionRequest[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const result: AskPermissionRequest[] = []; + for (const item of value) { + if (!item || typeof item !== "object") { + continue; + } + const record = item as Record; + if (typeof record.toolCallId !== "string" || typeof record.name !== "string") { + continue; + } + const scopes = Array.isArray(record.scopes) + ? record.scopes.filter((scope): scope is AskPermissionScope => isAskPermissionScope(scope)) + : []; + result.push({ + toolCallId: record.toolCallId, + scopes, + name: record.name, + command: typeof record.command === "string" ? record.command : record.name, + description: typeof record.description === "string" ? record.description : undefined, + }); + } + return result.length > 0 ? result : undefined; +} + +export function isAskPermissionScope(value: unknown): value is AskPermissionScope { + return ( + value === "read-in-cwd" || + value === "read-out-cwd" || + value === "write-in-cwd" || + value === "write-out-cwd" || + value === "delete-in-cwd" || + value === "delete-out-cwd" || + value === "query-git-log" || + value === "mutate-git-log" || + value === "network" || + value === "mcp" || + value === "unknown" + ); +} diff --git a/src/common/process-tree.ts b/packages/core/src/common/process-tree.ts similarity index 100% rename from src/common/process-tree.ts rename to packages/core/src/common/process-tree.ts diff --git a/src/common/shell-utils.ts b/packages/core/src/common/shell-utils.ts similarity index 97% rename from src/common/shell-utils.ts rename to packages/core/src/common/shell-utils.ts index 1dcec25a..eb8a2da7 100644 --- a/src/common/shell-utils.ts +++ b/packages/core/src/common/shell-utils.ts @@ -83,9 +83,14 @@ export function getShellKind(shellPath: string): ShellKind { export function buildShellInitCommand(shellPath: string): string | null { switch (getShellKind(shellPath)) { case "zsh": - return ['ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi'].join("; "); + return ['ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', 'if [ -f "$ZSHRC" ]; then { . "$ZSHRC"; } >/dev/null 2>&1; fi'].join( + "; " + ); case "bash": - return ['BASHRC="${BASH_ENV:-$HOME/.bashrc}"', 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi'].join("; "); + return [ + 'BASHRC="${BASH_ENV:-$HOME/.bashrc}"', + 'if [ -f "$BASHRC" ]; then { . "$BASHRC"; } >/dev/null 2>&1; fi', + ].join("; "); default: return null; } diff --git a/packages/core/src/common/state.ts b/packages/core/src/common/state.ts new file mode 100644 index 00000000..46e0b52d --- /dev/null +++ b/packages/core/src/common/state.ts @@ -0,0 +1,535 @@ +import * as fs from "fs"; +import * as path from "path"; +import { readTextFileWithMetadata } from "./file-utils"; +import { posixPathToWindowsPath } from "./shell-utils"; + +export type FileLineEnding = "LF" | "CRLF"; + +export type FileState = { + filePath: string; + content: string; + timestamp: number; + version?: number; + offset?: number; + limit?: number; + isPartialView?: boolean; + encoding?: BufferEncoding; + lineEndings?: FileLineEnding; +}; + +export type FileSnippet = { + id: string; + filePath: string; + startLine: number; + endLine: number; + preview: string; + fileVersion: number; + scopeType: "snippet" | "full"; +}; + +export type SessionStateHistoryMessage = { + role?: unknown; + content?: unknown; +}; + +const fileStatesBySession = new Map>(); +const snippetsBySession = new Map>(); +const snippetCountersBySession = new Map(); +const fullFileSnippetCountersBySession = new Map(); +const fileVersionsBySession = new Map>(); + +export function clearSessionState(sessionId: string): void { + if (!sessionId) { + return; + } + + fileStatesBySession.delete(sessionId); + snippetsBySession.delete(sessionId); + snippetCountersBySession.delete(sessionId); + fullFileSnippetCountersBySession.delete(sessionId); + fileVersionsBySession.delete(sessionId); +} + +export function hasSessionState(sessionId: string): boolean { + if (!sessionId) { + return false; + } + + return Boolean( + fileStatesBySession.get(sessionId)?.size || + snippetsBySession.get(sessionId)?.size || + snippetCountersBySession.has(sessionId) || + fullFileSnippetCountersBySession.has(sessionId) || + fileVersionsBySession.get(sessionId)?.size + ); +} + +export function normalizeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string { + const nativePath = normalizeNativeFilePath(filePath, platform); + return platform === "win32" ? path.win32.normalize(nativePath) : path.normalize(nativePath); +} + +export function normalizeNativeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string { + if (platform !== "win32") { + return filePath; + } + + if (isGitBashAbsolutePath(filePath)) { + return posixPathToWindowsPath(filePath); + } + + return filePath; +} + +export function isAbsoluteFilePath(filePath: string, platform: NodeJS.Platform = process.platform): boolean { + const nativePath = normalizeNativeFilePath(filePath, platform); + if (platform !== "win32") { + return path.isAbsolute(nativePath); + } + + const normalized = path.win32.normalize(nativePath); + return path.win32.isAbsolute(normalized) && (/^[A-Za-z]:[\\/]/.test(normalized) || /^\\\\/.test(normalized)); +} + +function isGitBashAbsolutePath(filePath: string): boolean { + return /^\/[A-Za-z](?:\/|$)/.test(filePath) || /^\/cygdrive\/[A-Za-z](?:\/|$)/.test(filePath); +} + +export function recordFileState( + sessionId: string, + state: FileState, + options: { incrementVersion?: boolean } = {} +): void { + if (!sessionId || !state.filePath) { + return; + } + + let sessionState = fileStatesBySession.get(sessionId); + if (!sessionState) { + sessionState = new Map(); + fileStatesBySession.set(sessionId, sessionState); + } + + const normalizedPath = normalizeFilePath(state.filePath); + const currentVersion = getFileVersion(sessionId, normalizedPath); + const nextVersion = options.incrementVersion ? currentVersion + 1 : currentVersion; + setFileVersion(sessionId, normalizedPath, nextVersion); + sessionState.set(normalizedPath, { + ...state, + filePath: normalizedPath, + version: nextVersion, + }); +} + +export function markFileRead( + sessionId: string, + filePath: string, + state: Omit | null = null +): void { + if (!sessionId || !filePath) { + return; + } + + recordFileState(sessionId, { + filePath, + content: state?.content ?? "", + timestamp: state?.timestamp ?? 0, + offset: state?.offset, + limit: state?.limit, + isPartialView: state?.isPartialView, + encoding: state?.encoding, + lineEndings: state?.lineEndings, + }); +} + +export function getFileState(sessionId: string, filePath: string): FileState | null { + if (!sessionId || !filePath) { + return null; + } + + return fileStatesBySession.get(sessionId)?.get(normalizeFilePath(filePath)) ?? null; +} + +export function wasFileRead(sessionId: string, filePath: string): boolean { + return getFileState(sessionId, filePath) !== null; +} + +export function getFileVersion(sessionId: string, filePath: string): number { + if (!sessionId || !filePath) { + return 0; + } + return fileVersionsBySession.get(sessionId)?.get(normalizeFilePath(filePath)) ?? 0; +} + +function setFileVersion(sessionId: string, filePath: string, version: number): void { + let sessionVersions = fileVersionsBySession.get(sessionId); + if (!sessionVersions) { + sessionVersions = new Map(); + fileVersionsBySession.set(sessionId, sessionVersions); + } + sessionVersions.set(normalizeFilePath(filePath), version); +} + +export function isFullFileView(state: FileState | null): boolean { + return Boolean( + state && !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined" + ); +} + +export function createSnippet( + sessionId: string, + filePath: string, + startLine: number, + endLine: number, + preview: string +): FileSnippet | null { + const nextCounter = (snippetCountersBySession.get(sessionId) ?? 0) + 1; + snippetCountersBySession.set(sessionId, nextCounter); + return createSnippetWithId(sessionId, filePath, startLine, endLine, preview, `snippet_${nextCounter}`, "snippet"); +} + +export function createFullFileSnippet( + sessionId: string, + filePath: string, + startLine: number, + endLine: number, + preview: string +): FileSnippet | null { + const nextCounter = fullFileSnippetCountersBySession.get(sessionId) ?? 0; + fullFileSnippetCountersBySession.set(sessionId, nextCounter + 1); + return createSnippetWithId(sessionId, filePath, startLine, endLine, preview, `full_file_${nextCounter}`, "full"); +} + +export function restoreSnippet( + sessionId: string, + snippet: { + id: string; + filePath: string; + startLine: number; + endLine: number; + preview?: string; + scopeType?: FileSnippet["scopeType"]; + } +): FileSnippet | null { + const restored = createSnippetWithId( + sessionId, + snippet.filePath, + snippet.startLine, + snippet.endLine, + snippet.preview ?? "", + snippet.id, + snippet.scopeType ?? inferSnippetScopeType(snippet.id) + ); + if (restored) { + updateSnippetCounters(sessionId, snippet.id); + } + return restored; +} + +function createSnippetWithId( + sessionId: string, + filePath: string, + startLine: number, + endLine: number, + preview: string, + id: string, + scopeType: FileSnippet["scopeType"] +): FileSnippet | null { + if (!sessionId || !filePath || startLine < 1 || endLine < startLine) { + return null; + } + + const snippet: FileSnippet = { + id, + filePath: normalizeFilePath(filePath), + startLine, + endLine, + preview, + fileVersion: getFileVersion(sessionId, filePath), + scopeType, + }; + + let snippets = snippetsBySession.get(sessionId); + if (!snippets) { + snippets = new Map(); + snippetsBySession.set(sessionId, snippets); + } + snippets.set(snippet.id, snippet); + return snippet; +} + +function inferSnippetScopeType(id: string): FileSnippet["scopeType"] { + return id.startsWith("full_file_") ? "full" : "snippet"; +} + +function updateSnippetCounters(sessionId: string, id: string): void { + const fullFileMatch = /^full_file_(\d+)$/.exec(id); + if (fullFileMatch) { + const nextCounter = Number(fullFileMatch[1]) + 1; + const current = fullFileSnippetCountersBySession.get(sessionId) ?? 0; + fullFileSnippetCountersBySession.set(sessionId, Math.max(current, nextCounter)); + return; + } + + const snippetMatch = /^snippet_(\d+)$/.exec(id); + if (snippetMatch) { + const currentCounter = Number(snippetMatch[1]); + const current = snippetCountersBySession.get(sessionId) ?? 0; + snippetCountersBySession.set(sessionId, Math.max(current, currentCounter)); + } +} + +export function getSnippet(sessionId: string, snippetId: string): FileSnippet | null { + if (!sessionId || !snippetId) { + return null; + } + return snippetsBySession.get(sessionId)?.get(snippetId) ?? null; +} + +export function hasSnippetOutdatedFileVersion(sessionId: string, snippet: FileSnippet): boolean { + return getFileVersion(sessionId, snippet.filePath) > snippet.fileVersion; +} + +export function rebuildSessionStateFromHistory( + sessionId: string, + messages: Iterable +): void { + if (!sessionId || hasSessionState(sessionId)) { + return; + } + + for (const message of messages) { + if (message.role !== "tool" || typeof message.content !== "string") { + continue; + } + + const result = parsePersistedToolResult(message.content); + if (!result || result.ok !== true) { + continue; + } + + const metadata = asRecord(result.metadata); + if (!metadata) { + continue; + } + + if (result.name === "read") { + rebuildReadResult(sessionId, result, metadata); + } else if (result.name === "edit") { + rebuildEditResult(sessionId, metadata); + } else if (result.name === "write") { + rebuildWriteResult(sessionId, metadata); + } + } +} + +function rebuildReadResult( + sessionId: string, + result: Record, + metadata: Record +): void { + const snippet = asRecord(metadata.snippet); + if (!snippet) { + return; + } + + const restored = restoreSnippetFromRecord(sessionId, snippet, { + idKey: "id", + filePathKey: "filePath", + startLineKey: "startLine", + endLineKey: "endLine", + preview: typeof result.output === "string" ? result.output : "", + }); + if (!restored) { + return; + } + + refreshRebuiltFileState(sessionId, restored.filePath, { + scopeType: restored.scopeType, + startLine: restored.startLine, + endLine: restored.endLine, + incrementVersion: false, + }); +} + +function rebuildEditResult(sessionId: string, metadata: Record): void { + const scope = asRecord(metadata.scope); + if (scope) { + restoreSnippetFromRecord(sessionId, scope, { + idKey: "snippet_id", + filePathKey: "file_path", + startLineKey: "start_line", + endLineKey: "end_line", + scopeType: metadata.read_scope_type === "full" ? "full" : undefined, + }); + } + + const scopeFilePath = typeof scope?.file_path === "string" ? scope.file_path : undefined; + rebuildCandidateSnippets(sessionId, metadata, scopeFilePath); + + const filePath = typeof metadata.file_path === "string" ? metadata.file_path : scopeFilePath; + if (filePath && metadata.cache_refreshed === true) { + refreshRebuiltFileState(sessionId, filePath, { incrementVersion: true }); + } +} + +function rebuildWriteResult(sessionId: string, metadata: Record): void { + if (metadata.cache_refreshed !== true || typeof metadata.file_path !== "string") { + return; + } + + refreshRebuiltFileState(sessionId, metadata.file_path, { incrementVersion: true }); +} + +function rebuildCandidateSnippets( + sessionId: string, + metadata: Record, + filePath: string | undefined +): void { + if (!filePath) { + return; + } + + const candidates = Array.isArray(metadata.candidates) ? metadata.candidates : []; + for (const candidate of candidates) { + const record = asRecord(candidate); + if (!record) { + continue; + } + restoreSnippetFromRecord( + sessionId, + { ...record, file_path: filePath }, + { + idKey: "snippet_id", + filePathKey: "file_path", + startLineKey: "start_line", + endLineKey: "end_line", + scopeType: "snippet", + preview: typeof record.preview === "string" ? record.preview : "", + } + ); + } + + const closestMatch = asRecord(metadata.closest_match); + if (closestMatch) { + restoreSnippetFromRecord( + sessionId, + { ...closestMatch, file_path: filePath }, + { + idKey: "snippet_id", + filePathKey: "file_path", + startLineKey: "start_line", + endLineKey: "end_line", + scopeType: "snippet", + preview: typeof closestMatch.preview === "string" ? closestMatch.preview : "", + } + ); + } +} + +function restoreSnippetFromRecord( + sessionId: string, + record: Record, + options: { + idKey: string; + filePathKey: string; + startLineKey: string; + endLineKey: string; + preview?: string; + scopeType?: FileSnippet["scopeType"]; + } +): FileSnippet | null { + const rawId = record[options.idKey]; + const rawFilePath = record[options.filePathKey]; + const id = typeof rawId === "string" ? rawId.trim() : ""; + const filePath = typeof rawFilePath === "string" ? normalizeFilePath(rawFilePath) : ""; + const startLine = toPositiveInteger(record[options.startLineKey]); + const endLine = toPositiveInteger(record[options.endLineKey]); + if (!id || !filePath || startLine === null || endLine === null) { + return null; + } + + return restoreSnippet(sessionId, { + id, + filePath, + startLine, + endLine, + preview: options.preview, + scopeType: options.scopeType, + }); +} + +function refreshRebuiltFileState( + sessionId: string, + rawFilePath: string, + options: { + scopeType?: FileSnippet["scopeType"]; + startLine?: number; + endLine?: number; + incrementVersion?: boolean; + } = {} +): void { + const filePath = normalizeFilePath(rawFilePath); + if (!filePath || !fs.existsSync(filePath)) { + return; + } + + try { + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + return; + } + + const metadata = readTextFileWithMetadata(filePath); + const isPartialView = options.scopeType === "snippet"; + const content = isPartialView + ? metadata.content + .split("\n") + .slice((options.startLine ?? 1) - 1, options.endLine) + .join("\n") + : metadata.content; + + recordFileState( + sessionId, + { + filePath, + content, + timestamp: metadata.timestamp, + offset: isPartialView ? options.startLine : undefined, + limit: + isPartialView && options.startLine !== undefined && options.endLine !== undefined + ? Math.max(1, options.endLine - options.startLine + 1) + : undefined, + isPartialView, + encoding: metadata.encoding, + lineEndings: metadata.lineEndings, + }, + { incrementVersion: options.incrementVersion } + ); + } catch { + // Best-effort restore: later tool execution will return the precise filesystem error. + } +} + +function parsePersistedToolResult(content: string): Record | null { + try { + return asRecord(JSON.parse(content)); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function toPositiveInteger(value: unknown): number | null { + const numberValue = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; + if (!Number.isInteger(numberValue) || numberValue < 1) { + return null; + } + return numberValue; +} diff --git a/packages/core/src/common/telemetry.ts b/packages/core/src/common/telemetry.ts new file mode 100644 index 00000000..f6dc60b3 --- /dev/null +++ b/packages/core/src/common/telemetry.ts @@ -0,0 +1,34 @@ +const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; +const DEFAULT_REPORT_TIMEOUT_MS = 3000; + +export type NewPromptReportOptions = { + enabled: boolean; + machineId?: string; + timeoutMs?: number; +}; + +/** + * Fire-and-forget report of a new prompt session. + * Respects the `enabled` toggle: when disabled, the call is a no-op. + */ +export function reportNewPrompt(options: NewPromptReportOptions): void { + if (!options.enabled || !options.machineId) { + return; + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_REPORT_TIMEOUT_MS; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + void fetch(DEFAULT_NEW_PROMPT_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Token: options.machineId, + }, + body: JSON.stringify({}), + signal: controller.signal, + }) + .catch(() => {}) + .finally(() => clearTimeout(timeout)); +} diff --git a/packages/core/src/common/tool-types.ts b/packages/core/src/common/tool-types.ts new file mode 100644 index 00000000..1d664a76 --- /dev/null +++ b/packages/core/src/common/tool-types.ts @@ -0,0 +1,107 @@ +import type OpenAI from "openai"; +import type { ReasoningEffort } from "../settings"; + +export type CreateOpenAIClient = () => { + client: OpenAI | null; + model: string; + baseURL?: string; + temperature?: number; + thinkingEnabled: boolean; + reasoningEffort?: ReasoningEffort; + debugLogEnabled?: boolean; + telemetryEnabled?: boolean; + notify?: string; + webSearchTool?: string; + env?: Record; + machineId?: string; +}; + +export type ToolCall = { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +}; + +export type ToolExecutionContext = { + sessionId: string; + projectRoot: string; + toolCall: ToolCall; + createOpenAIClient?: CreateOpenAIClient; + onProcessStart?: (processId: string | number, command: string) => void; + onProcessExit?: (processId: string | number) => void; + onProcessStdout?: (processId: string | number, chunk: string) => void; + onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void; + onBackgroundProcessComplete?: (completion: BackgroundProcessCompletion) => void; + onBeforeFileMutation?: (filePath: string) => void; + onAfterFileMutation?: (filePath: string) => void; + bashTimeoutMs?: number; + bashMinTimeoutMs?: number; +}; + +export type ToolExecutionHooks = { + onProcessStart?: (processId: string | number, command: string) => void; + onProcessExit?: (processId: string | number) => void; + onProcessStdout?: (processId: string | number, chunk: string) => void; + onProcessTimeoutControl?: (processId: string | number, control: ProcessTimeoutControl | null) => void; + onBackgroundProcessComplete?: (completion: BackgroundProcessCompletion) => void; + onBeforeFileMutation?: (filePath: string) => void; + onAfterFileMutation?: (filePath: string) => void; + shouldStop?: () => boolean; +}; + +export type BackgroundProcessCompletion = { + taskId: string; + processId: number; + command: string; + outputPath: string; + ok: boolean; + exitCode: number | null; + signal: string | null; + error?: string; + cwd: string | null; + shellPath: string; + startedAtMs: number; + completedAtMs: number; +}; + +export type ProcessTimeoutInfo = { + timeoutMs: number; + startedAtMs: number; + deadlineAtMs: number; + timedOut: boolean; +}; + +export type ProcessTimeoutControl = { + getInfo: () => ProcessTimeoutInfo; + setTimeoutMs: (timeoutMs: number) => ProcessTimeoutInfo; +}; + +export type ToolExecutionResult = { + ok: boolean; + name: string; + output?: string; + error?: string; + metadata?: Record; + awaitUserResponse?: boolean; + followUpMessages?: ToolExecutionFollowUpMessage[]; +}; + +export type ToolExecutionFollowUpMessage = { + role: "system"; + content: string; + contentParams?: unknown | null; +}; + +export type ToolHandler = ( + args: Record, + context: ToolExecutionContext +) => Promise; + +export type ToolCallExecution = { + toolCallId: string; + content: string; + result: ToolExecutionResult; +}; diff --git a/src/common/runtime.ts b/packages/core/src/common/validate.ts similarity index 99% rename from src/common/runtime.ts rename to packages/core/src/common/validate.ts index b1195d8d..7e274253 100644 --- a/src/common/runtime.ts +++ b/packages/core/src/common/validate.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ToolExecutionContext, ToolExecutionResult } from "../tools/executor"; +import type { ToolExecutionContext, ToolExecutionResult } from "./tool-types"; export type ValidationResult = { ok: true; input: Record } | { ok: false; error: string }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 00000000..832d2444 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,135 @@ +// Core library public API — used by both CLI and VSCode companion. + +// Settings +export { + resolveCurrentSettings, + resolveSettings, + resolveSettingsSources, + readSettings, + readProjectSettings, + writeSettings, + writeProjectSettings, + writeModelConfigSelection, + applyModelConfigSelection, + modelConfigKey, + getUserSettingsPath, + getProjectSettingsPath, + DEFAULT_MODEL, + DEFAULT_BASE_URL, +} from "./settings"; +export type { + DeepcodingSettings, + ResolvedDeepcodingSettings, + ModelConfigSelection, + PermissionScope, + PermissionSettings, + PermissionDefaultMode, + McpServerConfig, + ReasoningEffort, + StatusLineSettings, + ResolvedStatusLineSettings, + StatusLineProviderConfig, +} from "./settings"; + +// Session +export { SessionManager, getProjectCode, getCompactPromptTokenThreshold } from "./session"; +export type { + SessionMessage, + SessionEntry, + SessionStatus, + SessionsIndex, + SessionMessageRole, + MessageMeta, + UndoTarget, + UserPromptContent, + SkillInfo, + ModelUsage, + SessionProcessEntry, + BashTimeoutAdjustment, + LlmStreamProgress, +} from "./session"; + +// Prompt utilities +export { + getSystemPrompt, + getCompactPrompt, + getRuntimeContext, + getDefaultSkillPrompt, + getExtensionRoot, + getTools, + buildSkillDocumentsPrompt, +} from "./prompt"; +export type { ToolDefinition, SkillPromptDocument } from "./prompt"; + +// Tools +export { ToolExecutor } from "./tools/executor"; +export type { + CreateOpenAIClient, + ToolCall, + ToolExecutionContext, + ToolExecutionHooks, + ToolExecutionResult, + ToolHandler, + ToolCallExecution, + ProcessTimeoutInfo, + ProcessTimeoutControl, + BackgroundProcessCompletion, + ToolExecutionFollowUpMessage, +} from "./common/tool-types"; + +// Tool handlers +export { handleBashTool, clearSessionWorkingDir } from "./tools/bash-handler"; +export { handleReadTool } from "./tools/read-handler"; +export { handleWriteTool } from "./tools/write-handler"; +export { handleEditTool } from "./tools/edit-handler"; +export { handleUpdatePlanTool } from "./tools/update-plan-handler"; +export { handleWebSearchTool } from "./tools/web-search-handler"; +export { handleAskUserQuestionTool } from "./tools/ask-user-question-handler"; + +// MCP +export { McpManager } from "./mcp/mcp-manager"; +export { McpClient } from "./mcp/mcp-client"; +export type { McpServerStatus } from "./mcp/mcp-manager"; + +// Common utilities +export { createOpenAIClient } from "./common/openai-client"; +export { buildThinkingRequestOptions } from "./common/openai-thinking"; +export { readTextFileWithMetadata, writeTextFile, buildDiffPreview, ensureParentDirectory } from "./common/file-utils"; +export { normalizeFilePath, getSnippet, clearSessionState, recordFileState, getFileState } from "./common/state"; +export { GitFileHistory } from "./common/file-history"; +export { killProcessTree } from "./common/process-tree"; +export { launchNotifyScript } from "./common/notify"; +export { reportNewPrompt } from "./common/telemetry"; +export { DEEPSEEK_V4_MODELS, supportsMultimodal, defaultsToThinkingMode } from "./common/model-capabilities"; +export { findGitBashPath, resolveShellPath, setShellIfWindows } from "./common/shell-utils"; +export { logApiError } from "./common/error-logger"; +export { logOpenAIChatCompletionDebug } from "./common/debug-logger"; +export { + clampBashTimeoutMs, + DEFAULT_BASH_TIMEOUT_MS, + BASH_TIMEOUT_INCREMENT_MS, + BASH_TIMEOUT_DECREMENT_MS, +} from "./common/bash-timeout"; +export { executeValidatedTool, semanticBoolean } from "./common/validate"; +export { OpenAIMessageConverter } from "./common/openai-message-converter"; +export { + computeToolCallPermissions, + buildPermissionToolExecution, + hasUserPermissionReplies, + appendProjectPermissionAllows, + normalizeAskPermissions, + parseToolCallForPermissions, +} from "./common/permissions"; +export type { + AskPermissionRequest, + AskPermissionScope, + BashPermissionScope, + MessageToolPermission, + PermissionDecision, + PermissionToolCall, + UserToolPermission, +} from "./common/permissions"; + +// State types +export type { FileState, FileSnippet, FileLineEnding } from "./common/state"; +export type { FileReadMetadata } from "./common/file-utils"; diff --git a/src/mcp/mcp-client.ts b/packages/core/src/mcp/mcp-client.ts similarity index 81% rename from src/mcp/mcp-client.ts rename to packages/core/src/mcp/mcp-client.ts index 27557552..4420c569 100644 --- a/src/mcp/mcp-client.ts +++ b/packages/core/src/mcp/mcp-client.ts @@ -1,6 +1,5 @@ import { spawn, type ChildProcess } from "child_process"; import { createInterface, type Interface } from "readline"; -import * as os from "os"; import * as path from "path"; import { killProcessTree } from "../common/process-tree"; @@ -97,6 +96,13 @@ type ReadResourceResult = { export type McpNotificationHandler = (method: string, params?: Record) => void; +export type McpSpawnSpec = { + command: string; + args: string[]; + shell: boolean; + windowsHide?: boolean; +}; + export class McpClient { private process: ChildProcess | null = null; private reader: Interface | null = null; @@ -107,55 +113,67 @@ export class McpClient { >(); private stderrBuffer = ""; private notificationHandler: McpNotificationHandler | null = null; + private disconnectHandler: ((reason: string) => void) | null = null; + private intentionallyDisconnected = false; constructor( private readonly serverName: string, private readonly command: string, private readonly args: string[] = [], private readonly env?: Record, - onNotification?: McpNotificationHandler + onNotification?: McpNotificationHandler, + onDisconnect?: (reason: string) => void ) { this.notificationHandler = onNotification ?? null; + this.disconnectHandler = onDisconnect ?? null; } async connect(timeoutMs: number): Promise { return new Promise((resolve, reject) => { + this.intentionallyDisconnected = false; const childEnv = { ...process.env, ...this.env, }; const args = this.withNpxYesArg(this.command, this.args); + const spawnSpec = createMcpSpawnSpec(this.command, args); - const isWindows = os.platform() === "win32"; - - if (isWindows) { - // On Windows, shell: true lets cmd.exe resolve the command via - // PATHEXT (npx → npx.cmd, etc.) without blindly appending .cmd, - // which would break absolute paths like process.execPath. - this.process = spawn(this.command, args, { - stdio: ["pipe", "pipe", "pipe"], - env: childEnv, - shell: true, - windowsHide: true, - }); - } else { - this.process = spawn(this.command, args, { - stdio: ["pipe", "pipe", "pipe"], - env: childEnv, - }); - } + this.process = spawn(spawnSpec.command, spawnSpec.args, { + stdio: ["pipe", "pipe", "pipe"], + env: childEnv, + shell: spawnSpec.shell, + windowsHide: spawnSpec.windowsHide, + }); + + let resolved = false; + const safeReject = (err: Error) => { + if (!resolved) { + resolved = true; + reject(err); + } + }; this.process.on("error", (err) => { - reject(this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`)); + safeReject( + this.withStderr(`Failed to start MCP server "${this.serverName}" (${this.command}): ${err.message}`) + ); }); this.process.on("close", (code) => { - const error = this.withStderr(`MCP server "${this.serverName}" exited with code ${code}`); + const reason = `MCP server "${this.serverName}" exited with code ${code}`; + const error = this.withStderr(reason); for (const [, pending] of this.pendingRequests) { clearTimeout(pending.timer); pending.reject(error); } this.pendingRequests.clear(); + this.reader?.close(); + this.reader = null; + this.process = null; + if (!this.intentionallyDisconnected && this.disconnectHandler) { + this.disconnectHandler(reason); + } + safeReject(error); }); if (this.process.stderr) { @@ -264,6 +282,7 @@ export class McpClient { } disconnect(): void { + this.intentionallyDisconnected = true; if (this.reader) { this.reader.close(); this.reader = null; @@ -278,6 +297,10 @@ export class McpClient { } } + isConnected(): boolean { + return this.process !== null && this.process.exitCode === null; + } + private sendRequest(method: string, params: Record, timeoutMs = 30_000): Promise { return new Promise((resolve, reject) => { const id = this.nextId++; @@ -393,3 +416,36 @@ export class McpClient { return new Error(stderr ? `${message}. stderr: ${stderr}` : message); } } + +export function createMcpSpawnSpec( + command: string, + args: string[], + platform: NodeJS.Platform = process.platform +): McpSpawnSpec { + if (platform === "win32") { + return { + // On Windows, shell: true lets cmd.exe resolve the command via PATHEXT + // (npx -> npx.cmd, etc.). Join command and args into a single string + // with empty spawn args to avoid Node 24 DEP0190. + // Only quote arguments that need protection from cmd.exe to prevent + // double-wrapping by Node.js's own shell quoting. + command: [command, ...args].map(quoteWindowsArgIfNeeded).join(" "), + args: [], + shell: true, + windowsHide: true, + }; + } + + return { + command, + args, + shell: false, + }; +} + +function quoteWindowsArgIfNeeded(arg: string): string { + if (/[\s"&|<>^()]/.test(arg)) { + return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/\\+$/g, "$&$&")}"`; + } + return arg; +} diff --git a/src/mcp/mcp-manager.ts b/packages/core/src/mcp/mcp-manager.ts similarity index 50% rename from src/mcp/mcp-manager.ts rename to packages/core/src/mcp/mcp-manager.ts index 5a9f5530..6d2edc63 100644 --- a/src/mcp/mcp-manager.ts +++ b/packages/core/src/mcp/mcp-manager.ts @@ -1,8 +1,13 @@ +import { createHash } from "crypto"; import { McpClient, type McpToolDefinition, type McpPromptDefinition, type McpResourceDefinition } from "./mcp-client"; import type { McpServerConfig } from "../settings"; -const MCP_STARTUP_TIMEOUT_MS = 30_000; +const MCP_STARTUP_TIMEOUT_MS = process.env.DEEPCODE_MCP_TIMEOUT + ? parseInt(process.env.DEEPCODE_MCP_TIMEOUT, 10) + : 30_000; const MCP_CALL_TOOL_TIMEOUT_MS = 60_000; +const API_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; +const API_TOOL_NAME_MAX_LENGTH = 64; type McpToolEntry = { serverName: string; @@ -14,7 +19,7 @@ type McpToolEntry = { export type McpServerStatus = { name: string; - status: "starting" | "ready" | "failed"; + status: "starting" | "ready" | "failed" | "reconnecting"; connected: boolean; error?: string; toolCount: number; @@ -25,6 +30,32 @@ export type McpServerStatus = { resources: string[]; }; +function buildMcpNamespacedName( + serverName: string, + toolName: string, + usedNames: ReadonlySet = new Set() +): string { + const rawName = buildRawMcpNamespacedName(serverName, toolName); + const sanitizedName = `mcp__${sanitizeApiToolNamePart(serverName)}__${sanitizeApiToolNamePart(toolName)}`; + let candidate = fitApiToolName(sanitizedName, rawName); + if (!usedNames.has(candidate)) { + return candidate; + } + + const hash = hashToolName(rawName); + candidate = fitApiToolNameWithSuffix(sanitizedName, `_${hash}`); + if (!usedNames.has(candidate)) { + return candidate; + } + + for (let index = 2; ; index += 1) { + candidate = fitApiToolNameWithSuffix(sanitizedName, `_${hash}_${index}`); + if (!usedNames.has(candidate)) { + return candidate; + } + } +} + export class McpManager { private clients: McpClient[] = []; private tools: McpToolEntry[] = []; @@ -46,12 +77,10 @@ export class McpManager { private serverStatuses: McpServerStatus[] = []; private onToolsListChanged: (() => void) | null = null; private onStatusChanged: (() => void) | null = null; + private serverConfigs: Record = {}; prepare(servers?: Record): void { if (!servers || Object.keys(servers).length === 0) return; - // Clear the disposed flag — a re-prepare means we are live again. - // (disconnect() sets disposed=true to stop a stale initialize() loop, - // but prepare+initialize must be able to start a new one.) this.disposed = false; for (const name of Object.keys(servers)) { @@ -81,116 +110,178 @@ export class McpManager { if (!servers || Object.keys(servers).length === 0) return; - const entries = Object.entries(servers); + this.serverConfigs = servers; this.prepare(servers); - for (const [name, config] of entries) { + for (const [name, config] of Object.entries(servers)) { if (this.disposed) break; - let client: McpClient | null = null; - try { - client = new McpClient(name, config.command, config.args ?? [], config.env, (method) => { + await this.connectServer(name, config); + } + } + + async reconnect(name: string, config?: McpServerConfig): Promise { + if (this.disposed) return; + const effectiveConfig = config ?? this.serverConfigs[name]; + if (!effectiveConfig) return; + if (config) { + this.serverConfigs[name] = config; + } + + this.setStatus({ + name, + status: "reconnecting", + connected: false, + error: "Reconnecting...", + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + + await this.connectServer(name, effectiveConfig); + } + + private async connectServer(name: string, config: McpServerConfig): Promise { + if (this.disposed) return; + + // Clean up stale entries from previous connection attempts + this.clients = this.clients.filter((c) => c.isConnected()); + this.tools = this.tools.filter((t) => t.serverName !== name); + this.prompts = this.prompts.filter((p) => p.serverName !== name); + this.resources = this.resources.filter((r) => r.serverName !== name); + + let client: McpClient | null = null; + try { + client = new McpClient( + name, + config.command, + config.args ?? [], + config.env, + (method) => { if (method === "notifications/tools/list_changed") { - this.refreshServerTools(name, client!).catch(() => { - // swallow refresh errors - }); + this.refreshServerTools(name, client!).catch(() => {}); + } + }, + (reason) => { + if (!this.disposed && this.serverConfigs[name]) { + this.onServerCrash(name, reason); } - }); - await client.connect(MCP_STARTUP_TIMEOUT_MS); - if (this.disposed) { - client.disconnect(); - break; - } - this.clients.push(client); - - // Discover tools - const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); - if (this.disposed) break; - const toolNamespacedNames: string[] = []; - for (const tool of serverTools) { - const namespacedName = `mcp__${name}__${tool.name}`; - this.tools.push({ - serverName: name, - originalName: tool.name, - namespacedName, - definition: tool, - client, - }); - toolNamespacedNames.push(namespacedName); - } - - // Discover prompts - let serverPrompts: McpPromptDefinition[] = []; - try { - serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS); - } catch { - // Server may not support prompts — safe to ignore - } - if (this.disposed) break; - const promptNamespacedNames: string[] = []; - for (const prompt of serverPrompts) { - const namespacedName = `mcp__${name}__${prompt.name}`; - this.prompts.push({ - serverName: name, - namespacedName, - definition: prompt, - client, - }); - promptNamespacedNames.push(namespacedName); } + ); + await client.connect(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) { + client.disconnect(); + return; + } + this.clients.push(client); - // Discover resources - let serverResources: McpResourceDefinition[] = []; - try { - serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS); - } catch { - // Server may not support resources — safe to ignore - } - if (this.disposed) break; - const resourceNamespacedNames: string[] = []; - for (const resource of serverResources) { - const namespacedName = `mcp__${name}__${resource.name}`; - this.resources.push({ - serverName: name, - namespacedName, - definition: resource, - client, - }); - resourceNamespacedNames.push(namespacedName); - } + const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); + if (this.disposed) return; + const toolNamespacedNames: string[] = []; + const usedToolNames = new Set(this.tools.map((tool) => tool.namespacedName)); + for (const tool of serverTools) { + const namespacedName = buildMcpNamespacedName(name, tool.name, usedToolNames); + usedToolNames.add(namespacedName); + this.tools.push({ + serverName: name, + originalName: tool.name, + namespacedName, + definition: tool, + client, + }); + toolNamespacedNames.push(namespacedName); + } - this.setStatus({ - name, - status: "ready", - connected: true, - toolCount: serverTools.length, - tools: toolNamespacedNames, - promptCount: serverPrompts.length, - prompts: promptNamespacedNames, - resourceCount: serverResources.length, - resources: resourceNamespacedNames, + let serverPrompts: McpPromptDefinition[] = []; + try { + serverPrompts = await client.listPrompts(MCP_STARTUP_TIMEOUT_MS); + } catch { + // server may not support prompts + } + if (this.disposed) return; + const promptNamespacedNames: string[] = []; + for (const prompt of serverPrompts) { + const namespacedName = `mcp__${name}__${prompt.name}`; + this.prompts.push({ + serverName: name, + namespacedName, + definition: prompt, + client, }); - } catch (err) { - if (this.disposed) break; - client?.disconnect(); - const message = err instanceof Error ? err.message : String(err); - // 不在控制台输出错误日志,避免暴露敏感信息 - // process.stderr.write(`[deepcode] MCP server "${name}" failed to initialize: ${message}\n`); - this.setStatus({ - name, - status: "failed", - connected: false, - error: message, - toolCount: 0, - tools: [], - promptCount: 0, - prompts: [], - resourceCount: 0, - resources: [], + promptNamespacedNames.push(namespacedName); + } + + let serverResources: McpResourceDefinition[] = []; + try { + serverResources = await client.listResources(MCP_STARTUP_TIMEOUT_MS); + } catch { + // server may not support resources + } + if (this.disposed) return; + const resourceNamespacedNames: string[] = []; + for (const resource of serverResources) { + const namespacedName = `mcp__${name}__${resource.name}`; + this.resources.push({ + serverName: name, + namespacedName, + definition: resource, + client, }); + resourceNamespacedNames.push(namespacedName); } + + this.setStatus({ + name, + status: "ready", + connected: true, + toolCount: serverTools.length, + tools: toolNamespacedNames, + promptCount: serverPrompts.length, + prompts: promptNamespacedNames, + resourceCount: serverResources.length, + resources: resourceNamespacedNames, + }); + } catch (err) { + client?.disconnect(); + const message = err instanceof Error ? err.message : String(err); + this.setStatus({ + name, + status: "failed", + connected: false, + error: message, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); } } + private onServerCrash(name: string, reason: string): void { + if (this.disposed) return; + this.clients = this.clients.filter((c) => c.isConnected()); + this.tools = this.tools.filter((t) => t.serverName !== name); + this.prompts = this.prompts.filter((p) => p.serverName !== name); + this.resources = this.resources.filter((r) => r.serverName !== name); + this.onToolsListChanged?.(); + this.setStatus({ + name, + status: "failed", + connected: false, + error: reason, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }); + } + getStatus(): McpServerStatus[] { const result = [...this.serverStatuses]; const knownNames = new Set(result.map((s) => s.name)); @@ -229,7 +320,7 @@ export class McpManager { type: "function" as const, function: { name: t.namespacedName, - description: t.definition.description ?? `${t.serverName}: ${t.originalName}`, + description: this.buildMcpToolDescription(t), parameters: { type: "object" as const, properties: t.definition.inputSchema.properties, @@ -345,16 +436,18 @@ export class McpManager { this.resources = []; this.serverStatuses = []; this.configuredServerNames = []; + this.serverConfigs = {}; this.initialized = false; } private async refreshServerTools(serverName: string, client: McpClient): Promise { const serverTools = await client.listTools(MCP_STARTUP_TIMEOUT_MS); - // Remove old tool entries for this server this.tools = this.tools.filter((t) => t.serverName !== serverName); const toolNamespacedNames: string[] = []; + const usedToolNames = new Set(this.tools.map((tool) => tool.namespacedName)); for (const tool of serverTools) { - const namespacedName = `mcp__${serverName}__${tool.name}`; + const namespacedName = buildMcpNamespacedName(serverName, tool.name, usedToolNames); + usedToolNames.add(namespacedName); this.tools.push({ serverName, originalName: tool.name, @@ -364,13 +457,11 @@ export class McpManager { }); toolNamespacedNames.push(namespacedName); } - // Update status const existing = this.serverStatuses.find((s) => s.name === serverName); if (existing) { existing.toolCount = serverTools.length; existing.tools = toolNamespacedNames; } - // Notify listener this.onToolsListChanged?.(); } @@ -390,7 +481,44 @@ export class McpManager { } else { this.serverStatuses[index] = status; } - // 触发状态变更回调 this.onStatusChanged?.(); } + + private buildMcpToolDescription(tool: McpToolEntry): string { + const description = tool.definition.description?.trim(); + const source = `${tool.serverName}: ${tool.originalName}`; + if (!description) { + return source; + } + if (tool.namespacedName === buildRawMcpNamespacedName(tool.serverName, tool.originalName)) { + return description; + } + return `${description}\nMCP source: ${source}`; + } +} + +function buildRawMcpNamespacedName(serverName: string, toolName: string): string { + return `mcp__${serverName}__${toolName}`; +} + +function sanitizeApiToolNamePart(value: string): string { + const sanitized = value.replace(/[^a-zA-Z0-9_-]/g, "_"); + return sanitized || "unnamed"; +} + +function fitApiToolName(name: string, rawName: string): string { + if (API_TOOL_NAME_PATTERN.test(name) && name.length <= API_TOOL_NAME_MAX_LENGTH) { + return name; + } + return fitApiToolNameWithSuffix(name, `_${hashToolName(rawName)}`); +} + +function fitApiToolNameWithSuffix(name: string, suffix: string): string { + const maxPrefixLength = API_TOOL_NAME_MAX_LENGTH - suffix.length; + const prefix = name.slice(0, Math.max(1, maxPrefixLength)); + return `${prefix}${suffix}`; +} + +function hashToolName(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 8); } diff --git a/src/prompt.ts b/packages/core/src/prompt.ts similarity index 75% rename from src/prompt.ts rename to packages/core/src/prompt.ts index 717991bf..dce34940 100644 --- a/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -2,8 +2,9 @@ import { execFileSync, execSync } from "child_process"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { fileURLToPath } from "url"; import ejs from "ejs"; +import matter from "gray-matter"; +import { fileURLToPath } from "url"; import type { SessionMessage } from "./session"; import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; import { supportsMultimodal } from "./common/model-capabilities"; @@ -99,7 +100,35 @@ type PromptToolOptions = { webSearchEnabled?: boolean; }; -const DEFAULT_SKILL_TEMPLATES = ["agent-drift-guard.md", "plan-and-execute.md"]; +type DefaultSkillPromptOptions = { + enabledSkills?: Record; +}; + +const DEFAULT_SKILL_TEMPLATES = ["karpathy-guidelines.md"]; +const DEFAULT_SKILL_RESOURCE_FILE_LIMIT = 50; +const SKILL_RESOURCE_EXCLUDED_DIRS = new Set([ + ".cache", + ".git", + ".next", + ".turbo", + "build", + "coverage", + "dist", + "node_modules", + "out", +]); + +export type SkillPromptDocument = { + name: string; + content: string; + path?: string; + skillFilePath?: string; +}; + +type SkillResourceListing = { + files: string[]; + truncated: boolean; +}; function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): string { const toolsDir = path.join(extensionRoot, "templates", "tools"); @@ -128,13 +157,20 @@ function readToolDocs(extensionRoot: string, options: PromptToolOptions = {}): s return docs.join("\n\n"); } -function readDefaultSkillDocs(extensionRoot: string): Array<{ name: string; content: string }> { +function readDefaultSkillDocs( + extensionRoot: string, + enabledSkills: Record = {} +): Array<{ name: string; content: string }> { const skillsDir = path.join(extensionRoot, "templates", "skills"); return DEFAULT_SKILL_TEMPLATES.map((entry) => { const fullPath = path.join(skillsDir, entry); + const name = path.basename(entry, ".md"); + if (enabledSkills[name] === false) { + return null; + } try { return { - name: path.basename(entry, ".md"), + name, content: fs.readFileSync(fullPath, "utf8").trim(), }; } catch { @@ -143,20 +179,121 @@ function readDefaultSkillDocs(extensionRoot: string): Array<{ name: string; cont }).filter((skill): skill is { name: string; content: string } => Boolean(skill?.content)); } -export function getDefaultSkillPrompt(): string { - const skillDocs = readDefaultSkillDocs(getExtensionRoot()); +export function getDefaultSkillPrompt(options: DefaultSkillPromptOptions = {}): string { + const skillDocs = readDefaultSkillDocs(getExtensionRoot(), options.enabledSkills); if (skillDocs.length === 0) { return ""; } - const blocks = skillDocs.map( - (skill) => `<${skill.name}-skill> -${skill.content} -` - ); + return buildSkillDocumentsPrompt(skillDocs); +} + +export function buildSkillDocumentsPrompt(skills: SkillPromptDocument[]): string { + const blocks = skills.map((skill) => renderSkillDocumentBlock(skill)); return `Use the skill documents below to assist the user:\n${blocks.join("\n\n")}`; } +function renderSkillDocumentBlock(skill: SkillPromptDocument): string { + const pathAttribute = skill.path ? ` path="${escapeXml(skill.path)}"` : ""; + const resources = renderSkillResources(skill.skillFilePath); + const content = stripSkillPromptMetadata(skill.content); + return `<${skill.name}-skill${pathAttribute}> +${content}${resources} +`; +} + +function stripSkillPromptMetadata(content: string): string { + try { + const parsed = matter(content); + if (!Object.prototype.hasOwnProperty.call(parsed.data, "metadata")) { + return content; + } + + const frontmatter = { ...parsed.data }; + delete frontmatter.metadata; + return matter.stringify(parsed.content, frontmatter); + } catch { + return content; + } +} + +function renderSkillResources(skillFilePath?: string): string { + if (!skillFilePath) { + return ""; + } + + const listing = listSkillResourceFiles(skillFilePath, DEFAULT_SKILL_RESOURCE_FILE_LIMIT); + if (listing.files.length === 0 && !listing.truncated) { + return ""; + } + + const fileLines = listing.files.map((file) => ` ${escapeXml(file)}`); + const noteLine = listing.truncated + ? [` Listing capped at ${DEFAULT_SKILL_RESOURCE_FILE_LIMIT} files and may be incomplete.`] + : []; + return `\n\n\n${[...fileLines, ...noteLine].join("\n")}\n`; +} + +function listSkillResourceFiles(skillFilePath: string, limit: number): SkillResourceListing { + const skillDir = path.dirname(skillFilePath); + const files: string[] = []; + let truncated = false; + + const visit = (dir: string, relativeDir = ""): void => { + if (files.length > limit) { + truncated = true; + return; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + } catch { + return; + } + + for (const entry of entries) { + if (entry.name.startsWith(".")) { + continue; + } + + const relativePath = relativeDir ? path.join(relativeDir, entry.name) : entry.name; + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (SKILL_RESOURCE_EXCLUDED_DIRS.has(entry.name)) { + continue; + } + visit(fullPath, relativePath); + if (truncated) { + return; + } + continue; + } + + if (!entry.isFile() || entry.name === "SKILL.md") { + continue; + } + + files.push(toPosixPath(relativePath)); + if (files.length > limit) { + truncated = true; + return; + } + } + }; + + visit(skillDir); + return { files: files.slice(0, limit), truncated }; +} + +function toPosixPath(filePath: string): string { + return filePath.split(path.sep).join("/"); +} + +function escapeXml(value: string): string { + return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} + function getCurrentDateAndModelPrompt(model?: string): string { const date = new Date(); let prompt = `今天是${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日。随着对话的进行,时间在流逝。`; @@ -166,8 +303,7 @@ function getCurrentDateAndModelPrompt(model?: string): string { export function getSystemPrompt(_projectRoot: string, options: PromptToolOptions = {}): string { const toolDocs = readToolDocs(getExtensionRoot(), options); - const basePrompt = toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; - return basePrompt; + return toolDocs ? `${SYSTEM_PROMPT_BASE}\n\n# Available Tools\n\n${toolDocs}` : SYSTEM_PROMPT_BASE; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { @@ -287,7 +423,7 @@ function getUnameInfo(): string { } } -function getExtensionRoot(): string { +export function getExtensionRoot(): string { // Prefer `__dirname` which is always available in the CJS bundle output. // Fall back to `import.meta.url` for ESM test environments (tsx --test). if (typeof __dirname !== "undefined") { @@ -331,8 +467,34 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe description: 'Clear, concise description of what this command does in active voice. Never use words like "complex" or "risk" in the description - just describe what it does.', }, + sideEffects: { + description: + 'Permission scopes required by this bash command. Use [] only for commands that do not read, write, delete, or access the network. Use ["unknown"] when the effects cannot be classified safely.', + type: "array", + items: { + type: "string", + enum: [ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown", + ], + }, + uniqueItems: true, + }, + run_in_background: { + type: "boolean", + description: + "Set to true to run the command in the background. Use this only when you need to perform a blocking task and do not need the result immediately.", + }, }, - required: ["command"], + required: ["command", "sideEffects"], additionalProperties: false, }, }, @@ -473,18 +635,17 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe parameters: { type: "object", properties: { - file_path: { + snippet_id: { type: "string", - description: "Absolute path to file. Optional when snippet_id is provided.", + description: "Required Read/Edit snippet_id.", }, - snippet_id: { + file_path: { type: "string", - description: - "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", + description: "Optional absolute path guard; must match snippet_id's file.", }, old_string: { type: "string", - description: "Exact text to replace inside the file or snippet scope", + description: "Exact text to replace inside snippet_id's scope", }, new_string: { type: "string", @@ -500,7 +661,7 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe description: "Expected number of matches, especially useful as a safety check with replace_all", }, }, - required: ["old_string", "new_string"], + required: ["snippet_id", "old_string", "new_string"], additionalProperties: false, }, }, diff --git a/src/session.ts b/packages/core/src/session.ts similarity index 64% rename from src/session.ts rename to packages/core/src/session.ts index 3b6b67a3..4483c67f 100644 --- a/src/session.ts +++ b/packages/core/src/session.ts @@ -2,33 +2,71 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; -import { fileURLToPath } from "url"; import matter from "gray-matter"; import ejs from "ejs"; -import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import { launchNotifyScript } from "./common/notify"; import { buildThinkingRequestOptions } from "./common/openai-thinking"; -import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities"; +import { DEEPSEEK_V4_MODELS } from "./common/model-capabilities"; +import { readTextFileWithMetadata } from "./common/file-utils"; import { + buildSkillDocumentsPrompt, getCompactPrompt, getDefaultSkillPrompt, + getExtensionRoot, getRuntimeContext, getSystemPrompt, getTools, type ToolDefinition, } from "./prompt"; -import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; +import { + ToolExecutor, + type CreateOpenAIClient, + type ProcessTimeoutControl, + type ProcessTimeoutInfo, + type ToolCallExecution, + type ToolExecutionHooks, +} from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; -import type { McpServerConfig } from "./settings"; +import type { McpServerConfig, PermissionScope, PermissionSettings } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; import { killProcessTree } from "./common/process-tree"; +import { GitFileHistory, type FileHistoryCheckpointResult } from "./common/file-history"; +import { clearSessionState, getSnippet, rebuildSessionStateFromHistory } from "./common/state"; +import { + appendProjectPermissionAllows, + buildPermissionToolExecution, + computeToolCallPermissions, + hasUserPermissionReplies, + normalizeAskPermissions, + parseToolCallForPermissions, + type AskPermissionRequest, + type MessageToolPermission, + type PermissionToolCall, + type UserToolPermission, +} from "./common/permissions"; +import { clearSessionWorkingDir } from "./tools/bash-handler"; +import { reportNewPrompt } from "./common/telemetry"; +import { OpenAIMessageConverter } from "./common/openai-message-converter"; + +export type { PermissionScope } from "./settings"; +export type { + AskPermissionRequest, + AskPermissionScope, + BashPermissionScope, + MessageToolPermission, + PermissionDecision, + UserToolPermission, +} from "./common/permissions"; const MAX_SESSION_ENTRIES = 50; -const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; -const NEW_PROMPT_REPORT_TIMEOUT_MS = 3000; +const MAX_PROJECT_CODE_LENGTH = 64; +const PROJECT_CODE_HASH_LENGTH = 16; +const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; +const PLAN_MODE_STATUS_MESSAGE = "/plan\n └ Set Plan Mode on. Awaiting ."; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -43,6 +81,36 @@ export function getCompactPromptTokenThreshold(model: string): number { : DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD; } +// Keep project storage paths short enough for Git's internal files on Windows. +export function getProjectCode(projectRoot: string): string { + const legacyCode = getLegacyProjectCode(projectRoot); + if (legacyCode.length <= MAX_PROJECT_CODE_LENGTH) { + return legacyCode; + } + + const normalizedRoot = path.resolve(projectRoot); + const hashInput = process.platform === "win32" ? normalizedRoot.toLowerCase() : normalizedRoot; + const hash = crypto.createHash("sha256").update(hashInput).digest("hex").slice(0, PROJECT_CODE_HASH_LENGTH); + const prefixLimit = MAX_PROJECT_CODE_LENGTH - PROJECT_CODE_HASH_LENGTH - 1; + const basename = path.basename(normalizedRoot); + const prefix = + sanitizeProjectCodePart(basename) + .slice(0, prefixLimit) + .replace(/[-.]+$/g, "") || "project"; + return `${prefix}-${hash}`; +} + +function getLegacyProjectCode(projectRoot: string): string { + return projectRoot.replace(/[\\/]/g, "-").replace(/:/g, ""); +} + +function sanitizeProjectCodePart(value: string): string { + return value + .replace(/[^A-Za-z0-9._-]/g, "-") + .replace(/-+/g, "-") + .replace(/^[-.]+|[-.]+$/g, ""); +} + function isUsageRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -104,15 +172,6 @@ function accumulateUsagePerModel( return usagePerModel; } -function getExtensionRoot(): string { - if (typeof __dirname !== "undefined") { - return path.resolve(__dirname, ".."); - } - - const currentFilePath = fileURLToPath(import.meta.url); - return path.resolve(path.dirname(currentFilePath), ".."); -} - function getTotalTokens(usage: ModelUsage | null | undefined): number { if (!isUsageRecord(usage)) { return 0; @@ -121,7 +180,15 @@ function getTotalTokens(usage: ModelUsage | null | undefined): number { return typeof totalTokens === "number" ? totalTokens : 0; } -export type SessionStatus = "failed" | "pending" | "processing" | "waiting_for_user" | "completed" | "interrupted"; +export type SessionStatus = + | "failed" + | "pending" + | "processing" + | "waiting_for_user" + | "completed" + | "interrupted" + | "ask_permission" + | "permission_denied"; export type ModelUsage = { prompt_tokens: number; @@ -134,6 +201,21 @@ export type ModelUsage = { total_reqs?: number; }; +export type SessionProcessEntry = { + startTime: string; + command: string; + timeoutMs?: number; + deadlineAt?: string; + timedOut?: boolean; +}; + +export type BashTimeoutAdjustment = { + processId: string; + timeoutMs: number; + deadlineAt: string; + timedOut: boolean; +}; + export type SessionEntry = { id: string; summary: string | null; @@ -148,7 +230,8 @@ export type SessionEntry = { activeTokens: number; createTime: string; updateTime: string; - processes: Map | null; // {pid: {startTime, command}} + processes: Map | null; // {pid: process info} + askPermissions?: AskPermissionRequest[]; }; export type SessionsIndex = { @@ -167,6 +250,8 @@ export type MessageMeta = { isSummary?: boolean; isModelChange?: boolean; skill?: SkillInfo; + permissions?: MessageToolPermission[]; + userPrompt?: UserPromptContent; }; export type SessionMessage = { @@ -182,12 +267,21 @@ export type SessionMessage = { updateTime: string; meta?: MessageMeta; html?: string; + checkpointHash?: string; +}; + +export type UndoTarget = { + message: SessionMessage; + index: number; + canRestoreCode: boolean; }; export type UserPromptContent = { text?: string; imageUrls?: string[]; skills?: SkillInfo[]; + permissions?: UserToolPermission[]; + alwaysAllows?: PermissionScope[]; }; export type SkillInfo = { @@ -195,12 +289,19 @@ export type SkillInfo = { path: string; description: string; isLoaded?: boolean; + allowImplicitInvocation?: boolean; }; type SessionManagerOptions = { projectRoot: string; createOpenAIClient: CreateOpenAIClient; - getResolvedSettings: () => { model: string; webSearchTool?: string; mcpServers?: Record }; + getResolvedSettings: () => { + model: string; + webSearchTool?: string; + mcpServers?: Record; + permissions?: Required; + enabledSkills?: Record; + }; renderMarkdown: (text: string) => string; onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; onSessionEntryUpdated?: (entry: SessionEntry) => void; @@ -225,6 +326,8 @@ export class SessionManager { model: string; webSearchTool?: string; mcpServers?: Record; + permissions?: Required; + enabledSkills?: Record; }; private readonly onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => void; private readonly onSessionEntryUpdated?: (entry: SessionEntry) => void; @@ -234,9 +337,12 @@ export class SessionManager { private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); + private readonly processTimeoutControls = new Map(); + private readonly liveProcessKeys = new Set(); private readonly toolExecutor: ToolExecutor; private readonly mcpManager = new McpManager(); private mcpToolDefinitions: ToolDefinition[] = []; + private readonly messageConverter: OpenAIMessageConverter; constructor(options: SessionManagerOptions) { this.projectRoot = options.projectRoot; @@ -249,6 +355,21 @@ export class SessionManager { this.onProcessStdout = options.onProcessStdout; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); + this.messageConverter = new OpenAIMessageConverter({ + renderInitPrompt: () => this.renderInitCommandPrompt(), + }); + } + + /** + * @deprecated Use messageConverter.buildMessages directly. + * Kept for test compatibility. + */ + buildOpenAIMessages( + messages: SessionMessage[], + thinkingEnabled: boolean, + model: string + ): ChatCompletionMessageParam[] { + return this.messageConverter.buildMessages(messages, thinkingEnabled, model); } async initMcpServers(servers?: Record): Promise { @@ -267,7 +388,25 @@ export class SessionManager { return this.mcpManager.getStatus(); } + async reconnectMcpServer(name: string, config?: McpServerConfig): Promise { + await this.mcpManager.reconnect(name, config); + this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); + } + dispose(): void { + const controller = this.activePromptController; + if (controller && !controller.signal.aborted) { + controller.abort(); + } + this.activePromptController = null; + for (const sessionController of this.sessionControllers.values()) { + if (!sessionController.signal.aborted) { + sessionController.abort(); + } + } + this.killLiveProcesses(); + this.sessionControllers.clear(); + this.processTimeoutControls.clear(); this.mcpManager.disconnect(); } @@ -536,9 +675,10 @@ export class SessionManager { const toolCalls = Array.from(toolCallsByIndex.entries()) .sort(([left], [right]) => left - right) .map(([, toolCall]) => toolCall); + const normalizedToolCalls = this.normalizeLlmToolCalls(toolCalls); const message: Record = { content }; - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; + if (normalizedToolCalls) { + message.tool_calls = normalizedToolCalls; } if (reasoningContent.length > 0) { message.reasoning_content = reasoningContent; @@ -583,7 +723,7 @@ export class SessionManager { options?: { signal?: AbortSignal; sessionId?: string } ): Promise { this.throwIfAborted(options?.signal); - let systemPrompt = `When users ask you to perform tasks, check if any of the available skills match. Skills provide specialized capabilities and domain knowledge.\n + let systemPrompt = `When users ask you to perform tasks, check if any of the available skills match the goal and situation. Skills provide specialized capabilities and domain knowledge.\n Response in JSON format: \`\`\` { @@ -591,27 +731,39 @@ Response in JSON format: } \`\`\`\n If none of the available skills match, respond with an empty array, i.e. \`{"skillNames": []}\`.\n -The candidate skills are as follows:\n\n`; +`; const simpleSkills = skills - .filter((x) => !x.isLoaded) + .filter((x) => !x.isLoaded && x.allowImplicitInvocation !== false) .map((x) => { return { name: x.name, description: x.description }; }); if (simpleSkills.length === 0) { return []; } - systemPrompt += "```\n" + JSON.stringify(simpleSkills, null, 2) + "\n```"; + const candidateSkillNames = new Set(simpleSkills.map((skill) => skill.name)); const { client, model, baseURL, debugLogEnabled } = this.createOpenAIClient(); if (!client) { return []; } + const agentInstructions = this.loadAgentInstructions(); + if (agentInstructions) { + systemPrompt += `Use the current agent instructions as additional context when deciding which skills match:\n + +${agentInstructions} +\n +`; + } + systemPrompt += "The candidate skills are as follows:\n\n"; + systemPrompt += "```\n" + JSON.stringify(simpleSkills, null, 2) + "\n```"; + try { const response = await this.createChatCompletionStream( client, { model, + temperature: 0.1, messages: [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, @@ -624,7 +776,7 @@ The candidate skills are as follows:\n\n`; enabled: debugLogEnabled, location: "SessionManager.identifyMatchingSkillNames", baseURL, - params: { purpose: "skill-matching" }, + params: { purpose: "skill-matching", temperature: 0.1 }, } ); this.throwIfAborted(options?.signal); @@ -637,7 +789,10 @@ The candidate skills are as follows:\n\n`; const parsed = JSON.parse(content); if (parsed && Array.isArray(parsed.skillNames)) { - return parsed.skillNames; + return parsed.skillNames.filter( + (skillName: unknown): skillName is string => + typeof skillName === "string" && candidateSkillNames.has(skillName) + ); } return []; @@ -649,11 +804,35 @@ The candidate skills are as follows:\n\n`; } } - async listSkills(sessionId?: string): Promise { + private getSkillScanRoots(): Array<{ root: string; displayRoot: string }> { const homeDir = os.homedir(); - const agentsRoot = path.join(homeDir, ".agents", "skills"); - const legacyProjectSkillsRoot = path.join(this.projectRoot, ".deepcode", "skills"); - const projectAgentsSkillsRoot = path.join(this.projectRoot, ".agents", "skills"); + return [ + { root: path.join(this.projectRoot, ".deepcode", "skills"), displayRoot: "./.deepcode/skills" }, + { root: path.join(this.projectRoot, ".agents", "skills"), displayRoot: "./.agents/skills" }, + { root: path.join(homeDir, ".deepcode", "skills"), displayRoot: "~/.deepcode/skills" }, + { root: path.join(homeDir, ".agents", "skills"), displayRoot: "~/.agents/skills" }, + { root: this.getBundledSkillsRoot(), displayRoot: "bundled:" }, + ]; + } + + private getBundledSkillsRoot(): string { + const extensionRoot = getExtensionRoot(); + const sourceRoot = path.join(extensionRoot, "templates", "skills", "bundled"); + + // Source check keeps local development/tests on the checked-in templates. + if (fs.existsSync(path.join(extensionRoot, "src", "session.ts")) && fs.existsSync(sourceRoot)) { + return sourceRoot; + } + + // In the published bundle, getExtensionRoot() resolves to dist/ and + // bundled skills are copied to dist/bundled/ (not dist/templates/skills/bundled/). + const distRoot = path.join(extensionRoot, "bundled"); + return fs.existsSync(distRoot) ? distRoot : sourceRoot; + } + + async listSkills(sessionId?: string): Promise { + const skillRoots = this.getSkillScanRoots(); + const enabledSkills = this.getResolvedSettings().enabledSkills ?? {}; const skillsByName = new Map(); const collectSkills = (root: string, displayRoot: string): SkillInfo[] => { @@ -685,19 +864,23 @@ The candidate skills are as follows:\n\n`; } catch { continue; } - results.push(this.readSkillInfo(skillPath, `${displayRoot}/${skillName}/SKILL.md`, skillName)); + const displayPath = + displayRoot === "bundled:" ? `bundled:${skillName}/SKILL.md` : `${displayRoot}/${skillName}/SKILL.md`; + const skill = this.readSkillInfo(skillPath, displayPath, skillName); + if (enabledSkills[skill.name] === false) { + continue; + } + results.push(skill); } return results; }; - for (const skill of collectSkills(agentsRoot, "~/.agents/skills")) { - skillsByName.set(skill.name, skill); - } - for (const skill of collectSkills(legacyProjectSkillsRoot, "./.deepcode/skills")) { - skillsByName.set(skill.name, skill); - } - for (const skill of collectSkills(projectAgentsSkillsRoot, "./.agents/skills")) { - skillsByName.set(skill.name, skill); + for (const { root, displayRoot } of skillRoots) { + for (const skill of collectSkills(root, displayRoot)) { + if (!skillsByName.has(skill.name)) { + skillsByName.set(skill.name, skill); + } + } } if (sessionId) { @@ -713,6 +896,16 @@ The candidate skills are as follows:\n\n`; } private resolveSkillPath(skillPath: string): string { + if (skillPath.startsWith("bundled:")) { + const relativePath = skillPath.slice("bundled:".length); + const root = this.getBundledSkillsRoot(); + const resolvedPath = path.resolve(root, relativePath); + const resolvedRoot = path.resolve(root); + if (resolvedPath === resolvedRoot || !resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)) { + return path.join(root, "__invalid_bundled_skill__"); + } + return resolvedPath; + } if (skillPath.startsWith("~/")) { return path.join(os.homedir(), skillPath.slice(2)); } @@ -731,6 +924,18 @@ The candidate skills are as follows:\n\n`; return path.join(os.homedir(), skillPath); } + private buildSkillPrompt(skill: SkillInfo): string { + const skillPath = this.resolveSkillPath(skill.path); + return buildSkillDocumentsPrompt([ + { + name: skill.name, + content: fs.readFileSync(skillPath, "utf8"), + path: skillPath, + skillFilePath: skillPath, + }, + ]); + } + private readSkillInfo(skillPath: string, displayPath: string, fallbackName: string): SkillInfo { const fallbackSkill: SkillInfo = { name: fallbackName.replace(/_/g, "-"), @@ -741,6 +946,14 @@ The candidate skills are as follows:\n\n`; try { const skillMd = fs.readFileSync(skillPath, "utf8"); const parsed = matter(skillMd); + const metadata = parsed.data.metadata; + const allowImplicitInvocation = + metadata && + typeof metadata === "object" && + !Array.isArray(metadata) && + (metadata as Record)["allow-implicit-invocation"] === false + ? false + : undefined; return { name: typeof parsed.data.name === "string" && parsed.data.name.trim() @@ -748,6 +961,7 @@ The candidate skills are as follows:\n\n`; : fallbackSkill.name, path: displayPath, description: typeof parsed.data.description === "string" ? parsed.data.description.trim() : "", + allowImplicitInvocation, }; } catch { return fallbackSkill; @@ -826,6 +1040,25 @@ The candidate skills are as follows:\n\n`; }); } + private appendSkillMessages(sessionId: string, skills?: SkillInfo[]): void { + if (!skills || skills.length === 0) { + return; + } + + for (const skill of skills) { + if (skill.name === "plan") { + this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_STATUS_MESSAGE)); + } + if (skill.isLoaded) { + continue; + } + const skillPrompt = this.buildSkillPrompt(skill); + const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill); + this.appendSessionMessage(sessionId, skillMessage); + this.onAssistantMessage(skillMessage, true); + } + } + getActiveSessionId(): string | null { return this.activeSessionId; } @@ -866,21 +1099,8 @@ The candidate skills are as follows:\n\n`; const signal = controller?.signal; this.throwIfAborted(signal); - if (userPrompt.text) { - const skills = await this.listSkills(); - const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal }); - this.throwIfAborted(signal); - const skillSet = new Set(skillNames); - const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); - if (Array.isArray(userPrompt.skills)) { - userPrompt.skills.push(...matchedSkill); - } else if (matchedSkill.length > 0) { - userPrompt.skills = matchedSkill; - } - } - userPrompt.skills = await this.normalizeSkills(userPrompt.skills); - this.throwIfAborted(signal); const sessionId = crypto.randomUUID(); + this.ensureFileHistorySession(sessionId); const now = new Date().toISOString(); const index = this.loadSessionsIndex(); const entry: SessionEntry = { @@ -913,14 +1133,19 @@ The candidate skills are as follows:\n\n`; const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); index.entries = keptEntries; this.saveSessionsIndex(index); - this.removeSessionMessages(droppedEntries.map((item) => item.id)); + for (const dropped of droppedEntries) { + this.cleanupSessionResources(dropped.id, { + removeMessages: true, + processIds: this.getProcessIds(dropped.processes ?? null), + }); + } const promptToolOptions = this.getPromptToolOptions(); const systemPrompt = getSystemPrompt(this.projectRoot, promptToolOptions); const systemMessage = this.buildSystemMessage(sessionId, systemPrompt); this.appendSessionMessage(sessionId, systemMessage); - const defaultSkillPrompt = getDefaultSkillPrompt(); + const defaultSkillPrompt = getDefaultSkillPrompt({ enabledSkills: this.getResolvedSettings().enabledSkills }); if (defaultSkillPrompt) { const defaultSkillMessage = this.buildSystemMessage(sessionId, defaultSkillPrompt); this.appendSessionMessage(sessionId, defaultSkillMessage); @@ -938,24 +1163,26 @@ The candidate skills are as follows:\n\n`; this.appendSessionMessage(sessionId, instructionsMessage); } + this.recordUserPromptCheckpoint(sessionId); const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); - if (userPrompt.skills && userPrompt.skills.length > 0) { - for (const skill of userPrompt.skills) { - if (skill.isLoaded) { - continue; - } - const skillMd = fs.readFileSync(this.resolveSkillPath(skill.path), "utf8"); - const skillPrompt = `Use the skill document below to assist the user:\n -<${skill.name}-skill path="${this.resolveSkillPath(skill.path)}"> -${skillMd} -`; - const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill); - this.appendSessionMessage(sessionId, skillMessage); - this.onAssistantMessage(skillMessage, true); + if (userPrompt.text) { + const skills = await this.listSkills(); + const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal }); + this.throwIfAborted(signal); + const skillSet = new Set(skillNames); + const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); + if (Array.isArray(userPrompt.skills)) { + userPrompt.skills.push(...matchedSkill); + } else if (matchedSkill.length > 0) { + userPrompt.skills = matchedSkill; } } + userPrompt.skills = await this.normalizeSkills(userPrompt.skills); + this.throwIfAborted(signal); + + this.appendSkillMessages(sessionId, userPrompt.skills); this.activeSessionId = sessionId; await this.activateSession(sessionId, controller); @@ -965,11 +1192,15 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); + appendProjectPermissionAllows(this.projectRoot, userPrompt.alwaysAllows, { + inheritedPermissions: this.getResolvedSettings().permissions, + }); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "pending", failReason: null, + askPermissions: undefined, updateTime: now, })); @@ -978,14 +1209,29 @@ ${skillMd} return; } + if (hasUserPermissionReplies(userPrompt) && this.hasTrailingPendingToolCalls(sessionId)) { + this.activeSessionId = sessionId; + await this.activateSession(sessionId, controller, userPrompt); + return; + } + if (this.isContinuePrompt(userPrompt)) { this.activeSessionId = sessionId; - await this.activateSession(sessionId, controller); + await this.activateSession(sessionId, controller, userPrompt); return; } this.reportNewPrompt(); + this.ensureFileHistorySession(sessionId); + const checkpoint = this.recordUserPromptCheckpoint(sessionId); + if (checkpoint.changedFilePaths.length) { + const content = `Note that the user manually modified these files:\n${checkpoint.changedFilePaths.join("\n")}`; + this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, content)); + } + const userMessage = this.buildUserMessage(sessionId, userPrompt); + this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { const skills = await this.listSkills(sessionId); const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal, sessionId }); @@ -1001,24 +1247,7 @@ ${skillMd} userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); this.throwIfAborted(signal); - const userMessage = this.buildUserMessage(sessionId, userPrompt); - this.appendSessionMessage(sessionId, userMessage); - - if (userPrompt.skills && userPrompt.skills.length > 0) { - for (const skill of userPrompt.skills) { - if (skill.isLoaded) { - continue; - } - const skillMd = fs.readFileSync(this.resolveSkillPath(skill.path), "utf8"); - const skillPrompt = `Use the skill document below to assist the user:\n -<${skill.name}-skill path="${this.resolveSkillPath(skill.path)}"> -${skillMd} -`; - const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill); - this.appendSessionMessage(sessionId, skillMessage); - this.onAssistantMessage(skillMessage, true); - } - } + this.appendSkillMessages(sessionId, userPrompt.skills); this.activeSessionId = sessionId; await this.activateSession(sessionId, controller); } @@ -1032,23 +1261,28 @@ ${skillMd} ); } - async activateSession(sessionId: string, controller?: AbortController): Promise { + async activateSession( + sessionId: string, + controller?: AbortController, + permissionPrompt?: UserPromptContent + ): Promise { const startedAt = Date.now(); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = + const { client, model, baseURL, temperature, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, env } = this.createOpenAIClient(); const now = new Date().toISOString(); + rebuildSessionStateFromHistory(sessionId, this.listSessionMessages(sessionId)); if (!client) { this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "failed", - failReason: "OpenAI API key not found", + failReason: "API key not found", updateTime: now, })); this.onAssistantMessage( this.buildAssistantMessage( sessionId, - "OpenAI API key not found. Please configure ~/.deepcode/settings.json or ./.deepcode/settings.json.", + "API key not found. Please configure ~/.deepcode/settings.json or ./.deepcode/settings.json.", null ), false @@ -1091,16 +1325,24 @@ ${skillMd} return; } - const pendingToolCalls = this.getTrailingPendingToolCalls(this.listSessionMessages(sessionId)); - if (pendingToolCalls.length > 0) { - const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCalls); + const pendingToolCallMessage = this.messageConverter.getTrailingPendingToolCallMessage( + this.listSessionMessages(sessionId) + ); + if (pendingToolCallMessage.toolCalls.length > 0) { + const toolAppendResult = await this.appendToolMessages(sessionId, pendingToolCallMessage.toolCalls, { + permissionOverrides: permissionPrompt?.permissions, + messagePermissions: pendingToolCallMessage.message?.meta?.permissions, + }); + await this.appendDeferredPermissionPrompt(sessionId, permissionPrompt, sessionController); + // Permission replies are one-shot: do not reuse decisions or append the deferred user prompt again on later tool-call batches. + permissionPrompt = undefined; if (this.isInterrupted(sessionId)) { return; } if (toolAppendResult.waitingForUser) { this.updateSessionEntry(sessionId, (entry) => ({ ...entry, - toolCalls: pendingToolCalls, + toolCalls: pendingToolCallMessage.toolCalls, status: "waiting_for_user", updateTime: new Date().toISOString(), })); @@ -1120,12 +1362,17 @@ ${skillMd} await this.compactSession(sessionId, sessionController.signal); } - const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled, model); + const messages = this.messageConverter.buildMessages( + this.listSessionMessages(sessionId), + thinkingEnabled, + model + ); const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); const response = await this.createChatCompletionStream( client, { model, + ...(temperature !== undefined ? { temperature } : {}), messages, tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions), ...thinkingOptions, @@ -1136,7 +1383,7 @@ ${skillMd} enabled: debugLogEnabled, location: "SessionManager.activateSession", baseURL, - params: { iteration, thinkingEnabled, reasoningEffort }, + params: { iteration, temperature, thinkingEnabled, reasoningEffort }, } ); @@ -1144,7 +1391,7 @@ ${skillMd} const rawContent = message?.content; const content = typeof rawContent === "string" ? rawContent : ""; const rawToolCalls = (message as { tool_calls?: unknown[] } | undefined)?.tool_calls ?? null; - toolCalls = Array.isArray(rawToolCalls) && rawToolCalls.length > 0 ? rawToolCalls : null; + toolCalls = this.normalizeLlmToolCalls(rawToolCalls); const rawThinking = (message as { reasoning_content?: unknown } | undefined)?.reasoning_content; const thinking = typeof rawThinking === "string" ? rawThinking : null; const refusal = (message as { refusal?: string } | undefined)?.refusal ?? null; @@ -1154,12 +1401,48 @@ ${skillMd} return; } const assistantMessage = this.buildAssistantMessage(sessionId, content, toolCalls, thinking); + const permissionPlan = toolCalls + ? computeToolCallPermissions({ + sessionId, + projectRoot: this.projectRoot, + toolCalls, + settings: this.getResolvedSettings().permissions, + readPermissionExemptPaths: this.getSkillScanRoots().map((entry) => entry.root), + resolveSnippetPath: (id, snippetId) => getSnippet(id, snippetId)?.filePath, + }) + : null; + if (permissionPlan) { + assistantMessage.meta = { + ...(assistantMessage.meta ?? {}), + permissions: permissionPlan.permissions, + }; + } this.appendSessionMessage(sessionId, assistantMessage); this.onAssistantMessage(assistantMessage, true); let waitingForUser = false; + const responseUsage = response.usage ?? null; if (toolCalls) { - const toolAppendResult = await this.appendToolMessages(sessionId, toolCalls); + if (permissionPlan?.askPermissions.length) { + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + assistantReply: content, + assistantThinking: thinking, + assistantRefusal: refusal, + toolCalls, + usage: accumulateUsage(entry.usage, responseUsage), + usagePerModel: accumulateUsagePerModel(entry.usagePerModel, model, responseUsage), + activeTokens: getTotalTokens(responseUsage), + status: "ask_permission", + failReason: null, + askPermissions: permissionPlan.askPermissions, + updateTime: new Date().toISOString(), + })); + return; + } + const toolAppendResult = await this.appendToolMessages(sessionId, toolCalls, { + messagePermissions: permissionPlan?.permissions, + }); waitingForUser = toolAppendResult.waitingForUser; } @@ -1167,7 +1450,6 @@ ${skillMd} return; } - const responseUsage = response.usage ?? null; this.updateSessionEntry(sessionId, (entry) => ({ ...entry, assistantReply: content, @@ -1179,6 +1461,7 @@ ${skillMd} activeTokens: getTotalTokens(responseUsage), status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed", failReason: refusal ? refusal : entry.failReason, + askPermissions: undefined, updateTime: new Date().toISOString(), })); @@ -1231,7 +1514,8 @@ ${skillMd} async compactSession(sessionId: string, signal?: AbortSignal): Promise { this.throwIfAborted(signal); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled } = this.createOpenAIClient(); + const { client, model, baseURL, temperature, thinkingEnabled, reasoningEffort, debugLogEnabled } = + this.createOpenAIClient(); if (!client) { return; } @@ -1263,6 +1547,7 @@ ${skillMd} client, { model, + ...(temperature !== undefined ? { temperature } : {}), messages: [{ role: "user", content: compactPrompt }], ...thinkingOptions, }, @@ -1272,7 +1557,7 @@ ${skillMd} enabled: debugLogEnabled, location: "SessionManager.compactSession", baseURL, - params: { thinkingEnabled, reasoningEffort }, + params: { temperature, thinkingEnabled, reasoningEffort }, } ); this.throwIfAborted(signal); @@ -1321,25 +1606,8 @@ ${skillMd} } private reportNewPrompt(): void { - const { machineId } = this.createOpenAIClient(); - if (!machineId) { - return; - } - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), NEW_PROMPT_REPORT_TIMEOUT_MS); - - void fetch(DEFAULT_NEW_PROMPT_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Token: machineId, - }, - body: JSON.stringify({}), - signal: controller.signal, - }) - .catch(() => {}) - .finally(() => clearTimeout(timeout)); + const { machineId, telemetryEnabled } = this.createOpenAIClient(); + reportNewPrompt({ enabled: telemetryEnabled ?? true, machineId }); } interruptActiveSession(): void { @@ -1360,6 +1628,9 @@ ${skillMd} const killedPids: number[] = []; const failedPids: number[] = []; for (const pid of processIds) { + const processControlKey = this.getProcessControlKey(sessionId, pid); + this.processTimeoutControls.delete(processControlKey); + this.liveProcessKeys.delete(processControlKey); if (killProcessTree(pid, "SIGKILL")) { killedPids.push(pid); continue; @@ -1397,6 +1668,51 @@ ${skillMd} return !this.sessionControllers.has(sessionId); } + /** + * Mark a session's permission as denied by the user. + * Updates the session entry status and failReason so the denial is visible in the session list. + */ + denySessionPermission(sessionId: string, reason?: string): void { + const now = new Date().toISOString(); + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + status: "permission_denied", + failReason: reason ?? "Permission denied by user", + updateTime: now, + })); + } + + adjustActiveBashTimeout(deltaMs: number): BashTimeoutAdjustment | null { + const sessionId = this.activeSessionId; + if (!sessionId || !Number.isFinite(deltaMs)) { + return null; + } + const session = this.getSession(sessionId); + if (!session?.processes) { + return null; + } + + let selectedPid: string | null = null; + for (const pid of session.processes.keys()) { + if (this.processTimeoutControls.has(this.getProcessControlKey(sessionId, pid))) { + selectedPid = pid; + } + } + if (!selectedPid) { + return null; + } + + const control = this.processTimeoutControls.get(this.getProcessControlKey(sessionId, selectedPid)); + if (!control) { + return null; + } + + const current = control.getInfo(); + const next = control.setTimeoutMs(current.timeoutMs + deltaMs); + this.updateSessionProcessTimeout(sessionId, selectedPid, next); + return this.buildBashTimeoutAdjustment(selectedPid, next); + } + listSessions(): SessionEntry[] { const index = this.loadSessionsIndex(); return index.entries; @@ -1407,6 +1723,51 @@ ${skillMd} return index.entries.find((entry) => entry.id === sessionId) ?? null; } + /** + * Delete a session by its ID. + * Removes the session entry from the index and cleans up associated resources + * such as message files, in-memory state caches, working directory state, + * session controllers, and tracked process timeout controls. + * Returns true if the session was found and deleted, false otherwise. + */ + deleteSession(sessionId: string): boolean { + const index = this.loadSessionsIndex(); + const targetEntry = index.entries.find((entry) => entry.id === sessionId) ?? null; + const nextEntries = index.entries.filter((entry) => entry.id !== sessionId); + if (nextEntries.length === index.entries.length) { + return false; + } + + index.entries = nextEntries; + this.saveSessionsIndex(index); + this.cleanupSessionResources(sessionId, { + removeMessages: true, + processIds: this.getProcessIds(targetEntry?.processes ?? null), + }); + return true; + } + + /** + * Rename a session by updating its summary (display title). + * Returns true if the session was found and renamed, false otherwise. + */ + renameSession(sessionId: string, summary: string): boolean { + const trimmed = summary.trim(); + if (!trimmed) { + return false; + } + const entry = this.getSession(sessionId); + if (!entry) { + return false; + } + this.updateSessionEntry(sessionId, (existing) => ({ + ...existing, + summary: trimmed, + updateTime: new Date().toISOString(), + })); + return true; + } + listSessionMessages(sessionId: string): SessionMessage[] { const messagePath = this.getSessionMessagesPath(sessionId); if (!fs.existsSync(messagePath)) { @@ -1427,6 +1788,61 @@ ${skillMd} return messages; } + listUndoTargets(sessionId: string): UndoTarget[] { + return this.listSessionMessages(sessionId) + .map((message, index) => ({ message, index })) + .filter(({ message }) => this.isUndoTargetMessage(message)) + .map(({ message, index }) => ({ + message, + index, + canRestoreCode: Boolean( + message.checkpointHash && this.canRestoreCheckpointHash(sessionId, message.checkpointHash) + ), + })); + } + + restoreSessionConversation(sessionId: string, messageId: string): SessionMessage[] { + const messages = this.listSessionMessages(sessionId); + const targetIndex = messages.findIndex((message) => message.id === messageId); + if (targetIndex === -1) { + throw new Error("Selected message was not found in this session."); + } + + const keptMessages = messages.slice(0, targetIndex); + this.saveSessionMessages(sessionId, keptMessages); + const now = new Date().toISOString(); + const latestAssistant = [...keptMessages].reverse().find((message) => message.role === "assistant"); + const latestAssistantParams = latestAssistant?.messageParams as + | { tool_calls?: unknown[]; reasoning_content?: string } + | null + | undefined; + + this.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + assistantReply: latestAssistant?.content ?? null, + assistantThinking: + typeof latestAssistantParams?.reasoning_content === "string" ? latestAssistantParams.reasoning_content : null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + processes: null, + updateTime: now, + })); + return keptMessages; + } + + restoreSessionCode(sessionId: string, messageId: string): void { + const message = this.listSessionMessages(sessionId).find((item) => item.id === messageId); + if (!message) { + throw new Error("Selected message was not found in this session."); + } + if (!message.checkpointHash) { + throw new Error("Selected message has no code checkpoint."); + } + this.restoreCheckpointHash(sessionId, message.checkpointHash); + } + private normalizeSessionMessage(message: SessionMessage): SessionMessage { if (message.role !== "tool") { return message; @@ -1450,21 +1866,89 @@ ${skillMd} }; } - private getProjectCode(projectRoot: string): string { - return projectRoot.replace(/[\\/]/g, "-").replace(/:/g, ""); - } - private getProjectStorage(): { projectCode: string; projectDir: string; sessionsIndexPath: string; } { - const projectCode = this.getProjectCode(this.projectRoot); + const projectCode = getProjectCode(this.projectRoot); const projectDir = path.join(os.homedir(), ".deepcode", "projects", projectCode); const sessionsIndexPath = path.join(projectDir, "sessions-index.json"); return { projectCode, projectDir, sessionsIndexPath }; } + private getFileHistory(): GitFileHistory { + return new GitFileHistory(this.projectRoot, this.getFileHistoryGitDir()); + } + + private getFileHistoryGitDir(): string { + const { projectDir } = this.getProjectStorage(); + return path.join(projectDir, "file-history", ".git"); + } + + private ensureFileHistorySession(sessionId: string): string | undefined { + return this.getFileHistory().ensureSession(sessionId); + } + + private getCurrentCheckpointHash(sessionId: string): string | undefined { + return this.getFileHistory().getCurrentCheckpointHash(sessionId); + } + + private recordUserPromptCheckpoint(sessionId: string): FileHistoryCheckpointResult { + return this.getFileHistory().recordTrackedFilesCheckpoint(sessionId, "User prompt checkpoint"); + } + + private prepareFileMutationCheckpoint(sessionId: string, filePath: string): void { + const fileHistory = this.getFileHistory(); + const previousHash = fileHistory.ensureSession(sessionId); + if (!previousHash) { + return; + } + this.updateLatestUserCheckpointHash(sessionId, undefined, previousHash); + const nextHash = fileHistory.recordCheckpoint(sessionId, [filePath], "Pre-mutation checkpoint"); + if (nextHash && nextHash !== previousHash) { + this.updateLatestUserCheckpointHash(sessionId, previousHash, nextHash); + } + } + + private recordFileMutationCheckpoint(sessionId: string, filePath: string): void { + const fileHistory = this.getFileHistory(); + fileHistory.ensureSession(sessionId); + fileHistory.recordCheckpoint(sessionId, [filePath], "File mutation checkpoint"); + } + + private updateLatestUserCheckpointHash(sessionId: string, previousHash: string | undefined, nextHash: string): void { + const messages = this.listSessionMessages(sessionId); + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (!message || !this.isUndoTargetMessage(message)) { + continue; + } + if (message.checkpointHash && message.checkpointHash !== previousHash) { + return; + } + messages[index] = { + ...message, + checkpointHash: nextHash, + updateTime: new Date().toISOString(), + }; + this.saveSessionMessages(sessionId, messages); + return; + } + } + + private canRestoreCheckpointHash(sessionId: string, checkpointHash: string): boolean { + return this.getFileHistory().canRestore(sessionId, checkpointHash); + } + + private restoreCheckpointHash(sessionId: string, checkpointHash: string): void { + this.getFileHistory().restore(sessionId, checkpointHash); + } + + private isUndoTargetMessage(message: SessionMessage): boolean { + return message.role === "user" && message.visible && !message.compacted; + } + private ensureProjectDir(): string { const { projectDir } = this.getProjectStorage(); fs.mkdirSync(projectDir, { recursive: true }); @@ -1527,6 +2011,32 @@ ${skillMd} } } + private cleanupSessionResources( + sessionId: string, + options: { removeMessages: boolean; processIds?: number[] } + ): void { + const processIds = options.processIds ?? []; + for (const pid of processIds) { + const processControlKey = this.getProcessControlKey(sessionId, pid); + if (!this.processTimeoutControls.has(processControlKey) && !this.liveProcessKeys.has(processControlKey)) { + continue; + } + + this.killTrackedProcess(processControlKey, pid); + } + + clearSessionState(sessionId); + clearSessionWorkingDir(sessionId); + const controller = this.sessionControllers.get(sessionId); + if (controller && !controller.signal.aborted) { + controller.abort(); + } + this.sessionControllers.delete(sessionId); + if (options.removeMessages) { + this.removeSessionMessages([sessionId]); + } + } + private appendSessionMessage(sessionId: string, message: SessionMessage): void { this.ensureProjectDir(); const messagePath = this.getSessionMessagesPath(sessionId); @@ -1575,6 +2085,8 @@ ${skillMd} visible: true, createTime: now, updateTime: now, + meta: { userPrompt: this.cloneUserPromptForMeta(prompt) }, + checkpointHash: this.getCurrentCheckpointHash(sessionId), }; } @@ -1707,6 +2219,33 @@ ${skillMd} }; } + private generateToolCallId(): string { + return crypto.randomBytes(16).toString("hex"); + } + + private normalizeLlmToolCalls(rawToolCalls: unknown[] | null | undefined): unknown[] | null { + if (!Array.isArray(rawToolCalls) || rawToolCalls.length === 0) { + return null; + } + + return rawToolCalls.map((toolCall) => { + if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) { + return toolCall; + } + + const record = toolCall as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + if (id) { + return toolCall; + } + + return { + ...record, + id: this.generateToolCallId(), + }; + }); + } + private buildToolMessage( sessionId: string, toolCallId: string, @@ -1736,13 +2275,40 @@ ${skillMd} }; } - private async appendToolMessages(sessionId: string, toolCalls: unknown[]): Promise<{ waitingForUser: boolean }> { - const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { + private async appendToolMessages( + sessionId: string, + toolCalls: unknown[], + options: { + permissionOverrides?: UserToolPermission[]; + messagePermissions?: MessageToolPermission[]; + } = {} + ): Promise<{ waitingForUser: boolean }> { + const hooks: ToolExecutionHooks = { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk), + onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control), + onBackgroundProcessComplete: (completion) => this.addBackgroundProcessCompletionMessage(sessionId, completion), + onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath), + onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath), shouldStop: () => this.isInterrupted(sessionId), - }); + }; + const parsedToolCalls = toolCalls + .map((toolCall) => parseToolCallForPermissions(toolCall)) + .filter((toolCall): toolCall is PermissionToolCall => Boolean(toolCall)); + const toolExecutions: ToolCallExecution[] = []; + for (const toolCall of parsedToolCalls) { + if (hooks.shouldStop?.()) { + break; + } + const blockedResult = buildPermissionToolExecution(toolCall, options); + if (blockedResult) { + toolExecutions.push(blockedResult); + continue; + } + const executions = await this.toolExecutor.executeToolCalls(sessionId, [toolCall], hooks); + toolExecutions.push(...executions); + } if (this.isInterrupted(sessionId)) { return { waitingForUser: false }; } @@ -1752,7 +2318,7 @@ ${skillMd} if (execution.result.awaitUserResponse === true) { waitingForUser = true; } - const toolFunction = this.findToolFunction(toolCalls, execution.toolCallId); + const toolFunction = this.messageConverter.findToolFunction(toolCalls, execution.toolCallId); const toolMessage = this.buildToolMessage(sessionId, execution.toolCallId, execution.content, toolFunction); this.appendSessionMessage(sessionId, toolMessage); this.onAssistantMessage(toolMessage, true); @@ -1773,234 +2339,57 @@ ${skillMd} return { waitingForUser }; } - private buildOpenAIMessages( - messages: SessionMessage[], - thinkingEnabled: boolean, - model: string - ): ChatCompletionMessageParam[] { - const activeMessages = messages.filter((message) => !message.compacted); - const toolPairings = this.pairToolMessages(activeMessages); - const openAIMessages: ChatCompletionMessageParam[] = []; - - for (let index = 0; index < activeMessages.length; index += 1) { - const message = activeMessages[index]; - if (message.role === "tool") { - continue; - } - - openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled, model)); - - const toolCalls = this.getAssistantToolCalls(message); - if (toolCalls.length === 0) { - continue; - } - - for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { - const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); - if (!toolCallId) { - continue; - } - - const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex)); - if (pairedToolIndex != null) { - openAIMessages.push( - this.sessionMessageToOpenAIMessage(activeMessages[pairedToolIndex], thinkingEnabled, model) - ); - continue; - } - - openAIMessages.push(this.buildInterruptedOpenAIToolMessage(toolCalls, toolCallId)); - } - } - - return openAIMessages; - } - - private sessionMessageToOpenAIMessage( - message: SessionMessage, - thinkingEnabled: boolean, - model: string - ): ChatCompletionMessageParam { - const content = this.renderOpenAIMessageContent(message); - const base: ChatCompletionMessageParam = { - role: message.role, - content, - } as ChatCompletionMessageParam; - - const messageParams = message.messageParams as - | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } - | null - | undefined; - if (messageParams?.tool_calls) { - (base as { tool_calls?: unknown[] }).tool_calls = messageParams.tool_calls; - } - if (messageParams?.tool_call_id) { - (base as { tool_call_id?: string }).tool_call_id = messageParams.tool_call_id; - } - if (typeof messageParams?.reasoning_content === "string") { - (base as { reasoning_content?: string }).reasoning_content = messageParams.reasoning_content; - } else if (thinkingEnabled && message.role === "assistant") { - // Thinking-mode providers require every replayed assistant message - // to include the reasoning_content field, even when it is empty. - (base as { reasoning_content?: string }).reasoning_content = ""; - } - - if ((message.role === "user" || message.role === "system") && message.contentParams) { - const contentParts: ChatCompletionContentPart[] = []; - if (content) { - contentParts.push({ type: "text", text: content }); - } - const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; - for (const param of params) { - const part = param as ChatCompletionContentPart; - if (part && (part.type !== "image_url" || supportsMultimodal(model))) { - contentParts.push(part); - } - } - const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content; - (base as { content: string | ChatCompletionContentPart[] }).content = contentValue; - } - - return base; - } - - private renderOpenAIMessageContent(message: SessionMessage): string { - if (message.role === "user" && message.content === "/init") { - return this.renderInitCommandPrompt(); - } - return message.content ?? ""; - } - - private pairToolMessages(messages: SessionMessage[]): Map { - const pairings = new Map(); - const usedToolMessageIndexes = new Set(); - - for (let assistantIndex = 0; assistantIndex < messages.length; assistantIndex += 1) { - const toolCalls = this.getAssistantToolCalls(messages[assistantIndex]); - for (let toolCallIndex = 0; toolCallIndex < toolCalls.length; toolCallIndex += 1) { - const toolCallId = this.getToolCallId(toolCalls[toolCallIndex]); - if (!toolCallId) { - continue; - } - - const toolIndex = this.findPairableToolMessageIndex( - messages, - assistantIndex, - toolCallId, - usedToolMessageIndexes - ); - if (toolIndex == null) { - continue; - } - - usedToolMessageIndexes.add(toolIndex); - pairings.set(this.buildToolPairingKey(assistantIndex, toolCallIndex), toolIndex); - } - } - - return pairings; - } - - private getTrailingPendingToolCalls(messages: SessionMessage[]): unknown[] { - const activeMessages = messages.filter((message) => !message.compacted); - const latestMessage = activeMessages[activeMessages.length - 1]; - if (!latestMessage || latestMessage.role !== "assistant") { - return []; - } - - const toolCalls = this.getAssistantToolCalls(latestMessage); - if (toolCalls.length === 0) { - return []; - } - return toolCalls.filter((toolCall) => Boolean(this.getToolCallId(toolCall))); - } - - private findPairableToolMessageIndex( - messages: SessionMessage[], - assistantIndex: number, - toolCallId: string, - usedToolMessageIndexes: Set - ): number | null { - let firstMatchingIndex: number | null = null; - for (let index = assistantIndex + 1; index < messages.length; index += 1) { - const message = messages[index]; - if (message.role !== "tool" || usedToolMessageIndexes.has(index)) { - continue; - } - - const candidateToolCallId = this.getToolMessageCallId(message); - if (candidateToolCallId !== toolCallId) { - continue; - } - - if (firstMatchingIndex == null) { - firstMatchingIndex = index; - } - if (!this.isInterruptedToolMessage(message)) { - return index; - } - } - return firstMatchingIndex; - } - - private getAssistantToolCalls(message: SessionMessage): unknown[] { - if (message.role !== "assistant") { - return []; - } - const messageParams = message.messageParams as { tool_calls?: unknown[] } | null; - return Array.isArray(messageParams?.tool_calls) ? messageParams.tool_calls : []; - } - - private getToolCallId(toolCall: unknown): string | null { - if (!toolCall || typeof toolCall !== "object") { - return null; - } - const id = (toolCall as { id?: unknown }).id; - return typeof id === "string" && id ? id : null; - } - - private getToolMessageCallId(message: SessionMessage): string | null { - const messageParams = message.messageParams as { tool_call_id?: unknown } | null; - const toolCallId = messageParams?.tool_call_id; - return typeof toolCallId === "string" && toolCallId ? toolCallId : null; + private cloneUserPromptForMeta(prompt: UserPromptContent): UserPromptContent { + return { + text: prompt.text, + imageUrls: prompt.imageUrls ? [...prompt.imageUrls] : undefined, + skills: prompt.skills ? prompt.skills.map((skill) => ({ ...skill })) : undefined, + permissions: prompt.permissions ? prompt.permissions.map((permission) => ({ ...permission })) : undefined, + alwaysAllows: prompt.alwaysAllows ? [...prompt.alwaysAllows] : undefined, + }; } - private buildToolPairingKey(assistantIndex: number, toolCallIndex: number): string { - return `${assistantIndex}:${toolCallIndex}`; + private hasTrailingPendingToolCalls(sessionId: string): boolean { + return ( + this.messageConverter.getTrailingPendingToolCallMessage(this.listSessionMessages(sessionId)).toolCalls.length > 0 + ); } - private isInterruptedToolMessage(message: SessionMessage): boolean { - if (typeof message.content !== "string" || !message.content.trim()) { - return false; + private async appendDeferredPermissionPrompt( + sessionId: string, + userPrompt: UserPromptContent | undefined, + controller: AbortController + ): Promise { + if (!userPrompt || this.isContinuePrompt(userPrompt)) { + return; } - try { - const parsed = JSON.parse(message.content) as { metadata?: { interrupted?: unknown } }; - return parsed.metadata?.interrupted === true; - } catch { - return false; + const text = userPrompt.text ?? ""; + const hasUserContent = + text.trim().length > 0 || + (Array.isArray(userPrompt.imageUrls) && userPrompt.imageUrls.length > 0) || + (Array.isArray(userPrompt.skills) && userPrompt.skills.length > 0); + if (!hasUserContent) { + return; } - } - - private buildInterruptedOpenAIToolMessage(toolCalls: unknown[], toolCallId: string): ChatCompletionMessageParam { - const toolFunction = this.findToolFunction(toolCalls, toolCallId); - return { - role: "tool", - content: this.buildInterruptedToolResult(toolFunction, "Previous tool call did not complete."), - tool_call_id: toolCallId, - } as ChatCompletionMessageParam; - } - - private findToolFunction(toolCalls: unknown[], toolCallId: string): unknown | null { - for (const toolCall of toolCalls) { - if (!toolCall || typeof toolCall !== "object") { - continue; - } - const record = toolCall as { id?: unknown; function?: unknown }; - if (record.id === toolCallId) { - return record.function ?? null; + this.reportNewPrompt(); + const signal = controller.signal; + const userMessage = this.buildUserMessage(sessionId, userPrompt); + this.appendSessionMessage(sessionId, userMessage); + if (userPrompt.text) { + const skills = await this.listSkills(sessionId); + const skillNames = await this.identifyMatchingSkillNames(skills, userPrompt.text, { signal, sessionId }); + this.throwIfAborted(signal); + const skillSet = new Set(skillNames); + const matchedSkill = skills.filter((skill) => skillSet.has(skill.name)); + if (Array.isArray(userPrompt.skills)) { + userPrompt.skills.push(...matchedSkill); + } else if (matchedSkill.length > 0) { + userPrompt.skills = matchedSkill; } } - return null; + userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId); + this.throwIfAborted(signal); + this.appendSkillMessages(sessionId, userPrompt.skills); } private buildToolParamsSnippet(toolFunction: unknown | null): string { @@ -2047,6 +2436,12 @@ ${skillMd} return typeof args.explanation === "string" ? args.explanation.trim() : ""; } else if (toolName === "write") { return typeof args.file_path === "string" ? args.file_path.trim() : ""; + } else if (toolName === "edit") { + const filePath = typeof args.file_path === "string" ? args.file_path.trim() : ""; + if (filePath) { + return filePath; + } + return typeof args.snippet_id === "string" ? args.snippet_id.trim() : ""; } const firstKey = Object.keys(args)[0]; @@ -2119,11 +2514,28 @@ ${skillMd} return; } - launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv); + // Find the last assistant message body for the BODY env variable. + let body: string | undefined; + const messages = this.listSessionMessages(sessionId); + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg && msg.role === "assistant" && msg.content) { + body = msg.content; + break; + } + } + + launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, { + status: session.status, + failReason: session.failReason ?? undefined, + body, + title: session.summary ?? undefined, + }); } private addSessionProcess(sessionId: string, processId: string | number, command: string): void { const now = new Date().toISOString(); + this.liveProcessKeys.add(this.getProcessControlKey(sessionId, processId)); this.updateSessionEntry(sessionId, (entry) => { const processes = new Map(entry.processes ?? []); processes.set(String(processId), { startTime: now, command }); @@ -2135,8 +2547,86 @@ ${skillMd} }); } + private addBackgroundProcessCompletionMessage( + sessionId: string, + completion: { + command: string; + outputPath: string; + ok: boolean; + exitCode: number | null; + signal: string | null; + error?: string; + completedAtMs: number; + startedAtMs: number; + } + ): void { + const status = completion.ok ? "completed" : "failed"; + const exitText = + completion.exitCode !== null + ? `exit code ${completion.exitCode}` + : completion.signal + ? `signal ${completion.signal}` + : completion.error || "unknown status"; + const durationMs = Math.max(0, completion.completedAtMs - completion.startedAtMs); + const baseContent = + `Background command "${completion.command}" ${status} with ${exitText} ` + + `after ${this.formatBackgroundDuration(durationMs)}. Output: ${completion.outputPath}`; + const logTail = completion.ok ? null : this.buildBackgroundFailureLogTailSlice(completion.outputPath); + const content = logTail ? `${baseContent}\n${logTail}` : baseContent; + this.addSessionSystemMessage(sessionId, content, true); + } + + private buildBackgroundFailureLogTailSlice(outputPath: string): string | null { + const tail = this.readTextFileTail(outputPath, BACKGROUND_FAILURE_LOG_TAIL_CHARS); + if (!tail || !tail.content) { + return null; + } + const prefix = tail.truncated ? `(${tail.totalBytes} bytes)...\n` : ""; + return [ + ``, + `${prefix}${tail.content}`, + "", + ].join("\n"); + } + + private readTextFileTail( + filePath: string, + maxChars: number + ): { content: string; totalBytes: number; truncated: boolean } | null { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size <= 0) { + return null; + } + const content = readTextFileWithMetadata(filePath).content; + return { + content: content.slice(-maxChars).trimEnd(), + totalBytes: stat.size, + truncated: content.length > maxChars, + }; + } catch { + return null; + } + } + + private formatBackgroundDuration(durationMs: number): string { + if (durationMs < 1000) { + return `${durationMs}ms`; + } + const seconds = Math.round(durationMs / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + } + private removeSessionProcess(sessionId: string, processId: string | number): void { const now = new Date().toISOString(); + const processControlKey = this.getProcessControlKey(sessionId, processId); + this.processTimeoutControls.delete(processControlKey); + this.liveProcessKeys.delete(processControlKey); this.updateSessionEntry(sessionId, (entry) => { const processes = new Map(entry.processes ?? []); processes.delete(String(processId)); @@ -2148,7 +2638,89 @@ ${skillMd} }); } - private getProcessIds(processes: Map | null): number[] { + private setSessionProcessTimeoutControl( + sessionId: string, + processId: string | number, + control: ProcessTimeoutControl | null + ): void { + const key = this.getProcessControlKey(sessionId, processId); + if (!control) { + this.processTimeoutControls.delete(key); + return; + } + + this.processTimeoutControls.set(key, control); + this.updateSessionProcessTimeout(sessionId, processId, control.getInfo()); + } + + private updateSessionProcessTimeout(sessionId: string, processId: string | number, info: ProcessTimeoutInfo): void { + const now = new Date().toISOString(); + this.updateSessionEntry(sessionId, (entry) => { + const processes = new Map(entry.processes ?? []); + const pid = String(processId); + const processInfo = processes.get(pid); + if (!processInfo) { + return entry; + } + processes.set(pid, { + ...processInfo, + timeoutMs: info.timeoutMs, + deadlineAt: new Date(info.deadlineAtMs).toISOString(), + timedOut: info.timedOut, + }); + return { + ...entry, + processes, + updateTime: now, + }; + }); + } + + private buildBashTimeoutAdjustment(processId: string, info: ProcessTimeoutInfo): BashTimeoutAdjustment { + return { + processId, + timeoutMs: info.timeoutMs, + deadlineAt: new Date(info.deadlineAtMs).toISOString(), + timedOut: info.timedOut, + }; + } + + private getProcessControlKey(sessionId: string, processId: string | number): string { + return `${sessionId}:${String(processId)}`; + } + + private killLiveProcesses(): void { + for (const processControlKey of Array.from(this.liveProcessKeys)) { + const processId = this.getProcessIdFromControlKey(processControlKey); + if (processId === null) { + this.liveProcessKeys.delete(processControlKey); + continue; + } + this.killTrackedProcess(processControlKey, processId); + } + } + + private killTrackedProcess(processControlKey: string, processId: number): void { + const killedGroup = killProcessTree(processId, "SIGKILL"); + if (!killedGroup) { + try { + process.kill(processId, "SIGKILL"); + } catch { + // Ignore process-kill failures during cleanup. + } + } + this.processTimeoutControls.delete(processControlKey); + this.liveProcessKeys.delete(processControlKey); + } + + private getProcessIdFromControlKey(processControlKey: string): number | null { + const separatorIndex = processControlKey.lastIndexOf(":"); + const rawProcessId = separatorIndex >= 0 ? processControlKey.slice(separatorIndex + 1) : processControlKey; + const processId = Number(rawProcessId); + return Number.isInteger(processId) && processId > 0 ? processId : null; + } + + private getProcessIds(processes: Map | null): number[] { if (!processes) { return []; } @@ -2162,25 +2734,6 @@ ${skillMd} return ids; } - private buildInterruptedToolResult(toolFunction: unknown | null, reason: string): string { - const toolName = - toolFunction && typeof toolFunction === "object" && typeof (toolFunction as { name?: unknown }).name === "string" - ? (toolFunction as { name: string }).name - : "tool"; - return JSON.stringify( - { - ok: false, - name: toolName, - error: reason, - metadata: { - interrupted: true, - }, - }, - null, - 2 - ); - } - private normalizeSessionEntry(entry: unknown): SessionEntry { const value = entry && typeof entry === "object" ? (entry as Record) : {}; return { @@ -2198,6 +2751,7 @@ ${skillMd} createTime: typeof value.createTime === "string" ? value.createTime : new Date().toISOString(), updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(), processes: this.deserializeProcesses(value.processes), + askPermissions: normalizeAskPermissions(value.askPermissions), }; } @@ -2208,7 +2762,9 @@ ${skillMd} status === "processing" || status === "waiting_for_user" || status === "completed" || - status === "interrupted" + status === "interrupted" || + status === "ask_permission" || + status === "permission_denied" ) { return status; } @@ -2232,11 +2788,11 @@ ${skillMd} return usagePerModel; } - private deserializeProcesses(value: unknown): Map | null { + private deserializeProcesses(value: unknown): Map | null { if (!value || typeof value !== "object") { return null; } - const processes = new Map(); + const processes = new Map(); for (const [pid, entry] of Object.entries(value as Record)) { if (!pid) { continue; @@ -2245,22 +2801,34 @@ ${skillMd} // Backward compatibility for old format where just stored start time processes.set(pid, { startTime: entry, command: "Running process..." }); } else if (typeof entry === "object" && entry !== null) { - const obj = entry as { startTime?: unknown; command?: unknown }; + const obj = entry as { + startTime?: unknown; + command?: unknown; + timeoutMs?: unknown; + deadlineAt?: unknown; + timedOut?: unknown; + }; const startTime = typeof obj.startTime === "string" ? obj.startTime : new Date().toISOString(); const command = typeof obj.command === "string" ? obj.command : "Running process..."; - processes.set(pid, { startTime, command }); + processes.set(pid, { + startTime, + command, + timeoutMs: typeof obj.timeoutMs === "number" ? obj.timeoutMs : undefined, + deadlineAt: typeof obj.deadlineAt === "string" ? obj.deadlineAt : undefined, + timedOut: typeof obj.timedOut === "boolean" ? obj.timedOut : undefined, + }); } } return processes.size > 0 ? processes : null; } private serializeProcesses( - processes: Map | null - ): Record | null { + processes: Map | null + ): Record | null { if (!processes || processes.size === 0) { return null; } - const serialized: Record = {}; + const serialized: Record = {}; for (const [pid, entry] of processes.entries()) { serialized[pid] = entry; } diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts new file mode 100644 index 00000000..5dab3b5a --- /dev/null +++ b/packages/core/src/settings.ts @@ -0,0 +1,677 @@ +import { defaultsToThinkingMode } from "./common/model-capabilities"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +export type DeepcodingEnv = Record & { + MODEL?: string; + BASE_URL?: string; + API_KEY?: string; + TEMPERATURE?: string; + THINKING_ENABLED?: string; + REASONING_EFFORT?: string; + DEBUG_LOG_ENABLED?: string; + TELEMETRY_ENABLED?: string; +}; + +export type ReasoningEffort = "high" | "max"; + +export type McpServerConfig = { + command: string; + args?: string[]; + env?: Record; +}; + +export type PermissionScope = + | "read-in-cwd" + | "read-out-cwd" + | "write-in-cwd" + | "write-out-cwd" + | "delete-in-cwd" + | "delete-out-cwd" + | "query-git-log" + | "mutate-git-log" + | "network" + | "mcp"; + +export type PermissionDefaultMode = "allowAll" | "askAll"; + +export type PermissionSettings = { + allow?: PermissionScope[]; + deny?: PermissionScope[]; + ask?: PermissionScope[]; + defaultMode?: PermissionDefaultMode; +}; + +export type EnabledSkillsSettings = Record; + +export type StatusLineProviderConfig = + | { + type: "command"; + id?: string; + command: string; + cwd?: string; + timeoutMs?: number; + color?: string; + newLine?: boolean; + maxLength?: number; + } + | { + type: "module"; + id?: string; + path: string; + timeoutMs?: number; + color?: string; + newLine?: boolean; + maxLength?: number; + }; + +export type StatusLineSettings = { + enabled?: boolean; + refreshMs?: number; + separator?: string; + providers?: StatusLineProviderConfig[]; +}; + +export type ResolvedStatusLineSettings = { + enabled: boolean; + refreshMs: number; + separator: string; + providers: StatusLineProviderConfig[]; +}; + +export type DeepcodingSettings = { + env?: DeepcodingEnv; + model?: string; + temperature?: number; + thinkingEnabled?: boolean; + reasoningEffort?: ReasoningEffort; + debugLogEnabled?: boolean; + telemetryEnabled?: boolean; + notify?: string; + webSearchTool?: string; + mcpServers?: Record; + permissions?: PermissionSettings; + enabledSkills?: EnabledSkillsSettings; + statusline?: StatusLineSettings; +}; + +export type ResolvedDeepcodingSettings = { + env: Record; + apiKey?: string; + baseURL: string; + model: string; + temperature?: number; + thinkingEnabled: boolean; + reasoningEffort: ReasoningEffort; + debugLogEnabled: boolean; + telemetryEnabled: boolean; + notify?: string; + webSearchTool?: string; + mcpServers?: Record; + permissions: Required; + enabledSkills: EnabledSkillsSettings; + statusline: ResolvedStatusLineSettings; +}; + +export type ModelConfigSelection = { + model: string; + thinkingEnabled: boolean; + reasoningEffort: ReasoningEffort; +}; + +export type SettingsProcessEnv = Record; + +function resolveReasoningEffort(value: unknown): ReasoningEffort | undefined { + return value === "high" || value === "max" ? value : undefined; +} + +function parseBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") { + return value; + } + if (typeof value !== "string") { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + if (["1", "true", "enabled", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "disabled", "no", "off"].includes(normalized)) { + return false; + } + return undefined; +} + +function parseTemperature(value: unknown): number | undefined { + const raw = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : NaN; + if (!Number.isFinite(raw) || raw < 0 || raw > 2) { + return undefined; + } + return raw; +} + +function trimString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +const VALID_PERMISSION_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +function normalizePermissionList(value: unknown): PermissionScope[] { + if (!Array.isArray(value)) { + return []; + } + const result: PermissionScope[] = []; + for (const item of value) { + if (typeof item !== "string" || !VALID_PERMISSION_SCOPES.has(item as PermissionScope)) { + continue; + } + const scope = item as PermissionScope; + if (!result.includes(scope)) { + result.push(scope); + } + } + return result; +} + +function mergePermissionLists(...lists: Array): PermissionScope[] { + const result: PermissionScope[] = []; + for (const list of lists) { + for (const scope of list ?? []) { + if (!result.includes(scope)) { + result.push(scope); + } + } + } + return result; +} + +function normalizePermissionDefaultMode(value: unknown): PermissionDefaultMode | undefined { + return value === "allowAll" || value === "askAll" ? value : undefined; +} + +function normalizePermissions(settings: PermissionSettings | null | undefined): Required { + return { + allow: normalizePermissionList(settings?.allow), + deny: normalizePermissionList(settings?.deny), + ask: normalizePermissionList(settings?.ask), + defaultMode: normalizePermissionDefaultMode(settings?.defaultMode) ?? "allowAll", + }; +} + +function mergePermissions( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined +): Required { + const userPermissions = normalizePermissions(userSettings?.permissions); + const projectPermissions = normalizePermissions(projectSettings?.permissions); + return { + allow: mergePermissionLists(userPermissions.allow, projectPermissions.allow), + deny: mergePermissionLists(userPermissions.deny, projectPermissions.deny), + ask: mergePermissionLists(userPermissions.ask, projectPermissions.ask), + defaultMode: projectSettings?.permissions + ? projectPermissions.defaultMode + : userSettings?.permissions + ? userPermissions.defaultMode + : "allowAll", + }; +} + +function normalizeEnabledSkills(value: unknown): EnabledSkillsSettings { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + const result: EnabledSkillsSettings = {}; + for (const [name, enabled] of Object.entries(value)) { + if (!name || typeof enabled !== "boolean") { + continue; + } + result[name] = enabled; + } + return result; +} + +function mergeEnabledSkills( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined +): EnabledSkillsSettings { + return { + ...normalizeEnabledSkills(userSettings?.enabledSkills), + ...normalizeEnabledSkills(projectSettings?.enabledSkills), + }; +} + +const DEFAULT_STATUSLINE_REFRESH_MS = 2000; +const MIN_STATUSLINE_REFRESH_MS = 500; +const DEFAULT_STATUSLINE_SEPARATOR = " · "; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeStatusLineProvider(value: unknown): StatusLineProviderConfig | null { + if (!isPlainObject(value)) { + return null; + } + const type = value["type"]; + const idRaw = trimString(value["id"]); + const id = idRaw || undefined; + const timeoutRaw = value["timeoutMs"]; + const timeoutMs = + typeof timeoutRaw === "number" && Number.isFinite(timeoutRaw) && timeoutRaw > 0 + ? Math.floor(timeoutRaw) + : undefined; + const colorRaw = trimString(value["color"]); + const color = colorRaw || undefined; + const maxLengthRaw = value["maxLength"]; + const maxLength = + typeof maxLengthRaw === "number" && Number.isFinite(maxLengthRaw) && maxLengthRaw > 0 + ? Math.floor(maxLengthRaw) + : undefined; + const newLine = value["newLine"] === true ? true : undefined; + + if (type === "command") { + const command = trimString(value["command"]); + if (!command) { + return null; + } + const cwdRaw = trimString(value["cwd"]); + return { + type: "command", + id, + command, + cwd: cwdRaw || undefined, + timeoutMs, + color, + newLine, + maxLength, + }; + } + if (type === "module") { + const modulePath = trimString(value["path"]); + if (!modulePath) { + return null; + } + return { + type: "module", + id, + path: modulePath, + timeoutMs, + color, + newLine, + maxLength, + }; + } + return null; +} + +function normalizeStatusLine(value: unknown): StatusLineSettings | null { + if (!isPlainObject(value)) { + return null; + } + const result: StatusLineSettings = {}; + const enabled = parseBoolean(value["enabled"]); + if (enabled !== undefined) { + result.enabled = enabled; + } + const refreshRaw = value["refreshMs"]; + if (typeof refreshRaw === "number" && Number.isFinite(refreshRaw) && refreshRaw >= MIN_STATUSLINE_REFRESH_MS) { + result.refreshMs = Math.floor(refreshRaw); + } + const separator = value["separator"]; + if (typeof separator === "string") { + result.separator = separator; + } + const providers = value["providers"]; + if (Array.isArray(providers)) { + const normalized: StatusLineProviderConfig[] = []; + for (const entry of providers) { + const provider = normalizeStatusLineProvider(entry); + if (provider) { + normalized.push(provider); + } + } + result.providers = normalized; + } + return result; +} + +function mergeStatusLine( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined +): ResolvedStatusLineSettings { + const userConfig = normalizeStatusLine(userSettings?.statusline) ?? {}; + const projectConfig = normalizeStatusLine(projectSettings?.statusline) ?? {}; + const userProviders = userConfig.providers ?? []; + const projectProviders = projectConfig.providers ?? []; + const projectIds = new Set(projectProviders.map((p) => p.id)); + const providers = [...userProviders.filter((p) => !projectIds.has(p.id)), ...projectProviders]; + const enabled = projectConfig.enabled ?? userConfig.enabled ?? providers.length > 0; + const refreshMs = projectConfig.refreshMs ?? userConfig.refreshMs ?? DEFAULT_STATUSLINE_REFRESH_MS; + const separator = projectConfig.separator ?? userConfig.separator ?? DEFAULT_STATUSLINE_SEPARATOR; + return { + enabled, + refreshMs, + separator, + providers, + }; +} + +function normalizeEnv(env: DeepcodingSettings["env"]): Record { + const result: Record = {}; + if (!env) { + return result; + } + + for (const [key, value] of Object.entries(env)) { + if (typeof value === "string") { + result[key] = value; + } + } + return result; +} + +export function collectDeepcodeEnv(processEnv: SettingsProcessEnv = process.env): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(processEnv)) { + if (!key.startsWith("DEEPCODE_") || typeof value !== "string") { + continue; + } + const strippedKey = key.slice("DEEPCODE_".length); + if (strippedKey) { + result[strippedKey] = value; + } + } + return result; +} + +function extractMcpEnv(env: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (!key.startsWith("MCP_")) { + continue; + } + const strippedKey = key.slice("MCP_".length); + if (strippedKey) { + result[strippedKey] = value; + } + } + return result; +} + +function mergeMcpServers( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined, + userEnv: Record, + projectEnv: Record, + systemEnv: Record +): Record | undefined { + const userServers = userSettings?.mcpServers ?? {}; + const projectServers = projectSettings?.mcpServers ?? {}; + const serverNames = new Set([...Object.keys(userServers), ...Object.keys(projectServers)]); + if (serverNames.size === 0) { + return undefined; + } + + const userMcpEnv = extractMcpEnv(userEnv); + const projectMcpEnv = extractMcpEnv(projectEnv); + const systemMcpEnv = extractMcpEnv(systemEnv); + const merged: Record = {}; + + for (const name of serverNames) { + const userConfig = userServers[name]; + const projectConfig = projectServers[name]; + const command = projectConfig?.command ?? userConfig?.command; + if (!command) { + continue; + } + + const env = { + ...userEnv, + ...(userConfig?.env ?? {}), + ...userMcpEnv, + ...projectEnv, + ...(projectConfig?.env ?? {}), + ...projectMcpEnv, + ...systemEnv, + ...systemMcpEnv, + }; + const config: McpServerConfig = { + command, + args: projectConfig?.args ?? userConfig?.args, + }; + if (Object.keys(env).length > 0) { + config.env = env; + } + merged[name] = config; + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} + +export function resolveSettingsSources( + userSettings: DeepcodingSettings | null | undefined, + projectSettings: DeepcodingSettings | null | undefined, + defaults: { model: string; baseURL: string }, + processEnv: SettingsProcessEnv = process.env +): ResolvedDeepcodingSettings { + const userEnv = normalizeEnv(userSettings?.env); + const projectEnv = normalizeEnv(projectSettings?.env); + const systemEnv = collectDeepcodeEnv(processEnv); + const env = { + ...userEnv, + ...projectEnv, + ...systemEnv, + }; + + const model = + trimString(systemEnv.MODEL) || + trimString(projectSettings?.model) || + trimString(projectEnv.MODEL) || + trimString(userSettings?.model) || + trimString(userEnv.MODEL) || + defaults.model; + + const thinkingEnabled = + parseBoolean(systemEnv.THINKING_ENABLED) ?? + parseBoolean(projectSettings?.thinkingEnabled) ?? + parseBoolean(projectEnv.THINKING_ENABLED) ?? + parseBoolean(userSettings?.thinkingEnabled) ?? + parseBoolean(userEnv.THINKING_ENABLED) ?? + defaultsToThinkingMode(model); + + const reasoningEffort = + resolveReasoningEffort(systemEnv.REASONING_EFFORT) ?? + resolveReasoningEffort(projectSettings?.reasoningEffort) ?? + resolveReasoningEffort(projectEnv.REASONING_EFFORT) ?? + resolveReasoningEffort(userSettings?.reasoningEffort) ?? + resolveReasoningEffort(userEnv.REASONING_EFFORT) ?? + "max"; + + const temperature = + parseTemperature(systemEnv.TEMPERATURE) ?? + parseTemperature(projectSettings?.temperature) ?? + parseTemperature(projectEnv.TEMPERATURE) ?? + parseTemperature(userSettings?.temperature) ?? + parseTemperature(userEnv.TEMPERATURE); + + const debugLogEnabled = + parseBoolean(systemEnv.DEBUG_LOG_ENABLED) ?? + parseBoolean(projectSettings?.debugLogEnabled) ?? + parseBoolean(projectEnv.DEBUG_LOG_ENABLED) ?? + parseBoolean(userSettings?.debugLogEnabled) ?? + parseBoolean(userEnv.DEBUG_LOG_ENABLED) ?? + false; + + const telemetryEnabled = + parseBoolean(systemEnv.TELEMETRY_ENABLED) ?? + parseBoolean(projectSettings?.telemetryEnabled) ?? + parseBoolean(projectEnv.TELEMETRY_ENABLED) ?? + parseBoolean(userSettings?.telemetryEnabled) ?? + parseBoolean(userEnv.TELEMETRY_ENABLED) ?? + true; + + const notify = + trimString(systemEnv.NOTIFY) || trimString(projectSettings?.notify) || trimString(userSettings?.notify) || ""; + const webSearchTool = + trimString(systemEnv.WEB_SEARCH_TOOL) || + trimString(projectSettings?.webSearchTool) || + trimString(userSettings?.webSearchTool) || + ""; + + return { + env, + apiKey: trimString(env.API_KEY) || undefined, + baseURL: trimString(env.BASE_URL) || defaults.baseURL, + model, + temperature, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, + telemetryEnabled, + notify: notify || undefined, + webSearchTool: webSearchTool || undefined, + mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), + permissions: mergePermissions(userSettings, projectSettings), + enabledSkills: mergeEnabledSkills(userSettings, projectSettings), + statusline: mergeStatusLine(userSettings, projectSettings), + }; +} + +export function resolveSettings( + settings: DeepcodingSettings | null | undefined, + defaults: { model: string; baseURL: string }, + processEnv: SettingsProcessEnv = process.env +): ResolvedDeepcodingSettings { + return resolveSettingsSources(settings, null, defaults, processEnv); +} + +export function modelConfigKey(config: Pick): string { + return config.thinkingEnabled ? `thinking:${config.reasoningEffort}` : "thinking:none"; +} + +export function applyModelConfigSelection( + settings: DeepcodingSettings | null | undefined, + current: ModelConfigSelection, + selected: ModelConfigSelection +): { settings: DeepcodingSettings; changed: boolean } { + const changed = selected.model !== current.model || modelConfigKey(selected) !== modelConfigKey(current); + const next: DeepcodingSettings = { ...(settings ?? {}) }; + + if (!changed) { + return { settings: next, changed: false }; + } + + if (selected.model !== current.model || Object.prototype.hasOwnProperty.call(next, "model")) { + next.model = selected.model; + } else { + delete next.model; + } + + next.thinkingEnabled = selected.thinkingEnabled; + if (selected.thinkingEnabled) { + next.reasoningEffort = selected.reasoningEffort; + } + + return { settings: next, changed: true }; +} + +// --------------------------------------------------------------------------- +// Default constants +// --------------------------------------------------------------------------- + +export const DEFAULT_MODEL = "deepseek-v4-pro"; +export const DEFAULT_BASE_URL = "https://api.deepseek.com"; + +// --------------------------------------------------------------------------- +// Settings file I/O +// --------------------------------------------------------------------------- + +export function getUserSettingsPath(): string { + return path.join(os.homedir(), ".deepcode", "settings.json"); +} + +export function getProjectSettingsPath(projectRoot: string): string { + return path.join(projectRoot, ".deepcode", "settings.json"); +} + +export function readSettingsFile(settingsPath: string): DeepcodingSettings | null { + try { + if (!fs.existsSync(settingsPath)) { + return null; + } + const raw = fs.readFileSync(settingsPath, "utf8"); + return JSON.parse(raw) as DeepcodingSettings; + } catch { + return null; + } +} + +export function readSettings(): DeepcodingSettings | null { + return readSettingsFile(getUserSettingsPath()); +} + +export function readProjectSettings(projectRoot: string = process.cwd()): DeepcodingSettings | null { + return readSettingsFile(getProjectSettingsPath(projectRoot)); +} + +function writeSettingsFile(settingsPath: string, settings: DeepcodingSettings): void { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); +} + +export function writeSettings(settings: DeepcodingSettings): void { + const settingsPath = getUserSettingsPath(); + writeSettingsFile(settingsPath, settings); +} + +export function writeProjectSettings(settings: DeepcodingSettings, projectRoot: string = process.cwd()): void { + const settingsPath = getProjectSettingsPath(projectRoot); + writeSettingsFile(settingsPath, settings); +} + +export function writeModelConfigSelection( + selection: ModelConfigSelection, + current: ModelConfigSelection = resolveCurrentSettings(), + projectRoot: string = process.cwd() +): { changed: boolean; settings: DeepcodingSettings } { + const projectSettingsPath = getProjectSettingsPath(projectRoot); + const shouldWriteProjectSettings = fs.existsSync(projectSettingsPath); + const rawSettings = shouldWriteProjectSettings ? readProjectSettings(projectRoot) : readSettings(); + const result = applyModelConfigSelection(rawSettings, current, selection); + if (result.changed) { + if (shouldWriteProjectSettings) { + writeProjectSettings(result.settings, projectRoot); + } else { + writeSettings(result.settings); + } + } + return result; +} + +export function resolveCurrentSettings(projectRoot: string = process.cwd()): ResolvedDeepcodingSettings { + const userPath = path.resolve(getUserSettingsPath()); + const projectPath = path.resolve(getProjectSettingsPath(projectRoot)); + const sameFile = userPath === projectPath; + return resolveSettingsSources( + readSettings(), + sameFile ? null : readProjectSettings(projectRoot), + { + model: DEFAULT_MODEL, + baseURL: DEFAULT_BASE_URL, + }, + process.env + ); +} diff --git a/src/tests/debug-logger.test.ts b/packages/core/src/tests/debug-logger.test.ts similarity index 85% rename from src/tests/debug-logger.test.ts rename to packages/core/src/tests/debug-logger.test.ts index 7b1aad40..3d42ff97 100644 --- a/src/tests/debug-logger.test.ts +++ b/packages/core/src/tests/debug-logger.test.ts @@ -7,8 +7,12 @@ import { getDebugLogPath, logOpenAIChatCompletionDebug } from "../common/debug-l test("debug logger appends full entries without rotation", () => { const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; const home = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-debug-log-home-")); process.env.HOME = home; + if (process.platform === "win32") { + process.env.USERPROFILE = home; + } try { for (let index = 0; index < 25; index += 1) { logOpenAIChatCompletionDebug({ @@ -42,5 +46,10 @@ test("debug logger appends full entries without rotation", () => { } else { process.env.HOME = originalHome; } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } } }); diff --git a/packages/core/src/tests/mcp-client.test.ts b/packages/core/src/tests/mcp-client.test.ts new file mode 100644 index 00000000..6a7dc016 --- /dev/null +++ b/packages/core/src/tests/mcp-client.test.ts @@ -0,0 +1,110 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { McpClient, createMcpSpawnSpec } from "../mcp/mcp-client"; + +test("createMcpSpawnSpec keeps non-Windows MCP launches shell-free", () => { + assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "darwin"), { + command: "npx", + args: ["-y", "@playwright/mcp@latest"], + shell: false, + }); +}); + +test("createMcpSpawnSpec joins args without quoting when spaces are absent (Windows)", () => { + assert.deepEqual(createMcpSpawnSpec("npx", ["-y", "@playwright/mcp@latest"], "win32"), { + command: "npx -y @playwright/mcp@latest", + args: [], + shell: true, + windowsHide: true, + }); +}); + +test("createMcpSpawnSpec quotes Windows command paths and arguments", () => { + const spec = createMcpSpawnSpec( + String.raw`C:\Program Files\nodejs\node.exe`, + [String.raw`C:\tmp\mcp server.cjs`, 'a "quoted" value'], + "win32" + ); + + assert.equal( + spec.command, + String.raw`"C:\Program Files\nodejs\node.exe" "C:\tmp\mcp server.cjs" "a \"quoted\" value"` + ); + assert.deepEqual(spec.args, []); +}); + +test("createMcpSpawnSpec quotes Windows args with cmd metacharacters", () => { + const spec = createMcpSpawnSpec( + "npx", + [ + "-y", + "some-mcp", + "--url=https://example.test?a=1&b=2", + "--pipe=a|b", + "--redirect=out", + "--caret=^value", + "--group=(value)", + ], + "win32" + ); + + assert.equal( + spec.command, + [ + "npx", + "-y", + "some-mcp", + '"--url=https://example.test?a=1&b=2"', + '"--pipe=a|b"', + '"--redirect=out"', + '"--caret=^value"', + '"--group=(value)"', + ].join(" ") + ); + assert.deepEqual(spec.args, []); +}); + +test("McpClient starts a PATH-resolved cmd MCP server on Windows", { skip: process.platform !== "win32" }, async () => { + const serverDir = mkdtempSync(path.join(tmpdir(), "deepcode-mcp-probe-")); + const originalPath = process.env.PATH; + + writeFileSync(path.join(serverDir, "mcp-probe.cmd"), '@echo off\r\nnode "%~dp0mcp-probe-server.cjs"\r\n'); + writeFileSync( + path.join(serverDir, "mcp-probe-server.cjs"), + [ + 'const readline = require("node:readline");', + "const rl = readline.createInterface({ input: process.stdin });", + "function send(message) { process.stdout.write(`${JSON.stringify(message)}\\n`); }", + 'rl.on("line", (line) => {', + " const request = JSON.parse(line);", + ' if (request.method === "initialize") {', + ' send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2025-03-26", capabilities: {}, serverInfo: { name: "probe", version: "1.0.0" } } });', + " return;", + " }", + ' if (request.method === "tools/list") {', + ' send({ jsonrpc: "2.0", id: request.id, result: { tools: [{ name: "probe_tool", inputSchema: { type: "object", properties: {} } }] } });', + " return;", + " }", + "});", + ].join("\n") + ); + + process.env.PATH = `${serverDir}${path.delimiter}${originalPath ?? ""}`; + const client = new McpClient("probe", "mcp-probe", []); + + try { + await client.connect(5_000); + const tools = await client.listTools(5_000); + assert.deepEqual( + tools.map((tool) => tool.name), + ["probe_tool"] + ); + } finally { + client.disconnect(); + process.env.PATH = originalPath; + rmSync(serverDir, { recursive: true, force: true }); + } +}); diff --git a/packages/core/src/tests/memory-leak.test.ts b/packages/core/src/tests/memory-leak.test.ts new file mode 100644 index 00000000..218bcee5 --- /dev/null +++ b/packages/core/src/tests/memory-leak.test.ts @@ -0,0 +1,220 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { SessionManager } from "../session"; +import { handleBashTool } from "../tools/bash-handler"; +import * as state from "../common/state"; +import type { ToolExecutionContext } from "../tools/executor"; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempDirs: string[] = []; + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + +function createSessionManager(projectRoot: string): SessionManager { + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: null, + model: "test", + baseURL: "https://api.test.com", + thinkingEnabled: false, + reasoningEffort: "high", + debugLogEnabled: false, + env: {}, + }), + getResolvedSettings: () => ({ model: "test" }), + renderMarkdown: (text: string) => text, + onAssistantMessage: () => {}, + }); +} + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("SessionManager.deleteSession clears state cache for that session", async () => { + const home = createTempDir("deepcode-mem-home-"); + const projectRoot = createTempDir("deepcode-mem-workspace-"); + setHomeDir(home); + const manager = createSessionManager(projectRoot); + + const sessionId = await manager.createSession({ text: "seed" }); + const filePath = path.join(projectRoot, "a.txt"); + fs.writeFileSync(filePath, "hello"); + state.recordFileState(sessionId, { filePath, content: "hello", timestamp: Date.now() }, { incrementVersion: true }); + const snippet = state.createSnippet(sessionId, filePath, 1, 1, "hello"); + const fileVersionBeforeDelete = state.getFileVersion(sessionId, filePath); + + assert.ok(state.wasFileRead(sessionId, filePath)); + assert.ok(snippet); + assert.ok(state.getSnippet(sessionId, snippet!.id)); + assert.equal(fileVersionBeforeDelete, 1); + + assert.equal(manager.deleteSession(sessionId), true); + assert.equal(state.wasFileRead(sessionId, filePath), false); + assert.equal(state.getSnippet(sessionId, snippet!.id), null); + assert.equal(state.getFileVersion(sessionId, filePath), 0); +}); + +test("SessionManager.createSession auto-prune clears dropped session state cache", async () => { + const home = createTempDir("deepcode-mem-home-"); + const projectRoot = createTempDir("deepcode-mem-workspace-"); + setHomeDir(home); + const manager = createSessionManager(projectRoot); + + const firstSession = await manager.createSession({ text: "first" }); + const filePath = path.join(projectRoot, "first.txt"); + fs.writeFileSync(filePath, "first"); + state.recordFileState(firstSession, { filePath, content: "first", timestamp: Date.now() }); + assert.equal(state.wasFileRead(firstSession, filePath), true); + + for (let i = 0; i < 60; i += 1) { + await manager.createSession({ text: `session-${i}` }); + } + + const remaining = manager.listSessions().map((entry) => entry.id); + assert.equal(remaining.includes(firstSession), false); + assert.equal(state.wasFileRead(firstSession, filePath), false); +}); + +test("SessionManager.deleteSession clears controller map entry", async () => { + const home = createTempDir("deepcode-mem-home-"); + const projectRoot = createTempDir("deepcode-mem-workspace-"); + setHomeDir(home); + const manager = createSessionManager(projectRoot); + + const sessionId = await manager.createSession({ text: "seed" }); + const controllers = (manager as unknown as { sessionControllers: Map }).sessionControllers; + controllers.set(sessionId, new AbortController()); + assert.equal(controllers.has(sessionId), true); + + assert.equal(manager.deleteSession(sessionId), true); + assert.equal(controllers.has(sessionId), false); +}); + +test("SessionManager.dispose aborts and clears controllers", () => { + const projectRoot = createTempDir("deepcode-mem-workspace-"); + const manager = createSessionManager(projectRoot); + const controllers = (manager as unknown as { sessionControllers: Map }).sessionControllers; + + const controllerA = new AbortController(); + const controllerB = new AbortController(); + controllers.set("a", controllerA); + controllers.set("b", controllerB); + assert.equal(controllers.size, 2); + + manager.dispose(); + assert.equal(controllers.size, 0); +}); + +test("Deleted session id reuse should reset bash cwd to project root", async () => { + const home = createTempDir("deepcode-mem-home-"); + const projectRoot = createTempDir("deepcode-mem-workspace-"); + setHomeDir(home); + const manager = createSessionManager(projectRoot); + + const sessionId = await manager.createSession({ text: "bash-session" }); + const sub = path.join(projectRoot, "sub"); + fs.mkdirSync(sub, { recursive: true }); + + const context: ToolExecutionContext = { + sessionId, + projectRoot, + toolCall: { id: "call-1", type: "function", function: { name: "bash", arguments: "{}" } }, + createOpenAIClient: () => ({ + client: null, + model: "test", + baseURL: "", + thinkingEnabled: false, + reasoningEffort: "high", + debugLogEnabled: false, + env: {}, + }), + }; + + const first = await handleBashTool({ command: `cd "${sub}" && pwd` }, context); + assert.equal(first.ok, true); + + assert.equal(manager.deleteSession(sessionId), true); + + const second = await handleBashTool({ command: "pwd" }, context); + assert.equal(second.ok, true); + + const output = (second.output ?? "").trim(); + const metadataCwd = + second.metadata && typeof second.metadata.cwd === "string" ? (second.metadata.cwd as string) : null; + const observedCwd = (metadataCwd ?? output).replace(/\\/g, "/").replace(/\/+$/, ""); + const normalizedSub = fs.realpathSync(sub).replace(/\\/g, "/").replace(/\/+$/, ""); + assert.notEqual( + observedCwd, + normalizedSub, + `expected cwd not to stay on deleted session subdir ${normalizedSub}, got output=${output}, metadata.cwd=${String(metadataCwd)}` + ); +}); + +test("deleteSession should not kill untracked stale persisted pids", async () => { + const home = createTempDir("deepcode-mem-home-"); + const projectRoot = createTempDir("deepcode-mem-workspace-"); + setHomeDir(home); + const manager = createSessionManager(projectRoot); + const sessionId = await manager.createSession({ text: "stale-pid" }); + + const privateManager = manager as unknown as { + updateSessionEntry: ( + sessionId: string, + updater: (entry: { processes: Map | null }) => { + processes: Map | null; + } + ) => unknown; + }; + privateManager.updateSessionEntry(sessionId, (entry) => ({ + ...entry, + processes: new Map([["999999", { startTime: new Date().toISOString(), command: "sleep 999" }]]), + })); + + const originalKill = process.kill; + let killCalls = 0; + const mockedKill = ((pid: number, signal?: NodeJS.Signals | number) => { + killCalls += 1; + return originalKill(pid, signal); + }) as typeof process.kill; + process.kill = mockedKill; + try { + assert.equal(manager.deleteSession(sessionId), true); + } finally { + process.kill = originalKill; + } + + assert.equal(killCalls, 0); +}); diff --git a/packages/core/src/tests/openai-message-converter.test.ts b/packages/core/src/tests/openai-message-converter.test.ts new file mode 100644 index 00000000..a54c213d --- /dev/null +++ b/packages/core/src/tests/openai-message-converter.test.ts @@ -0,0 +1,508 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { OpenAIMessageConverter } from "../common/openai-message-converter"; +import type { SessionMessage } from "../session"; + +// --------------------------------------------------------------------------- +// Test helpers — build SessionMessage objects without needing SessionManager +// --------------------------------------------------------------------------- + +function msg(overrides: Partial & { role: SessionMessage["role"] }): SessionMessage { + const now = "2026-01-01T00:00:00.000Z"; + return { + id: overrides.id ?? "msg-1", + sessionId: overrides.sessionId ?? "session-1", + role: overrides.role, + content: overrides.content ?? null, + contentParams: overrides.contentParams ?? null, + messageParams: overrides.messageParams ?? null, + compacted: overrides.compacted ?? false, + visible: overrides.visible ?? true, + createTime: overrides.createTime ?? now, + updateTime: overrides.updateTime ?? now, + meta: overrides.meta, + }; +} + +function assistantMsg( + id: string, + toolCalls?: Array<{ id: string; type?: string; function: { name: string; arguments: string } }>, + reasoningContent?: string | null +): SessionMessage { + const hasTcs = toolCalls && toolCalls.length > 0; + const hasReasoning = reasoningContent !== undefined && reasoningContent !== null; + const messageParams: Record | null = hasTcs || hasReasoning ? {} : null; + if (hasTcs) (messageParams as Record).tool_calls = toolCalls; + if (hasReasoning) (messageParams as Record).reasoning_content = reasoningContent; + return msg({ + id, + role: "assistant", + content: "", + messageParams, + visible: false, + }); +} + +function toolMsg( + id: string, + toolCallId: string, + content: string, + toolFunction?: { name: string; arguments: string } +): SessionMessage { + return msg({ + id, + role: "tool", + content, + messageParams: { tool_call_id: toolCallId }, + meta: toolFunction ? { function: toolFunction } : undefined, + }); +} + +function userMsg(id: string, content: string): SessionMessage { + return msg({ id, role: "user", content }); +} + +// --------------------------------------------------------------------------- +// Converter fixtures +// --------------------------------------------------------------------------- + +function converter(opts?: { renderInitPrompt?: () => string }) { + return new OpenAIMessageConverter(opts); +} + +// --------------------------------------------------------------------------- +// buildMessages — content handling +// --------------------------------------------------------------------------- + +test("OpenAIMessageConverter preserves image content for multimodal models", () => { + const c = converter(); + const messages: SessionMessage[] = [ + msg({ + role: "system", + content: "Loaded pixel.png", + contentParams: [{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } }], + }), + ]; + + const result = c.buildMessages(messages, false, "gpt-4o") as Array<{ role: string; content: unknown }>; + + assert.equal(result.length, 1); + assert.equal(result[0]?.role, "system"); + assert.deepEqual(result[0]?.content, [ + { type: "text", text: "Loaded pixel.png" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + ]); +}); + +test("OpenAIMessageConverter filters image content for non-multimodal models", () => { + const c = converter(); + const messages: SessionMessage[] = [ + msg({ + role: "system", + content: "Loaded pixel.png", + contentParams: [{ type: "image_url", image_url: { url: "data:image/png;base64,abc" } }], + }), + ]; + + const result = c.buildMessages(messages, false, "deepseek-chat") as Array<{ role: string; content: unknown }>; + + assert.equal(result.length, 1); + assert.deepEqual(result[0]?.content, [{ type: "text", text: "Loaded pixel.png" }]); +}); + +test("OpenAIMessageConverter injects reasoning_content in thinking mode", () => { + const c = converter(); + const messages: SessionMessage[] = [msg({ role: "assistant", content: "Final answer", messageParams: null })]; + + const thinking = c.buildMessages(messages, true, "test-model") as Array<{ reasoning_content?: string }>; + const nonThinking = c.buildMessages(messages, false, "test-model") as Array<{ reasoning_content?: string }>; + + assert.equal(thinking[0]?.reasoning_content, ""); + assert.equal(Object.prototype.hasOwnProperty.call(nonThinking[0] ?? {}, "reasoning_content"), false); +}); + +test("OpenAIMessageConverter preserves existing reasoning_content from messageParams", () => { + const c = converter(); + const messages: SessionMessage[] = [ + msg({ + role: "assistant", + content: "answer", + messageParams: { reasoning_content: "deep thought" }, + }), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ reasoning_content?: string }>; + + assert.equal(result[0]?.reasoning_content, "deep thought"); +}); + +test("OpenAIMessageConverter uses /init prompt via renderInitPrompt callback", () => { + const c = converter({ renderInitPrompt: () => "EXPANDED INIT PROMPT" }); + const messages: SessionMessage[] = [msg({ role: "user", content: "/init" })]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ content: string }>; + + assert.equal(result[0]?.content, "EXPANDED INIT PROMPT"); +}); + +test("OpenAIMessageConverter skips compacted messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + userMsg("u1", "hello"), + msg({ id: "a1", role: "assistant", content: "hi", compacted: true }), + userMsg("u2", "still here?"), + msg({ id: "a2", role: "assistant", content: "yes" }), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ role: string }>; + + assert.deepEqual( + result.map((m) => m.role), + ["user", "user", "assistant"] + ); +}); + +// --------------------------------------------------------------------------- +// buildMessages — tool-call pairing +// --------------------------------------------------------------------------- + +test("OpenAIMessageConverter preserves a complete multi-tool happy path", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } }, + { id: "call-2", type: "function", function: { name: "bash", arguments: '{"command":"pwd"}' } }, + ]), + toolMsg("t1", "call-1", JSON.stringify({ ok: true, name: "read", content: "file content" }), { + name: "read", + arguments: '{"file_path":"/tmp/a.txt"}', + }), + toolMsg("t2", "call-2", JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), { + name: "bash", + arguments: '{"command":"pwd"}', + }), + userMsg("u1", "thanks"), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + tool_call_id?: string; + content: string; + }>; + + assert.deepEqual( + result.map((m) => m.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + result.filter((m) => m.role === "tool").map((m) => m.tool_call_id), + ["call-1", "call-2"] + ); + const hasInterrupted = result.some((m) => m.content.includes("Previous tool call did not complete")); + assert.equal(hasInterrupted, false); +}); + +test("OpenAIMessageConverter inserts interrupted backfill for missing tool messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"sleep 100"}' } }, + ]), + userMsg("u1", "continue"), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.equal(result.length, 3); + assert.equal(result[0]?.role, "assistant"); + assert.equal(result[1]?.role, "tool"); + assert.equal(result[1]?.tool_call_id, "call-1"); + assert.match(result[1]?.content ?? "", /Previous tool call did not complete/); + assert.equal(result[2]?.role, "user"); +}); + +test("OpenAIMessageConverter ignores orphan tool messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + userMsg("u1", "hello"), + toolMsg("t1", "call-orphan", JSON.stringify({ ok: true, name: "bash", output: "orphan" }), { + name: "bash", + arguments: '{"command":"echo orphan"}', + }), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ role: string }>; + + assert.deepEqual( + result.map((m) => m.role), + ["user"] + ); +}); + +test("OpenAIMessageConverter prefers first non-interrupted tool result for a tool call", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"date"}' } }, + ]), + toolMsg("t1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "2026-05-07\n" }), { + name: "bash", + arguments: '{"command":"date"}', + }), + toolMsg( + "t2", + "call-1", + JSON.stringify({ + ok: false, + name: "bash", + error: "Previous tool call did not complete.", + metadata: { interrupted: true }, + }), + { name: "bash", arguments: '{"command":"date"}' } + ), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + const toolResults = result.filter((m) => m.role === "tool"); + + assert.equal(toolResults.length, 1); + assert.equal(toolResults[0]?.tool_call_id, "call-1"); + assert.match(toolResults[0]?.content ?? "", /2026-05-07/); + assert.doesNotMatch(toolResults[0]?.content ?? "", /Previous tool call did not complete/); +}); + +test("OpenAIMessageConverter prefers later real result over earlier interrupted placeholder", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"date"}' } }, + ]), + toolMsg( + "t1", + "call-1", + JSON.stringify({ + ok: false, + name: "bash", + error: "Previous tool call did not complete.", + metadata: { interrupted: true }, + }), + { name: "bash", arguments: '{"command":"date"}' } + ), + toolMsg("t2", "call-1", JSON.stringify({ ok: true, name: "bash", output: "real result" }), { + name: "bash", + arguments: '{"command":"date"}', + }), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + const toolResults = result.filter((m) => m.role === "tool"); + + assert.equal(toolResults.length, 1); + assert.match(toolResults[0]?.content ?? "", /real result/); +}); + +test("OpenAIMessageConverter preserves a real failed tool result", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"false"}' } }, + ]), + toolMsg( + "t1", + "call-1", + JSON.stringify({ ok: false, name: "bash", error: "Command failed", metadata: { exitCode: 1 } }), + { name: "bash", arguments: '{"command":"false"}' } + ), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.deepEqual( + result.map((m) => m.role), + ["assistant", "tool"] + ); + assert.match(result[1]?.content ?? "", /Command failed/); + assert.doesNotMatch(result[1]?.content ?? "", /Previous tool call did not complete/); +}); + +test("OpenAIMessageConverter repairs mixed missing/duplicate/orphan tool messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "read", arguments: '{"file_path":"/tmp/missing.txt"}' } }, + { id: "call-2", type: "function", function: { name: "bash", arguments: '{"command":"pwd"}' } }, + ]), + toolMsg("t-orphan", "call-orphan", JSON.stringify({ ok: true, name: "bash", output: "orphan" }), { + name: "bash", + arguments: '{"command":"echo orphan"}', + }), + toolMsg("t1", "call-2", JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), { + name: "bash", + arguments: '{"command":"pwd"}', + }), + toolMsg("t2", "call-2", JSON.stringify({ ok: true, name: "bash", output: "duplicate" }), { + name: "bash", + arguments: '{"command":"pwd"}', + }), + userMsg("u1", "continue"), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + const toolResults = result.filter((m) => m.role === "tool"); + + assert.deepEqual( + result.map((m) => m.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + toolResults.map((m) => m.tool_call_id), + ["call-1", "call-2"] + ); + assert.match(toolResults[0]?.content ?? "", /Previous tool call did not complete/); + assert.match(toolResults[1]?.content ?? "", /\/tmp/); + assert.equal( + result.some((m) => m.content.includes("orphan")), + false + ); + assert.equal( + result.some((m) => m.content.includes("duplicate")), + false + ); +}); + +test("OpenAIMessageConverter ignores tool messages before their assistant", () => { + const c = converter(); + const messages: SessionMessage[] = [ + toolMsg("t1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "too early" }), { + name: "bash", + arguments: '{"command":"date"}', + }), + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"date"}' } }, + ]), + ]; + + const result = c.buildMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.deepEqual( + result.map((m) => m.role), + ["assistant", "tool"] + ); + assert.match(result[1]?.content ?? "", /Previous tool call did not complete/); + assert.doesNotMatch(result[1]?.content ?? "", /too early/); +}); + +// --------------------------------------------------------------------------- +// getTrailingPendingToolCallMessage +// --------------------------------------------------------------------------- + +test("OpenAIMessageConverter.getTrailingPendingToolCallMessage finds pending tools", () => { + const c = converter(); + const messages: SessionMessage[] = [ + userMsg("u1", "hello"), + assistantMsg("a1", [ + { id: "call-1", type: "function", function: { name: "bash", arguments: '{"command":"date"}' } }, + ]), + ]; + + const result = c.getTrailingPendingToolCallMessage(messages); + + assert.notEqual(result.message, null); + assert.deepEqual( + result.toolCalls.map((tc) => (tc as { id: string }).id), + ["call-1"] + ); +}); + +test("OpenAIMessageConverter.getTrailingPendingToolCallMessage returns empty when latest is user", () => { + const c = converter(); + const messages: SessionMessage[] = [userMsg("u1", "hello")]; + + const result = c.getTrailingPendingToolCallMessage(messages); + + assert.equal(result.message, null); + assert.deepEqual(result.toolCalls, []); +}); + +test("OpenAIMessageConverter.getTrailingPendingToolCallMessage returns empty when no tool calls", () => { + const c = converter(); + const messages: SessionMessage[] = [msg({ id: "a1", role: "assistant", content: "done" })]; + + const result = c.getTrailingPendingToolCallMessage(messages); + + assert.equal(result.message, null); + assert.deepEqual(result.toolCalls, []); +}); + +test("OpenAIMessageConverter.getTrailingPendingToolCallMessage skips compacted messages", () => { + const c = converter(); + const messages: SessionMessage[] = [ + userMsg("u1", "hello"), + msg({ + id: "a1", + role: "assistant", + content: "", + messageParams: { + tool_calls: [{ id: "call-1", type: "function", function: { name: "bash", arguments: "{}" } }], + }, + compacted: true, + }), + msg({ id: "a2", role: "assistant", content: "done" }), + ]; + + const result = c.getTrailingPendingToolCallMessage(messages); + + assert.equal(result.message, null); + assert.deepEqual(result.toolCalls, []); +}); + +// --------------------------------------------------------------------------- +// findToolFunction +// --------------------------------------------------------------------------- + +test("OpenAIMessageConverter.findToolFunction finds matching tool function", () => { + const c = converter(); + const toolCalls = [ + { id: "call-1", type: "function", function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } }, + { id: "call-2", type: "function", function: { name: "bash", arguments: '{"command":"pwd"}' } }, + ]; + + const found = c.findToolFunction(toolCalls, "call-1") as { name: string }; + assert.equal(found?.name, "read"); + + const notFound = c.findToolFunction(toolCalls, "call-3"); + assert.equal(notFound, null); +}); + +test("OpenAIMessageConverter.findToolFunction handles null/empty toolCalls", () => { + const c = converter(); + + assert.equal(c.findToolFunction([], "call-1"), null); + + const toolCalls = [null, undefined, { noId: true }]; + assert.equal(c.findToolFunction(toolCalls as unknown[], "call-1"), null); +}); diff --git a/src/tests/openai-thinking.test.ts b/packages/core/src/tests/openai-thinking.test.ts similarity index 100% rename from src/tests/openai-thinking.test.ts rename to packages/core/src/tests/openai-thinking.test.ts diff --git a/packages/core/src/tests/permissions.test.ts b/packages/core/src/tests/permissions.test.ts new file mode 100644 index 00000000..fd3b676a --- /dev/null +++ b/packages/core/src/tests/permissions.test.ts @@ -0,0 +1,388 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { + appendProjectPermissionAllows, + computeToolCallPermissions, + evaluatePermissionScopes, + getPermissionScopesRequiringAsk, + hasUserPermissionReplies, + isPathInAnyDirectory, + parseBashSideEffects, +} from "../common/permissions"; +import type { PermissionScope, PermissionSettings } from "../settings"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("parseBashSideEffects accepts valid scopes and normalizes unsafe values to unknown", () => { + assert.deepEqual(parseBashSideEffects(["read-in-cwd", "network", "read-in-cwd"]), ["read-in-cwd", "network"]); + assert.deepEqual(parseBashSideEffects(undefined), ["unknown"]); + assert.deepEqual(parseBashSideEffects(["read-in-cwd", "unknown"]), ["unknown"]); + assert.deepEqual(parseBashSideEffects(["mcp"]), ["unknown"]); +}); + +test("evaluatePermissionScopes applies deny, ask, allow, and default mode precedence", () => { + const settings: Required = { + allow: ["read-in-cwd"] as PermissionScope[], + deny: ["write-out-cwd"] as PermissionScope[], + ask: ["network"] as PermissionScope[], + defaultMode: "askAll", + }; + + assert.equal(evaluatePermissionScopes(["write-out-cwd"], settings), "deny"); + assert.equal(evaluatePermissionScopes(["network"], settings), "ask"); + assert.equal(evaluatePermissionScopes(["read-in-cwd"], settings), "allow"); + assert.equal(evaluatePermissionScopes(["write-in-cwd"], settings), "ask"); + assert.equal(evaluatePermissionScopes([], settings), "allow"); + assert.equal(evaluatePermissionScopes(["unknown"], settings), "ask"); +}); + +test("evaluatePermissionScopes allows unknown when defaultMode is allowAll", () => { + const allowAllSettings: Required = { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: [] as PermissionScope[], + defaultMode: "allowAll", + }; + assert.equal(evaluatePermissionScopes(["unknown"], allowAllSettings), "allow"); + + // unknown + other scopes that would otherwise trigger ask should still ask for those scopes + const askNetworkSettings: Required = { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: ["network"] as PermissionScope[], + defaultMode: "allowAll", + }; + assert.equal(evaluatePermissionScopes(["unknown", "network"], askNetworkSettings), "ask"); +}); + +test("getPermissionScopesRequiringAsk excludes unknown when defaultMode is allowAll", () => { + const allowAllSettings: Required = { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: ["network"] as PermissionScope[], + defaultMode: "allowAll", + }; + const result = getPermissionScopesRequiringAsk(["unknown", "network"], allowAllSettings); + assert.deepEqual(result, ["network"]); +}); + +test("getPermissionScopesRequiringAsk includes unknown when defaultMode is askAll", () => { + const askAllSettings: Required = { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: ["network"] as PermissionScope[], + defaultMode: "askAll", + }; + const result = getPermissionScopesRequiringAsk(["unknown", "network"], askAllSettings); + assert.deepEqual(result, ["unknown", "network"]); +}); + +test("computeToolCallPermissions maps tool calls to permission requests", () => { + const projectRoot = createTempDir("deepcode-permissions-workspace-"); + const plan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + settings: { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: ["write-out-cwd", "network"] as PermissionScope[], + defaultMode: "allowAll" as const, + }, + resolveSnippetPath: () => path.join(projectRoot, "src", "file.ts"), + toolCalls: [ + { + id: "call-write", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/out.txt", content: "x" }) }, + }, + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: "curl https://example.com", sideEffects: ["network"] }), + }, + }, + { + id: "call-edit", + type: "function", + function: { name: "edit", arguments: JSON.stringify({ snippet_id: "snippet_1" }) }, + }, + ], + }); + + assert.deepEqual(plan.permissions, [ + { toolCallId: "call-write", permission: "ask" }, + { toolCallId: "call-bash", permission: "ask" }, + { toolCallId: "call-edit", permission: "allow" }, + ]); + assert.deepEqual( + plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })), + [ + { id: "call-write", scopes: ["write-out-cwd"] }, + { id: "call-bash", scopes: ["network"] }, + ] + ); +}); + +test("computeToolCallPermissions only asks for scopes not already allowed", () => { + const projectRoot = createTempDir("deepcode-permissions-filter-workspace-"); + const plan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + settings: { + allow: ["read-in-cwd"] as PermissionScope[], + deny: [] as PermissionScope[], + ask: [] as PermissionScope[], + defaultMode: "askAll" as const, + }, + toolCalls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "curl -s http://localhost:8899/ && ls index.html", + sideEffects: ["network", "read-in-cwd"], + }), + }, + }, + ], + }); + + assert.deepEqual(plan.permissions, [{ toolCallId: "call-bash", permission: "ask" }]); + assert.deepEqual( + plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })), + [{ id: "call-bash", scopes: ["network"] }] + ); +}); + +test("computeToolCallPermissions allows read tool calls under skill scan paths", () => { + const projectRoot = createTempDir("deepcode-permissions-skill-read-workspace-"); + const home = createTempDir("deepcode-permissions-skill-read-home-"); + const skillRoot = path.join(home, ".agents", "skills"); + const skillResourcePath = path.join(skillRoot, "pdf", "scripts", "extract.py"); + const outsidePath = path.join(home, "notes.txt"); + const plan = computeToolCallPermissions({ + sessionId: "session-1", + projectRoot, + readPermissionExemptPaths: [skillRoot], + settings: { + allow: [] as PermissionScope[], + deny: [] as PermissionScope[], + ask: [] as PermissionScope[], + defaultMode: "askAll" as const, + }, + toolCalls: [ + { + id: "call-skill-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: skillResourcePath }) }, + }, + { + id: "call-outside-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: outsidePath }) }, + }, + ], + }); + + assert.deepEqual(plan.permissions, [ + { toolCallId: "call-skill-read", permission: "allow" }, + { toolCallId: "call-outside-read", permission: "ask" }, + ]); + assert.deepEqual( + plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })), + [{ id: "call-outside-read", scopes: ["read-out-cwd"] }] + ); +}); + +test("isPathInAnyDirectory matches absolute and project-relative directories without sibling leaks", () => { + const projectRoot = createTempDir("deepcode-permissions-directory-match-workspace-"); + const home = createTempDir("deepcode-permissions-directory-match-home-"); + const absoluteSkillRoot = path.join(home, ".agents", "skills"); + const relativeSkillRoot = path.join(".deepcode", "skills"); + + assert.equal( + isPathInAnyDirectory(projectRoot, path.join(absoluteSkillRoot, "pdf", "scripts", "extract.py"), [ + absoluteSkillRoot, + ]), + true + ); + assert.equal( + isPathInAnyDirectory(projectRoot, path.join(projectRoot, relativeSkillRoot, "local", "SKILL.md"), [ + relativeSkillRoot, + ]), + true + ); + assert.equal( + isPathInAnyDirectory(projectRoot, path.join(`${absoluteSkillRoot}-backup`, "extract.py"), [absoluteSkillRoot]), + false + ); + assert.equal( + isPathInAnyDirectory(projectRoot, path.join(projectRoot, ".deepcode", "skills-extra", "file.md"), [ + relativeSkillRoot, + ]), + false + ); + assert.equal(isPathInAnyDirectory(projectRoot, path.join(home, "notes.txt"), undefined), false); +}); + +test("appendProjectPermissionAllows writes unique project-level allow scopes", () => { + const projectRoot = createTempDir("deepcode-permission-settings-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ permissions: { allow: ["read-in-cwd"] } }), "utf8"); + + appendProjectPermissionAllows(projectRoot, ["read-in-cwd", "write-in-cwd"]); + appendProjectPermissionAllows(projectRoot, ["write-in-cwd"]); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions.allow, ["read-in-cwd", "write-in-cwd"]); +}); + +test("appendProjectPermissionAllows seeds inherited permissions before adding allow scopes", () => { + const projectRoot = createTempDir("deepcode-permission-settings-default-"); + + appendProjectPermissionAllows(projectRoot, ["query-git-log"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "query-git-log"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows moves inherited ask and deny scopes into allow", () => { + const projectRoot = createTempDir("deepcode-permission-settings-move-inherited-"); + + appendProjectPermissionAllows(projectRoot, ["network", "write-out-cwd"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network", "mcp"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "network", "write-out-cwd"], + deny: [], + ask: ["mcp"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows writes inherited permissions even when scope is already allowed", () => { + const projectRoot = createTempDir("deepcode-permission-settings-inherited-existing-"); + + appendProjectPermissionAllows(projectRoot, ["read-in-cwd"], { + inheritedPermissions: { + allow: ["read-in-cwd"], + deny: [], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd"], + deny: [], + ask: ["network"], + defaultMode: "askAll", + }); +}); + +test("appendProjectPermissionAllows preserves existing project permissions", () => { + const projectRoot = createTempDir("deepcode-permission-settings-explicit-default-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ permissions: { allow: ["read-in-cwd"], defaultMode: "allowAll" } }), + "utf8" + ); + + appendProjectPermissionAllows(projectRoot, ["query-git-log"], { + inheritedPermissions: { + allow: ["write-in-cwd"], + deny: ["write-out-cwd"], + ask: ["network"], + defaultMode: "askAll", + }, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "query-git-log"], + defaultMode: "allowAll", + }); +}); + +test("appendProjectPermissionAllows removes existing ask and deny conflicts", () => { + const projectRoot = createTempDir("deepcode-permission-settings-existing-conflict-"); + const settingsPath = path.join(projectRoot, ".deepcode", "settings.json"); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + permissions: { + allow: ["read-in-cwd"], + deny: ["network", "write-out-cwd"], + ask: ["network", "mcp"], + defaultMode: "askAll", + }, + }), + "utf8" + ); + + appendProjectPermissionAllows(projectRoot, ["network"]); + + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); + assert.deepEqual(settings.permissions, { + allow: ["read-in-cwd", "network"], + deny: ["write-out-cwd"], + ask: ["mcp"], + defaultMode: "askAll", + }); +}); + +test("hasUserPermissionReplies detects permission reply payloads", () => { + assert.equal(hasUserPermissionReplies({}), false); + assert.equal(hasUserPermissionReplies({ permissions: [] }), false); + assert.equal(hasUserPermissionReplies({ permissions: [{ toolCallId: "call-1", permission: "allow" }] }), true); + assert.equal(hasUserPermissionReplies({ alwaysAllows: ["network"] }), true); +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} diff --git a/src/tests/process-tree.test.ts b/packages/core/src/tests/process-tree.test.ts similarity index 100% rename from src/tests/process-tree.test.ts rename to packages/core/src/tests/process-tree.test.ts diff --git a/packages/core/src/tests/prompt.test.ts b/packages/core/src/tests/prompt.test.ts new file mode 100644 index 00000000..6b474c1e --- /dev/null +++ b/packages/core/src/tests/prompt.test.ts @@ -0,0 +1,213 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { + buildSkillDocumentsPrompt, + getDefaultSkillPrompt, + getRuntimeContext, + getSystemPrompt, + getTools, +} from "../prompt"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +test("getTools always includes WebSearch", () => { + const names = getTools().map((tool) => tool.function.name); + assert.equal(names.includes("WebSearch"), true); +}); + +test("getTools includes UpdatePlan with string plan schema", () => { + const tool = getTools().find((candidate) => candidate.function.name === "UpdatePlan"); + assert.ok(tool); + assert.deepEqual(tool.function.parameters.required, ["plan"]); + assert.equal((tool.function.parameters.properties.plan as { type?: unknown }).type, "string"); +}); + +test("getTools requires bash sideEffects permission scopes", () => { + const tool = getTools().find((candidate) => candidate.function.name === "bash"); + assert.ok(tool); + assert.deepEqual(tool.function.parameters.required, ["command", "sideEffects"]); + const sideEffects = tool.function.parameters.properties.sideEffects as { + type?: unknown; + items?: { enum?: unknown[] }; + }; + assert.equal(sideEffects.type, "array"); + assert.equal(sideEffects.items?.enum?.includes("write-out-cwd"), true); + assert.equal(sideEffects.items?.enum?.includes("unknown"), true); + const runInBackground = tool.function.parameters.properties.run_in_background as { type?: unknown }; + assert.equal(runInBackground.type, "boolean"); +}); + +test("getSystemPrompt always includes WebSearch docs", () => { + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes("## WebSearch"), true); +}); + +test("getSystemPrompt includes UpdatePlan docs", () => { + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes("## UpdatePlan"), true); + assert.equal(prompt.includes("The `plan` argument is a markdown string, not an array of step objects."), true); +}); + +test("getSystemPrompt includes Bash background guidance", () => { + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes("run_in_background: true"), true); + assert.equal(prompt.includes("do NOT add `&`"), true); + assert.equal(prompt.includes("use the `stopCommand` returned in the tool result metadata"), true); + assert.equal(prompt.includes("stop background tasks that has not reported a completed state"), true); +}); + +test("getSystemPrompt does not include runtime context", () => { + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes("# Local Workspace Environment"), false); + assert.equal(prompt.includes('"root path": "/tmp/project"'), false); +}); + +test("getDefaultSkillPrompt loads the default skill template", () => { + const prompt = getDefaultSkillPrompt(); + + assert.equal(prompt.includes(""), true); + assert.equal(prompt.includes("# Karpathy Guidelines"), true); + assert.equal(prompt.includes("Use the skill documents below to assist the user:"), true); + assert.equal(prompt.includes('path="templates/skills/'), false); +}); + +test("getDefaultSkillPrompt skips disabled default skills", () => { + const prompt = getDefaultSkillPrompt({ enabledSkills: { "karpathy-guidelines": false } }); + + assert.equal(prompt, ""); +}); + +test("buildSkillDocumentsPrompt excludes SKILL.md frontmatter metadata", () => { + const prompt = buildSkillDocumentsPrompt([ + { + name: "example", + content: + "---\nname: example\ndescription: Example skill\nlicense: MIT\ncompatibility: Node.js\nallowed-tools: Read Bash\nmetadata:\n author: test\n allow-implicit-invocation: false\n---\n# Example Skill\n\nUse these instructions.\n", + }, + ]); + + assert.equal(prompt.includes("name: example"), true); + assert.equal(prompt.includes("description: Example skill"), true); + assert.equal(prompt.includes("license: MIT"), true); + assert.equal(prompt.includes("compatibility: Node.js"), true); + assert.equal(prompt.includes("allowed-tools: Read Bash"), true); + assert.equal(prompt.includes("# Example Skill"), true); + assert.equal(prompt.includes("Use these instructions."), true); + assert.equal(prompt.includes("metadata:"), false); + assert.equal(prompt.includes("author: test"), false); + assert.equal(prompt.includes("allow-implicit-invocation"), false); +}); + +test("buildSkillDocumentsPrompt lists skill resources", () => { + const skillDir = createTempDir("deepcode-skill-resources-"); + fs.mkdirSync(path.join(skillDir, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(skillDir, "references"), { recursive: true }); + const skillPath = path.join(skillDir, "SKILL.md"); + fs.writeFileSync(skillPath, "# PDF Skill\n", "utf8"); + fs.writeFileSync(path.join(skillDir, "scripts", "extract.py"), "print('extract')\n", "utf8"); + fs.writeFileSync(path.join(skillDir, "scripts", "merge.py"), "print('merge')\n", "utf8"); + fs.writeFileSync(path.join(skillDir, "references", "pdf-spec-summary.md"), "# PDF Spec\n", "utf8"); + + const prompt = buildSkillDocumentsPrompt([ + { name: "pdf", content: "# PDF Skill", path: skillPath, skillFilePath: skillPath }, + ]); + + assert.equal(prompt.includes(``), true); + assert.equal(prompt.includes(""), true); + assert.equal(prompt.includes("scripts/extract.py"), true); + assert.equal(prompt.includes("scripts/merge.py"), true); + assert.equal(prompt.includes("references/pdf-spec-summary.md"), true); + assert.equal(prompt.includes("SKILL.md"), false); +}); + +test("buildSkillDocumentsPrompt caps large skill resource listings", () => { + const skillDir = createTempDir("deepcode-skill-resource-cap-"); + const skillPath = path.join(skillDir, "SKILL.md"); + fs.writeFileSync(skillPath, "# Large Skill\n", "utf8"); + for (let index = 0; index < 55; index += 1) { + fs.writeFileSync(path.join(skillDir, `file-${String(index).padStart(2, "0")}.txt`), "resource\n", "utf8"); + } + + const prompt = buildSkillDocumentsPrompt([ + { name: "large", content: "# Large Skill", path: skillPath, skillFilePath: skillPath }, + ]); + + assert.equal((prompt.match(//g) ?? []).length, 50); + assert.equal(prompt.includes("file-49.txt"), true); + assert.equal(prompt.includes("file-50.txt"), false); + assert.equal(prompt.includes("Listing capped at 50 files and may be incomplete."), true); +}); + +test("buildSkillDocumentsPrompt excludes hidden and generated skill resources", () => { + const skillDir = createTempDir("deepcode-skill-resource-exclusions-"); + fs.mkdirSync(path.join(skillDir, ".hidden"), { recursive: true }); + fs.mkdirSync(path.join(skillDir, "node_modules", "pkg"), { recursive: true }); + fs.mkdirSync(path.join(skillDir, "dist"), { recursive: true }); + const skillPath = path.join(skillDir, "SKILL.md"); + fs.writeFileSync(skillPath, "# Clean Skill\n", "utf8"); + fs.writeFileSync(path.join(skillDir, ".secret.txt"), "hidden\n", "utf8"); + fs.writeFileSync(path.join(skillDir, ".hidden", "file.txt"), "hidden\n", "utf8"); + fs.writeFileSync(path.join(skillDir, "node_modules", "pkg", "index.js"), "module.exports = {}\n", "utf8"); + fs.writeFileSync(path.join(skillDir, "dist", "bundle.js"), "bundle\n", "utf8"); + fs.writeFileSync(path.join(skillDir, "README.md"), "# Resource\n", "utf8"); + + const prompt = buildSkillDocumentsPrompt([ + { name: "clean", content: "# Clean Skill", path: skillPath, skillFilePath: skillPath }, + ]); + + assert.equal(prompt.includes("README.md"), true); + assert.equal(prompt.includes(".secret.txt"), false); + assert.equal(prompt.includes(".hidden/file.txt"), false); + assert.equal(prompt.includes("node_modules/pkg/index.js"), false); + assert.equal(prompt.includes("dist/bundle.js"), false); +}); + +test("getSystemPrompt does not include current date guidance", () => { + const now = new Date(); + const expected = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; + const prompt = getSystemPrompt("/tmp/project"); + assert.equal(prompt.includes(expected), false); +}); + +test("getRuntimeContext includes current date and model guidance", () => { + const now = new Date(); + const expectedDate = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; + const prompt = getRuntimeContext("/tmp/project", "deepseek-v4-pro"); + assert.equal(prompt.includes(expectedDate), true); + assert.equal(prompt.includes("当前LLM模型为deepseek-v4-pro,对话中可通过/model命令切换模型。"), true); + assert.equal(prompt.includes("# Local Workspace Environment"), true); + assert.equal(prompt.includes('"root path": "/tmp/project"'), true); +}); + +test("getSystemPrompt renders Read docs for non-multimodal models", () => { + const prompt = getSystemPrompt("/tmp/project", { model: "deepseek-chat" }); + assert.equal(prompt.includes("the current model is not multimodal"), true); + assert.equal(prompt.includes("the contents are presented visually"), false); +}); + +test("runtime prompt assets live under templates", () => { + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "web-search.md")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md.ejs")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "prompts", "init_command.md.ejs")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "skills", "karpathy-guidelines.md")), true); + assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md")), false); + assert.equal(fs.existsSync(path.join(repoRoot, "docs", "tools")), false); + assert.equal(fs.existsSync(path.join(repoRoot, "docs", "prompts")), false); +}); diff --git a/packages/core/src/tests/run-tests.mjs b/packages/core/src/tests/run-tests.mjs new file mode 100644 index 00000000..ce87cbd2 --- /dev/null +++ b/packages/core/src/tests/run-tests.mjs @@ -0,0 +1,15 @@ +// Test runner for @vegamo/deepcode-core +import { globSync } from "glob"; +import { spawnSync } from "child_process"; +import { fileURLToPath } from "url"; +import * as path from "path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const testFiles = globSync("*.test.ts", { cwd: __dirname }); + +const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { + stdio: "inherit", + cwd: __dirname, +}); + +process.exit(result.status ?? 1); diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts new file mode 100644 index 00000000..57b981bb --- /dev/null +++ b/packages/core/src/tests/session.test.ts @@ -0,0 +1,3830 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { GitFileHistory } from "../common/file-history"; +import { clearSessionState } from "../common/state"; +import { getProjectCode, SessionManager, type SessionMessage, type SkillInfo } from "../session"; + +const originalFetch = globalThis.fetch; +const originalConsoleWarn = console.warn; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempDirs: string[] = []; +const PLAN_MODE_STATUS_MESSAGE = "/plan\n └ Set Plan Mode on. Awaiting ."; + +/** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */ +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + +afterEach(() => { + globalThis.fetch = originalFetch; + console.warn = originalConsoleWarn; + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("getProjectCode shortens long project roots for Windows-compatible storage paths", () => { + const shortRoot = "short-project"; + assert.equal(getProjectCode(shortRoot), shortRoot.replace(/[\\/]/g, "-").replace(/:/g, "")); + + const longRoot = path.join( + os.tmpdir(), + "deepcode-project-code-workspace-with-a-long-name-that-would-create-long-git-internal-paths" + ); + const projectCode = getProjectCode(longRoot); + + assert.ok(projectCode.length <= 64); + assert.match(projectCode, /^[A-Za-z0-9._-]+$/); + assert.notEqual(projectCode, longRoot.replace(/[\\/]/g, "-").replace(/:/g, "")); +}); + +test("SessionManager preserves structured system content when building OpenAI messages", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const messages: SessionMessage[] = [ + { + id: "system-image", + sessionId: "session-1", + role: "system", + content: "The read tool has loaded `pixel.png`.", + contentParams: [ + { + type: "image_url", + image_url: { url: "data:image/png;base64,abc123" }, + }, + ], + messageParams: null, + compacted: false, + visible: false, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }, + ]; + + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + role: string; + content: unknown; + }>; + + assert.equal(openAIMessages.length, 1); + assert.equal(openAIMessages[0]?.role, "system"); + assert.deepEqual(openAIMessages[0]?.content, [ + { type: "text", text: "The read tool has loaded `pixel.png`." }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,abc123" }, + }, + ]); +}); + +test("SessionManager appends failed background log tail as XML", () => { + const workspace = createTempDir("deepcode-background-log-workspace-"); + const home = createTempDir("deepcode-background-log-home-"); + setHomeDir(home); + const outputPath = path.join(workspace, "background.log"); + fs.writeFileSync(outputPath, ["before", "failure & one", "failure line two"].join("\n"), "utf8"); + let systemMessage: SessionMessage | null = null; + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: (message) => { + systemMessage = message; + }, + }); + + (manager as any).addBackgroundProcessCompletionMessage("session-background-fail", { + command: "npm test", + outputPath, + ok: false, + exitCode: 1, + signal: null, + startedAtMs: 0, + completedAtMs: 1200, + }); + + assert.ok(systemMessage); + const message = systemMessage as SessionMessage; + assert.equal(message.role, "system"); + const content = message.content ?? ""; + assert.match(content, /Background command "npm test" failed with exit code 1/); + assert.match(content, new RegExp(``)); + assert.match(content, /failure & one[\s\S]*failure line two/); + assert.doesNotMatch(content, /failure <line> & one/); + assert.doesNotMatch(content, //); + assert.doesNotMatch(content, //); +}); + +test("SessionManager filters image content for non-multimodal models", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "deepseek-chat", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "deepseek-chat" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const messages: SessionMessage[] = [ + { + id: "system-image", + sessionId: "session-1", + role: "system", + content: "The read tool has loaded `pixel.png`.", + contentParams: [ + { + type: "image_url", + image_url: { url: "data:image/png;base64,abc123" }, + }, + ], + messageParams: null, + compacted: false, + visible: false, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }, + ]; + + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "deepseek-chat") as Array<{ + role: string; + content: unknown; + }>; + + assert.equal(openAIMessages.length, 1); + assert.deepEqual(openAIMessages[0]?.content, [{ type: "text", text: "The read tool has loaded `pixel.png`." }]); +}); + +test("SessionManager preserves empty reasoning content on assistant tool calls", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const message = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "read", arguments: "{}" }, + }, + ], + "" + ) as SessionMessage; + + assert.deepEqual(message.messageParams, { + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "read", arguments: "{}" }, + }, + ], + reasoning_content: "", + }); + + const openAIMessages = (manager as any).buildOpenAIMessages([message], true, "test-model") as Array<{ + reasoning_content?: string; + }>; + + assert.equal(openAIMessages[0]?.reasoning_content, ""); +}); + +test("SessionManager repairs legacy thinking tool calls missing reasoning content", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const messages: SessionMessage[] = [ + { + id: "assistant-tool", + sessionId: "session-1", + role: "assistant", + content: "", + contentParams: null, + messageParams: { + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "read", arguments: "{}" }, + }, + ], + }, + compacted: false, + visible: false, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }, + ]; + + const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true, "test-model") as Array<{ + reasoning_content?: string; + }>; + const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + reasoning_content?: string; + }>; + + assert.equal(thinkingMessages[0]?.reasoning_content, ""); + assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); +}); + +test("SessionManager replays normal assistant messages with reasoning content in thinking mode", () => { + const manager = new SessionManager({ + projectRoot: process.cwd(), + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const messages: SessionMessage[] = [ + { + id: "assistant-final", + sessionId: "session-1", + role: "assistant", + content: "Final answer", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }, + ]; + + const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true, "test-model") as Array<{ + reasoning_content?: string; + }>; + const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + reasoning_content?: string; + }>; + + assert.equal(thinkingMessages[0]?.reasoning_content, ""); + assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); +}); + +test("SessionManager normalizes legacy sessions without activeTokens to zero", () => { + const workspace = createTempDir("deepcode-legacy-active-tokens-workspace-"); + const home = createTempDir("deepcode-legacy-active-tokens-home-"); + setHomeDir(home); + + const projectCode = getProjectCode(workspace); + const projectDir = path.join(home, ".deepcode", "projects", projectCode); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "sessions-index.json"), + JSON.stringify({ + version: 1, + originalPath: workspace, + entries: [ + { + id: "legacy-session", + status: "completed", + usage: { total_tokens: 123 }, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }, + ], + }), + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-legacy"); + + assert.equal(manager.getSession("legacy-session")?.activeTokens, 0); + assert.equal(manager.getSession("legacy-session")?.usagePerModel, null); +}); + +test("SessionManager keeps usagePerModel null until response usage is available", async () => { + const workspace = createTempDir("deepcode-null-usage-per-model-workspace-"); + const home = createTempDir("deepcode-null-usage-per-model-home-"); + setHomeDir(home); + + const manager = createMockedClientSessionManager(workspace, [{ choices: [{ message: { content: "no usage" } }] }]); + + const sessionId = await manager.createSession({ text: "" }); + + assert.equal(manager.getSession(sessionId)?.usage, null); + assert.equal(manager.getSession(sessionId)?.usagePerModel, null); +}); + +test("SessionManager marks skills loaded from existing session messages", async () => { + const workspace = createTempDir("deepcode-loaded-skills-workspace-"); + const home = createTempDir("deepcode-loaded-skills-home-"); + setHomeDir(home); + + const skillDir = path.join(home, ".agents", "skills", "lessweb-starter"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: lessweb-starter\ndescription: Create Lessweb projects\n---\n# Lessweb Starter\n", + "utf8" + ); + + const projectCode = getProjectCode(workspace); + const projectDir = path.join(home, ".deepcode", "projects", projectCode); + fs.mkdirSync(projectDir, { recursive: true }); + fs.writeFileSync( + path.join(projectDir, "loaded-session.jsonl"), + `${JSON.stringify({ + id: "skill-message", + sessionId: "loaded-session", + role: "system", + content: "Use the skill document below", + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + meta: { + skill: { + name: "lessweb-starter", + path: "~/.agents/skills/lessweb-starter/SKILL.md", + description: "Create Lessweb projects", + isLoaded: true, + }, + }, + })}\n`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-loaded-skills"); + const loadedSkill = (await manager.listSkills("loaded-session")).find((skill) => skill.name === "lessweb-starter"); + + assert.equal(loadedSkill?.isLoaded, true); +}); + +test("SessionManager lists skills from Deep Code and .agents roots by priority", async () => { + const workspace = createTempDir("deepcode-project-skills-workspace-"); + const home = createTempDir("deepcode-project-skills-home-"); + setHomeDir(home); + + const userSkillDir = path.join(home, ".agents", "skills", "shared"); + fs.mkdirSync(userSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(userSkillDir, "SKILL.md"), + "---\nname: shared\ndescription: User-level skill\n---\n# Shared\n", + "utf8" + ); + + const userNativeSkillDir = path.join(home, ".deepcode", "skills", "native-user"); + fs.mkdirSync(userNativeSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(userNativeSkillDir, "SKILL.md"), + "---\nname: native-user\ndescription: User .deepcode skill\n---\n# Native User\n", + "utf8" + ); + + const userNativeSharedSkillDir = path.join(home, ".deepcode", "skills", "shared"); + fs.mkdirSync(userNativeSharedSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(userNativeSharedSkillDir, "SKILL.md"), + "---\nname: shared\ndescription: User .deepcode skill\n---\n# Shared\n", + "utf8" + ); + + const projectAgentsSkillDir = path.join(workspace, ".agents", "skills", "shared"); + fs.mkdirSync(projectAgentsSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(projectAgentsSkillDir, "SKILL.md"), + "---\nname: shared\ndescription: Project .agents skill\n---\n# Shared\n", + "utf8" + ); + + const projectNativeSkillDir = path.join(workspace, ".deepcode", "skills", "shared"); + fs.mkdirSync(projectNativeSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(projectNativeSkillDir, "SKILL.md"), + "---\nname: shared\ndescription: Project .deepcode skill\n---\n# Shared\n", + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-project-skills"); + const skills = await manager.listSkills(); + const nativeUserSkill = skills.find((skill) => skill.name === "native-user"); + const sharedSkill = skills.find((skill) => skill.name === "shared"); + + assert.equal(nativeUserSkill?.path, "~/.deepcode/skills/native-user/SKILL.md"); + assert.equal(nativeUserSkill?.description, "User .deepcode skill"); + assert.equal(sharedSkill?.path, "./.deepcode/skills/shared/SKILL.md"); + assert.equal(sharedSkill?.description, "Project .deepcode skill"); +}); + +test("SessionManager lists bundled skills at lowest priority", async () => { + const workspace = createTempDir("deepcode-bundled-skills-workspace-"); + const home = createTempDir("deepcode-bundled-skills-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-bundled-skills"); + const skills = await manager.listSkills(); + const skillWriter = skills.find((skill) => skill.name === "skill-writer"); + const selfRefer = skills.find((skill) => skill.name === "deepcode-self-refer"); + + assert.equal(skillWriter?.path, "bundled:skill-writer/SKILL.md"); + assert.equal(selfRefer?.path, "bundled:deepcode-self-refer/SKILL.md"); + assert.match(skillWriter?.description ?? "", /Guide users through creating/); +}); + +test("SessionManager lets project skills override bundled skills", async () => { + const workspace = createTempDir("deepcode-bundled-override-workspace-"); + const home = createTempDir("deepcode-bundled-override-home-"); + setHomeDir(home); + + const projectSkillDir = path.join(workspace, ".deepcode", "skills", "skill-writer"); + fs.mkdirSync(projectSkillDir, { recursive: true }); + fs.writeFileSync( + path.join(projectSkillDir, "SKILL.md"), + "---\nname: skill-writer\ndescription: Project override skill writer\n---\n# Project Skill Writer\n", + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-bundled-override"); + const skillWriter = (await manager.listSkills()).find((skill) => skill.name === "skill-writer"); + + assert.equal(skillWriter?.path, "./.deepcode/skills/skill-writer/SKILL.md"); + assert.equal(skillWriter?.description, "Project override skill writer"); +}); + +test("SessionManager resolves bundled skill prompts", () => { + const workspace = createTempDir("deepcode-bundled-prompt-workspace-"); + const home = createTempDir("deepcode-bundled-prompt-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-bundled-prompt"); + const prompt = (manager as any).buildSkillPrompt({ + name: "skill-writer", + path: "bundled:skill-writer/SKILL.md", + description: "Write skills", + }); + + assert.match(prompt, / { + const workspace = createTempDir("deepcode-plan-skill-workspace-"); + const home = createTempDir("deepcode-plan-skill-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-plan-skill"); + const planSkill = await getPlanSkill(manager); + + const sessionId = await manager.createSession({ text: "", skills: [planSkill] }); + let messages = manager.listSessionMessages(sessionId); + assert.equal(countPlanModeStatusMessages(messages), 1); + assert.equal(countLoadedSkillMessages(messages, "plan"), 1); + + await manager.replySession(sessionId, { text: "", skills: [planSkill] }); + messages = manager.listSessionMessages(sessionId); + assert.equal(countPlanModeStatusMessages(messages), 2); + assert.equal(countLoadedSkillMessages(messages, "plan"), 1); +}); + +test("SessionManager appends plan mode status when the plan skill is auto-matched", async () => { + const workspace = createTempDir("deepcode-plan-matched-workspace-"); + const home = createTempDir("deepcode-plan-matched-home-"); + setHomeDir(home); + + const client = { + chat: { + completions: { + create: async (request: any) => { + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(["plan"]); + } + return createChatResponse("planned", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }); + }, + }, + }, + }; + const manager = createMockedClientSessionManagerWithClient(workspace, client); + + const sessionId = await manager.createSession({ text: "Plan Mode for this change" }); + const messages = manager.listSessionMessages(sessionId); + assert.equal(countPlanModeStatusMessages(messages), 1); + assert.equal(countLoadedSkillMessages(messages, "plan"), 1); +}); + +test("SessionManager appends plan mode status for deferred permission prompts", async () => { + const workspace = createTempDir("deepcode-plan-deferred-workspace-"); + const home = createTempDir("deepcode-plan-deferred-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-plan-deferred"); + const sessionId = await manager.createSession({ text: "" }); + const planSkill = await getPlanSkill(manager); + + await (manager as any).appendDeferredPermissionPrompt( + sessionId, + { text: "", skills: [planSkill] }, + new AbortController() + ); + + const messages = manager.listSessionMessages(sessionId); + assert.equal(countPlanModeStatusMessages(messages), 1); + assert.equal(countLoadedSkillMessages(messages, "plan"), 1); +}); + +test("SessionManager excludes disabled skills by resolved skill name", async () => { + const workspace = createTempDir("deepcode-disabled-skills-workspace-"); + const home = createTempDir("deepcode-disabled-skills-home-"); + setHomeDir(home); + + const writeSkill = (root: string, dirName: string, skillName: string): void => { + const skillDir = path.join(root, dirName); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `---\nname: ${skillName}\ndescription: ${skillName} description\n---\n# ${skillName}\n`, + "utf8" + ); + }; + + for (const root of [ + path.join(workspace, ".deepcode", "skills"), + path.join(workspace, ".agents", "skills"), + path.join(home, ".deepcode", "skills"), + path.join(home, ".agents", "skills"), + ]) { + writeSkill(root, "skill-writer", "skill-writer"); + } + writeSkill(path.join(workspace, ".deepcode", "skills"), "frontmatter-disabled", "renamed-disabled"); + writeSkill(path.join(workspace, ".deepcode", "skills"), "enabled-skill", "enabled-skill"); + + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: null, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + machineId: "machine-id-disabled-skills", + }), + getResolvedSettings: () => ({ + model: "test-model", + enabledSkills: { + "skill-writer": false, + "renamed-disabled": false, + "deepcode-self-refer": false, + "skill-digester": false, + plan: false, + "enabled-skill": true, + }, + }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const skills = await manager.listSkills(); + const skillNames = skills.map((skill) => skill.name); + + assert.deepEqual(skillNames, ["enabled-skill"]); + assert.equal(skills[0]?.path, "./.deepcode/skills/enabled-skill/SKILL.md"); +}); + +test("SessionManager keeps implicit opt-out skills available for manual invocation", async () => { + const workspace = createTempDir("deepcode-manual-only-skill-workspace-"); + const home = createTempDir("deepcode-manual-only-skill-home-"); + setHomeDir(home); + + const skillDir = path.join(workspace, ".agents", "skills", "manual-only"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: manual-only\ndescription: Manual-only skill\nmetadata:\n allow-implicit-invocation: false\n---\n# Manual Only\n", + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-manual-only-skill"); + const skill = (await manager.listSkills()).find((candidate) => candidate.name === "manual-only"); + assert.ok(skill); + assert.equal(skill.allowImplicitInvocation, false); + + const sessionId = await manager.createSession({ text: "", skills: [skill] }); + const skillMessages = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "system" && message.meta?.skill?.name === "manual-only"); + + assert.equal(skillMessages.length, 1); + assert.match(skillMessages[0]?.content ?? "", / { + const workspace = createTempDir("deepcode-implicit-opt-out-workspace-"); + const home = createTempDir("deepcode-implicit-opt-out-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + const writeSkill = (name: string, metadata = ""): void => { + const skillDir = path.join(workspace, ".deepcode", "skills", name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${name} description${metadata}\n---\n# ${name}\n`, + "utf8" + ); + }; + writeSkill("auto-skill"); + writeSkill("manual-only", "\nmetadata:\n allow-implicit-invocation: false"); + + const requests: any[] = []; + const client = { + chat: { + completions: { + create: async (request: any) => { + requests.push(request); + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(["manual-only", "auto-skill"]); + } + return createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }); + }, + }, + }, + }; + const manager = createMockedClientSessionManagerWithClient(workspace, client); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "choose an automatic skill" }); + const matchingPrompt = String(requests[0]?.messages?.[0]?.content ?? ""); + + assert.match(matchingPrompt, /"name": "auto-skill"/); + assert.doesNotMatch(matchingPrompt, /"name": "manual-only"/); + assert.equal(countLoadedSkillMessages(manager.listSessionMessages(sessionId), "auto-skill"), 1); + assert.equal(countLoadedSkillMessages(manager.listSessionMessages(sessionId), "manual-only"), 0); +}); + +test("SessionManager dispose disconnects MCP servers", async () => { + const workspace = createTempDir("deepcode-mcp-dispose-workspace-"); + const serverPath = path.join(workspace, "mcp-server.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + if (request.params && request.params.cursor === "page-2") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "count", inputSchema: { type: "object", properties: {} } } + ] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "echo", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } } + ], nextCursor: "page-2" } }); + return; + } + if (request.method === "tools/call") { + send({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: request.params.name + ":" + (request.params.arguments.text || "") }] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-dispose"); + const initPromise = manager.initMcpServers({ smoke: { command: process.execPath, args: [serverPath] } }); + + assert.deepEqual(manager.getMcpStatus(), [ + { + name: "smoke", + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, + ]); + + await initPromise; + + assert.deepEqual(manager.getMcpStatus(), [ + { + name: "smoke", + status: "ready", + connected: true, + toolCount: 2, + tools: ["mcp__smoke__echo", "mcp__smoke__count"], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, + ]); + const mcpManager = (manager as any).mcpManager; + assert.equal(mcpManager.getMcpToolDefinitions()[0].function.name, "mcp__smoke__echo"); + assert.deepEqual(await mcpManager.executeMcpTool("mcp__smoke__echo", { text: "ok" }), { + ok: true, + name: "mcp__smoke__echo", + output: "echo:ok", + }); + + manager.dispose(); + + assert.deepEqual(manager.getMcpStatus(), []); +}); + +test("SessionManager exposes MCP tools with API-safe names and preserves original dispatch names", async () => { + const workspace = createTempDir("deepcode-mcp-safe-name-workspace-"); + const serverPath = path.join(workspace, "mcp-invalid-name-server.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "speak.text", description: "Speak text", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }, + { name: "speak/text", description: "Speak text using a slash name", inputSchema: { type: "object", properties: {} } } + ] } }); + return; + } + if (request.method === "tools/call") { + send({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: request.params.name + ":" + (request.params.arguments.text || "") }] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-safe-name"); + await manager.initMcpServers({ "voice.box": { command: process.execPath, args: [serverPath] } }); + + const status = manager.getMcpStatus()[0]; + assert.equal(status?.status, "ready"); + assert.deepEqual(status?.tools, ["mcp__voice_box__speak_text", "mcp__voice_box__speak_text_59a610ad"]); + + const mcpManager = (manager as any).mcpManager; + const definitions = mcpManager.getMcpToolDefinitions(); + assert.equal(definitions[0].function.name, "mcp__voice_box__speak_text"); + assert.match(definitions[0].function.name, /^[a-zA-Z0-9_-]+$/); + assert.match(definitions[0].function.description, /MCP source: voice\.box: speak\.text/); + assert.deepEqual(await mcpManager.executeMcpTool("mcp__voice_box__speak_text", { text: "ok" }), { + ok: true, + name: "mcp__voice_box__speak_text", + output: "speak.text:ok", + }); + + manager.dispose(); +}); + +test("SessionManager dispose kills live processes without timeout controls", (t) => { + if (process.platform === "win32") { + t.skip("process group kill assertion is non-Windows specific"); + return; + } + + const workspace = createTempDir("deepcode-dispose-process-workspace-"); + const home = createTempDir("deepcode-dispose-process-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-dispose-process"); + const sessionId = createSessionAndMessages(manager, "session-dispose-process", "Dispose process session"); + const originalKill = process.kill; + const killed: Array<{ pid: number; signal?: NodeJS.Signals | number }> = []; + + try { + process.kill = ((pid: number, signal?: NodeJS.Signals | number) => { + killed.push({ pid, signal }); + return true; + }) as typeof process.kill; + + (manager as any).addSessionProcess(sessionId, 1234, "python3 -m http.server 8080"); + manager.dispose(); + } finally { + process.kill = originalKill; + } + + assert.deepEqual(killed, [{ pid: -1234, signal: "SIGKILL" }]); +}); + +test("SessionManager deleteSession ignores persisted processes that are not live", (t) => { + if (process.platform === "win32") { + t.skip("process group kill assertion is non-Windows specific"); + return; + } + + const workspace = createTempDir("deepcode-delete-stale-process-workspace-"); + const home = createTempDir("deepcode-delete-stale-process-home-"); + setHomeDir(home); + const manager = createSessionManager(workspace, "machine-id-delete-stale-process"); + const sessionId = createSessionAndMessages(manager, "session-delete-stale-process", "Delete stale process session"); + (manager as any).updateSessionEntry(sessionId, (entry: any) => ({ + ...entry, + processes: new Map([["1234", { startTime: new Date().toISOString(), command: "stale process" }]]), + })); + const originalKill = process.kill; + const killed: Array<{ pid: number; signal?: NodeJS.Signals | number }> = []; + + try { + process.kill = ((pid: number, signal?: NodeJS.Signals | number) => { + killed.push({ pid, signal }); + return true; + }) as typeof process.kill; + + assert.equal(manager.deleteSession(sessionId), true); + } finally { + process.kill = originalKill; + } + + assert.deepEqual(killed, []); +}); + +test("SessionManager refreshes cached MCP tool definitions after server crash", async () => { + const workspace = createTempDir("deepcode-mcp-crash-cache-workspace-"); + const serverPath = path.join(workspace, "mcp-server-crash.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [ + { name: "echo", inputSchema: { type: "object", properties: {} } } + ] } }); + return; + } + if (request.method === "prompts/list") { + send({ jsonrpc: "2.0", id: request.id, result: { prompts: [] } }); + return; + } + if (request.method === "resources/list") { + send({ jsonrpc: "2.0", id: request.id, result: { resources: [] } }); + setTimeout(() => process.exit(9), 10); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-crash-cache"); + await manager.initMcpServers({ crashy: { command: process.execPath, args: [serverPath] } }); + + assert.equal(manager.getMcpStatus()[0]?.status, "ready"); + assert.equal((manager as any).mcpToolDefinitions.length, 1); + + await waitForMcpStatus(manager, "failed"); + + assert.equal((manager as any).mcpToolDefinitions.length, 0); + + manager.dispose(); +}); + +test("SessionManager reports configured MCP servers as starting before initialization", () => { + const workspace = createTempDir("deepcode-mcp-configured-workspace-"); + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ + model: "test-model", + mcpServers: { + playwright: { command: "npx", args: ["@playwright/mcp@latest"] }, + }, + }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + assert.deepEqual(manager.getMcpStatus(), [ + { + name: "playwright", + status: "starting", + connected: false, + toolCount: 0, + tools: [], + promptCount: 0, + prompts: [], + resourceCount: 0, + resources: [], + }, + ]); +}); + +test("SessionManager reports MCP startup stderr on failure", async () => { + const workspace = createTempDir("deepcode-mcp-failure-workspace-"); + const serverPath = path.join(workspace, "mcp-server-fail.cjs"); + fs.writeFileSync(serverPath, 'process.stderr.write("mcp startup boom"); process.exit(7);', "utf8"); + + const manager = createSessionManager(workspace, "machine-id-mcp-failure"); + await manager.initMcpServers({ broken: { command: process.execPath, args: [serverPath] } }); + + const [status] = manager.getMcpStatus(); + assert.equal(status?.name, "broken"); + assert.equal(status?.status, "failed"); + assert.equal(status?.connected, false); + assert.match(status?.error ?? "", /mcp startup boom/); +}); + +test( + "SessionManager adds -y when launching MCP servers through npx", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-mcp-npx-workspace-"); + const argsPath = path.join(workspace, "args.json"); + const fakeNpxPath = path.join(workspace, "npx"); + fs.writeFileSync( + fakeNpxPath, + `#!/usr/bin/env node +const fs = require("fs"); +const readline = require("readline"); +fs.writeFileSync(process.env.ARGS_PATH, JSON.stringify(process.argv.slice(2))); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) { + return; + } + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + fs.chmodSync(fakeNpxPath, 0o755); + + const manager = createSessionManager(workspace, "machine-id-mcp-npx"); + await manager.initMcpServers({ + npxed: { command: fakeNpxPath, args: ["@playwright/mcp@latest"], env: { ARGS_PATH: argsPath } }, + }); + + assert.deepEqual(JSON.parse(fs.readFileSync(argsPath, "utf8")) as string[], ["-y", "@playwright/mcp@latest"]); + manager.dispose(); + } +); + +test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { + const workspace = createTempDir("deepcode-init-deepcode-workspace-"); + const home = createTempDir("deepcode-init-deepcode-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); + fs.writeFileSync(path.join(workspace, ".deepcode", "AGENTS.md"), "deepcode project instructions", "utf8"); + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-deepcode"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "/init" }); + const messages = manager.listSessionMessages(sessionId); + const userMessage = messages.find((message) => message.role === "user"); + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + }>; + const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); + const systemContents = messages + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.equal(userMessage?.content, "/init"); + assert.match(openAIUserMessage?.content ?? "", /Update \.\/\.deepcode\/AGENTS\.md/); + assert.doesNotMatch(openAIUserMessage?.content ?? "", /Update \.\/AGENTS\.md/); + assert.ok(systemContents.includes("deepcode project instructions")); + assert.ok(!systemContents.includes("root project instructions")); +}); + +test("createSession appends default system prompts in prefix-cache-friendly order", async () => { + const workspace = createTempDir("deepcode-system-order-workspace-"); + const home = createTempDir("deepcode-system-order-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-system-order"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "hello" }); + const systemContents = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.equal(systemContents.length >= 4, true); + assert.match(systemContents[0] ?? "", /# Available Tools/); + assert.doesNotMatch(systemContents[0] ?? "", /# Local Workspace Environment/); + assert.doesNotMatch(systemContents[0] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[1] ?? "", //); + assert.match(systemContents[1] ?? "", /# Karpathy Guidelines/); + assert.doesNotMatch(systemContents[1] ?? "", /path="templates\/skills\//); + assert.doesNotMatch(systemContents[1] ?? "", /当前LLM模型为test-model/); + assert.match(systemContents[2] ?? "", /# Local Workspace Environment/); + assert.match(systemContents[2] ?? "", /当前LLM模型为test-model/); + const environmentJsonMatch = (systemContents[2] ?? "").match(/```json\n([\s\S]+?)\n```/); + assert.ok(environmentJsonMatch); + const environmentInfo = JSON.parse(environmentJsonMatch[1] ?? "{}") as { "root path"?: string }; + assert.equal(environmentInfo["root path"], workspace); + assert.equal(systemContents[3], "root project instructions"); +}); + +test("createSession skips disabled default skills", async () => { + const workspace = createTempDir("deepcode-disabled-default-skill-workspace-"); + const home = createTempDir("deepcode-disabled-default-skill-home-"); + setHomeDir(home); + + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: null, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + machineId: "machine-id-disabled-default-skill", + }), + getResolvedSettings: () => ({ + model: "test-model", + enabledSkills: { "karpathy-guidelines": false }, + }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const sessionId = await manager.createSession({ text: "hello" }); + const systemContents = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.equal(systemContents.length, 2); + assert.match(systemContents[0] ?? "", /# Available Tools/); + assert.doesNotMatch(systemContents.join("\n"), //); + assert.match(systemContents[1] ?? "", /# Local Workspace Environment/); +}); + +test("createSession includes agent instructions in the skill matching system prompt", async () => { + const workspace = createTempDir("deepcode-skill-match-create-workspace-"); + const home = createTempDir("deepcode-skill-match-create-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); + fs.writeFileSync(path.join(workspace, ".deepcode", "AGENTS.md"), "prefer project-specific skill matching", "utf8"); + const skillDir = path.join(workspace, ".deepcode", "skills", "project-aware"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: project-aware\ndescription: Match project-specific instructions\n---\n# Project Aware\n", + "utf8" + ); + + const requests: any[] = []; + const client = { + chat: { + completions: { + create: async (request: any) => { + requests.push(request); + return { choices: [{ message: { content: '{"skillNames":[]}' } }] }; + }, + }, + }, + }; + const manager = createMockedClientSessionManagerWithClient(workspace, client); + (manager as any).activateSession = async () => {}; + + await manager.createSession({ text: "pick the right workflow" }); + + const messages = (requests[0]?.messages ?? []) as Array<{ role?: string; content?: string }>; + assert.equal(messages[0]?.role, "system"); + assert.match(messages[0]?.content ?? "", //); + assert.match(messages[0]?.content ?? "", /prefer project-specific skill matching/); + assert.match(messages[0]?.content ?? "", /<\/agent-instructions>/); + assert.match(messages[0]?.content ?? "", /The candidate skills are as follows/); + assert.equal(messages[1]?.role, "user"); +}); + +test("replySession includes current agent instructions in the skill matching system prompt", async () => { + const workspace = createTempDir("deepcode-skill-match-reply-workspace-"); + const home = createTempDir("deepcode-skill-match-reply-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + const requests: any[] = []; + const client = { + chat: { + completions: { + create: async (request: any) => { + requests.push(request); + return { choices: [{ message: { content: '{"skillNames":[]}' } }] }; + }, + }, + }, + }; + const manager = createMockedClientSessionManagerWithClient(workspace, client); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "" }); + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "use reply-time agent instructions", "utf8"); + const skillDir = path.join(workspace, ".agents", "skills", "reply-aware"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: reply-aware\ndescription: Match reply-time instructions\n---\n# Reply Aware\n", + "utf8" + ); + + await manager.replySession(sessionId, { text: "pick the reply workflow" }); + + const messages = (requests[0]?.messages ?? []) as Array<{ role?: string; content?: string }>; + assert.equal(messages[0]?.role, "system"); + assert.match(messages[0]?.content ?? "", //); + assert.match(messages[0]?.content ?? "", /use reply-time agent instructions/); + assert.match(messages[0]?.content ?? "", /<\/agent-instructions>/); + assert.match(messages[0]?.content ?? "", /The candidate skills are as follows/); + assert.equal(messages[1]?.role, "user"); +}); + +test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { + const workspace = createTempDir("deepcode-init-root-workspace-"); + const home = createTempDir("deepcode-init-root-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-root"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + await manager.replySession(sessionId, { text: "/init" }); + const messages = manager.listSessionMessages(sessionId); + const userMessages = messages.filter((message) => message.role === "user"); + const replyMessage = userMessages[userMessages.length - 1]; + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + }>; + const openAIUserMessages = openAIMessages.filter((message) => message.role === "user"); + const openAIReplyMessage = openAIUserMessages[openAIUserMessages.length - 1]; + + assert.equal(replyMessage?.content, "/init"); + assert.match(openAIReplyMessage?.content ?? "", /Update \.\/AGENTS\.md/); +}); + +test("createSession stores /init and sends generate prompt when no project AGENTS file is effective", async () => { + const workspace = createTempDir("deepcode-init-generate-workspace-"); + const home = createTempDir("deepcode-init-generate-home-"); + setHomeDir(home); + globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; + + fs.mkdirSync(path.join(home, ".deepcode"), { recursive: true }); + fs.writeFileSync(path.join(home, ".deepcode", "AGENTS.md"), "user instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-init-generate"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "/init" }); + const messages = manager.listSessionMessages(sessionId); + const userMessage = messages.find((message) => message.role === "user"); + const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ + role: string; + content: string; + }>; + const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); + + assert.equal(userMessage?.content, "/init"); + assert.match(openAIUserMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); + assert.doesNotMatch(openAIUserMessage?.content ?? "", /Update \.\/AGENTS\.md/); +}); + +test("createSession reports a new prompt with the machineId token", async () => { + const workspace = createTempDir("deepcode-session-workspace-"); + const home = createTempDir("deepcode-session-home-"); + setHomeDir(home); + + const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + fetchCalls.push({ input, init }); + return { + ok: true, + text: async () => "", + } as Response; + }) as typeof fetch; + + const manager = createSessionManager(workspace, "machine-id-123"); + const activatedSessionIds: string[] = []; + (manager as any).activateSession = async (sessionId: string) => { + activatedSessionIds.push(sessionId); + }; + + const sessionId = await manager.createSession({ text: "hello world" }); + await flushPromises(); + + assert.equal(activatedSessionIds.length, 1); + assert.equal(activatedSessionIds[0], sessionId); + assert.equal(fetchCalls.length, 1); + assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/new"); + assert.equal(fetchCalls[0].init?.method, "POST"); + assert.ok(fetchCalls[0].init?.signal instanceof AbortSignal); + assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); + assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-123"); +}); + +test("replySession reports a new prompt with the machineId token", async () => { + const workspace = createTempDir("deepcode-reply-workspace-"); + const home = createTempDir("deepcode-reply-home-"); + setHomeDir(home); + + const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + fetchCalls.push({ input, init }); + return { + ok: true, + text: async () => "", + } as Response; + }) as typeof fetch; + + const manager = createSessionManager(workspace, "machine-id-456"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + await flushPromises(); + fetchCalls.length = 0; + + await manager.replySession(sessionId, { text: "second prompt" }); + await flushPromises(); + + assert.equal(fetchCalls.length, 1); + assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/new"); + assert.equal(fetchCalls[0].init?.method, "POST"); + assert.ok(fetchCalls[0].init?.signal instanceof AbortSignal); + assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); + assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-456"); +}); + +test("reporting a new prompt does not warn when the background request fails", async () => { + const workspace = createTempDir("deepcode-report-failure-workspace-"); + const home = createTempDir("deepcode-report-failure-home-"); + setHomeDir(home); + + const warnings: unknown[][] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + globalThis.fetch = (async () => { + throw new Error("fetch failed"); + }) as typeof fetch; + + const manager = createSessionManager(workspace, "machine-id-failure"); + (manager as any).activateSession = async () => {}; + + await manager.createSession({ text: "hello world" }); + await flushPromises(); + + assert.deepEqual(warnings, []); +}); + +test( + "SessionManager notifies successful completion with session context", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-notify-success-workspace-"); + const home = createTempDir("deepcode-notify-success-home-"); + setHomeDir(home); + + const notifyOutput = path.join(workspace, "notify.jsonl"); + const notifyScript = createNotifyRecorderScript(workspace); + const manager = createNotifyingSessionManager( + workspace, + [createChatResponse("final answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + notifyScript, + notifyOutput + ); + + await manager.createSession({ text: "notify success" }); + + const records = await waitForNotifyRecords(notifyOutput, 1); + assert.equal(records[0]?.STATUS, "completed"); + assert.equal(records[0]?.FAIL_REASON, null); + assert.equal(records[0]?.BODY, "final answer"); + assert.equal(records[0]?.TITLE, "notify success"); + assert.match(String(records[0]?.DURATION), /^\d+$/); + } +); + +test( + "SessionManager notifies failed completion with failure context", + { skip: process.platform === "win32" }, + async () => { + const workspace = createTempDir("deepcode-notify-failure-workspace-"); + const home = createTempDir("deepcode-notify-failure-home-"); + setHomeDir(home); + + const notifyOutput = path.join(workspace, "notify.jsonl"); + const notifyScript = createNotifyRecorderScript(workspace); + const manager = createNotifyingSessionManager( + workspace, + [ + createChatResponse("first answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + new Error("second request failed"), + ], + notifyScript, + notifyOutput + ); + + const sessionId = await manager.createSession({ text: "notify failure" }); + await waitForNotifyRecords(notifyOutput, 1); + await manager.replySession(sessionId, { text: "second prompt" }); + + const records = await waitForNotifyRecords(notifyOutput, 2); + const failedRecord = records[1]; + assert.equal(failedRecord?.STATUS, "failed"); + assert.equal(failedRecord?.FAIL_REASON, "second request failed"); + assert.equal(failedRecord?.BODY, "first answer"); + assert.notEqual(failedRecord?.BODY, "stale-body"); + assert.equal(failedRecord?.TITLE, "notify failure"); + } +); + +test("replySession continues without appending /continue as a user message", async () => { + const workspace = createTempDir("deepcode-continue-workspace-"); + const home = createTempDir("deepcode-continue-home-"); + setHomeDir(home); + + const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + fetchCalls.push({ input, init }); + return { + ok: true, + text: async () => "", + } as Response; + }) as typeof fetch; + + const manager = createSessionManager(workspace, "machine-id-continue"); + const activatedSessionIds: string[] = []; + (manager as any).activateSession = async (sessionId: string) => { + activatedSessionIds.push(sessionId); + }; + + const sessionId = await manager.createSession({ text: "first prompt" }); + await flushPromises(); + const messagesBefore = manager.listSessionMessages(sessionId); + fetchCalls.length = 0; + activatedSessionIds.length = 0; + + await manager.replySession(sessionId, { text: "/continue" }); + await flushPromises(); + + const messagesAfter = manager.listSessionMessages(sessionId); + const userMessages = messagesAfter.filter((message) => message.role === "user"); + + assert.equal(activatedSessionIds.length, 1); + assert.equal(activatedSessionIds[0], sessionId); + assert.equal(messagesAfter.length, messagesBefore.length); + assert.equal( + userMessages.some((message) => message.content === "/continue"), + false + ); + assert.equal(fetchCalls.length, 0); +}); + +test("replySession records the current file-history branch head as checkpointHash", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-checkpoint-hash-workspace-"); + const home = createTempDir("deepcode-checkpoint-hash-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-checkpoint-hash"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "note.txt": "checkpoint\n" }); + + await manager.replySession(sessionId, { text: "second prompt" }); + + const userMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "user"); + assert.equal(userMessages[userMessages.length - 1]?.checkpointHash, checkpointHash); +}); + +test("createSession initializes file-history repo and session branch", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-file-history-init-workspace-"); + const home = createTempDir("deepcode-file-history-init-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-file-history-init"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + const gitDir = path.join(home, ".deepcode", "projects", getProjectCode(workspace), "file-history", ".git"); + + assert.ok(fs.existsSync(gitDir)); + assert.ok(userMessage?.checkpointHash); + assert.equal( + runFileHistoryGit(gitDir, workspace, ["rev-parse", "--verify", `refs/heads/${sessionId}^{commit}`]).trim(), + userMessage.checkpointHash + ); +}); + +test("createSession initializes an empty file-history manifest without scanning existing files", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-file-history-empty-init-workspace-"); + const home = createTempDir("deepcode-file-history-empty-init-home-"); + setHomeDir(home); + fs.writeFileSync(path.join(workspace, "unrelated.txt"), "keep me\n", "utf8"); + fs.mkdirSync(path.join(workspace, "nested")); + fs.writeFileSync(path.join(workspace, "nested", "another.txt"), "also keep me\n", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-file-history-empty-init"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + assert.ok(userMessage?.checkpointHash); + + const manifest = readFileHistoryManifest(home, workspace, userMessage.checkpointHash); + assert.deepEqual(manifest.files, {}); +}); + +test("replySession snapshots manual edits to tracked files before appending the user prompt", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-prompt-checkpoint-manual-edit-workspace-"); + const home = createTempDir("deepcode-prompt-checkpoint-manual-edit-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "hello_world.py"); + const manager = createSessionManager(workspace, "machine-id-prompt-checkpoint-manual-edit"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "create hello world" }); + const gitDir = getFileHistoryGitDir(home, workspace); + const fileHistory = new GitFileHistory(workspace, gitDir); + + fs.writeFileSync(filePath, 'print("Hello, World!")\n', "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "created hello world")); + + const manualEdit = 'if name == main:\n print("Hello, World!")\n'; + fs.writeFileSync(filePath, manualEdit, "utf8"); + await manager.replySession(sessionId, { text: "I manually edited @hello_world.py, note it" }); + const manualEditUserMessage = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "user") + .at(-1); + assert.ok(manualEditUserMessage?.checkpointHash); + + fs.writeFileSync(filePath, 'if __name__ == "__main__":\n print("Hello, World!")\n', "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "fixed hello world")); + + manager.restoreSessionCode(sessionId, manualEditUserMessage.id); + + assert.equal(fs.readFileSync(filePath, "utf8"), manualEdit); +}); + +test("replySession inserts hidden system notice for manually changed tracked files", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-manual-change-notice-workspace-"); + const home = createTempDir("deepcode-manual-change-notice-home-"); + setHomeDir(home); + + const firstPath = path.join(workspace, "a.txt"); + const secondPath = path.join(workspace, "b.txt"); + const manager = createSessionManager(workspace, "machine-id-manual-change-notice"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + fs.writeFileSync(firstPath, "one\n", "utf8"); + fs.writeFileSync(secondPath, "two\n", "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [secondPath, firstPath], "track files")); + + fs.writeFileSync(secondPath, "two changed\n", "utf8"); + fs.writeFileSync(firstPath, "one changed\n", "utf8"); + await manager.replySession(sessionId, { text: "check manual changes" }); + + const messages = manager.listSessionMessages(sessionId); + const userIndex = messages.findIndex( + (message) => message.role === "user" && message.content === "check manual changes" + ); + assert.ok(userIndex > 0); + const notice = messages[userIndex - 1]; + assert.equal(notice?.role, "system"); + assert.equal(notice?.visible, false); + assert.equal(notice?.content, `Note that the user manually modified these files:\n${firstPath}\n${secondPath}`); +}); + +test("replySession does not insert manual-change notice when tracked files are unchanged", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-no-manual-change-notice-workspace-"); + const home = createTempDir("deepcode-no-manual-change-notice-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "tracked.txt"); + const manager = createSessionManager(workspace, "machine-id-no-manual-change-notice"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + fs.writeFileSync(filePath, "same\n", "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "track file")); + + await manager.replySession(sessionId, { text: "second prompt" }); + + const notices = manager + .listSessionMessages(sessionId) + .filter( + (message) => + message.role === "system" && + typeof message.content === "string" && + message.content.startsWith("Note that the user manually modified these files:") + ); + assert.equal(notices.length, 0); +}); + +test("replySession reports manual deletion of a tracked file", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-manual-delete-notice-workspace-"); + const home = createTempDir("deepcode-manual-delete-notice-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "deleted.txt"); + const manager = createSessionManager(workspace, "machine-id-manual-delete-notice"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + fs.writeFileSync(filePath, "delete me\n", "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "track file")); + + fs.unlinkSync(filePath); + await manager.replySession(sessionId, { text: "check deletion" }); + + const notice = manager + .listSessionMessages(sessionId) + .find( + (message) => + message.role === "system" && + message.content === `Note that the user manually modified these files:\n${filePath}` + ); + assert.ok(notice); +}); + +test("replySession ignores manually created untracked files", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-untracked-manual-file-workspace-"); + const home = createTempDir("deepcode-untracked-manual-file-home-"); + setHomeDir(home); + + const trackedPath = path.join(workspace, "tracked.txt"); + const untrackedPath = path.join(workspace, "untracked.txt"); + const manager = createSessionManager(workspace, "machine-id-untracked-manual-file"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + fs.writeFileSync(trackedPath, "tracked\n", "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [trackedPath], "track file")); + + fs.writeFileSync(untrackedPath, "new manual file\n", "utf8"); + await manager.replySession(sessionId, { text: "second prompt" }); + + const notices = manager + .listSessionMessages(sessionId) + .filter( + (message) => + message.role === "system" && + typeof message.content === "string" && + message.content.startsWith("Note that the user manually modified these files:") + ); + assert.equal(notices.length, 0); +}); + +test("replySession does not insert manual-change notice for /continue", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-continue-no-manual-change-notice-workspace-"); + const home = createTempDir("deepcode-continue-no-manual-change-notice-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "tracked.txt"); + const manager = createSessionManager(workspace, "machine-id-continue-no-manual-change-notice"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + fs.writeFileSync(filePath, "before\n", "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "track file")); + + fs.writeFileSync(filePath, "manual change\n", "utf8"); + await manager.replySession(sessionId, { text: "/continue" }); + + const notices = manager + .listSessionMessages(sessionId) + .filter( + (message) => + message.role === "system" && + typeof message.content === "string" && + message.content.startsWith("Note that the user manually modified these files:") + ); + assert.equal(notices.length, 0); +}); + +test("replySession does not insert manual-change notice for permission-only replies", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-permission-no-manual-change-notice-workspace-"); + const home = createTempDir("deepcode-permission-no-manual-change-notice-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "tracked.txt"); + const manager = createSessionManager(workspace, "machine-id-permission-no-manual-change-notice"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + fs.writeFileSync(filePath, "before\n", "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [filePath], "track file")); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need permission", + [ + { + id: "call-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: filePath }) }, + }, + ], + null + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, assistant); + + fs.writeFileSync(filePath, "manual change\n", "utf8"); + await manager.replySession(sessionId, { permissions: [{ toolCallId: "call-read", permission: "allow" }] }); + + const notices = manager + .listSessionMessages(sessionId) + .filter( + (message) => + message.role === "system" && + typeof message.content === "string" && + message.content.startsWith("Note that the user manually modified these files:") + ); + assert.equal(notices.length, 0); +}); + +test("Write tool advances file-history while preserving the user prompt checkpoint", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-write-checkpoint-workspace-"); + const home = createTempDir("deepcode-write-checkpoint-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "index.html"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-index", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: filePath, content: "

Hello

\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an index page" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + assert.ok(userMessage?.checkpointHash); + assert.equal(fs.existsSync(filePath), true); + + manager.restoreSessionCode(sessionId, userMessage.id); + + assert.equal(fs.existsSync(filePath), false); +}); + +test("Write checkpoints restore tool-touched files outside the workspace and leave unrelated files alone", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-write-outside-workspace-"); + const outsideDir = createTempDir("deepcode-write-outside-target-"); + const home = createTempDir("deepcode-write-outside-home-"); + setHomeDir(home); + + const outsideFilePath = path.join(outsideDir, "outside.txt"); + const unrelatedWorkspaceFilePath = path.join(workspace, "unrelated.txt"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-outside", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: outsideFilePath, content: "outside\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an outside file" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + assert.ok(userMessage?.checkpointHash); + assert.equal(fs.readFileSync(outsideFilePath, "utf8"), "outside\n"); + + fs.writeFileSync(unrelatedWorkspaceFilePath, "keep\n", "utf8"); + manager.restoreSessionCode(sessionId, userMessage.id); + + assert.equal(fs.existsSync(outsideFilePath), false); + assert.equal(fs.readFileSync(unrelatedWorkspaceFilePath, "utf8"), "keep\n"); +}); + +test("missing git executable does not block sessions or Write tool calls", async () => { + const workspace = createTempDir("deepcode-no-git-write-workspace-"); + const home = createTempDir("deepcode-no-git-write-home-"); + setHomeDir(home); + + const originalPath = process.env.PATH; + process.env.PATH = ""; + try { + const filePath = path.join(workspace, "index.html"); + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-write-no-git", + type: "function", + function: { + name: "write", + arguments: JSON.stringify({ file_path: filePath, content: "

No Git

\n" }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "create an index page" }); + const userMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "user"); + + assert.equal(fs.readFileSync(filePath, "utf8"), "

No Git

\n"); + assert.equal(userMessage?.checkpointHash, undefined); + assert.equal(manager.getSession(sessionId)?.status, "completed"); + } finally { + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + } +}); + +test("restoreSessionConversation truncates messages before the selected user prompt", async () => { + const workspace = createTempDir("deepcode-undo-conversation-workspace-"); + const home = createTempDir("deepcode-undo-conversation-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-undo-conversation"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const firstAssistant = (manager as any).buildAssistantMessage( + sessionId, + "first answer", + null, + null + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, firstAssistant); + await manager.replySession(sessionId, { text: "second prompt" }); + const secondUserMessage = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "user") + .at(-1); + assert.ok(secondUserMessage); + const secondAssistant = (manager as any).buildAssistantMessage( + sessionId, + "second answer", + null, + null + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, secondAssistant); + + manager.restoreSessionConversation(sessionId, secondUserMessage.id); + + const contents = manager.listSessionMessages(sessionId).map((message) => message.content); + assert.ok(contents.includes("first prompt")); + assert.ok(contents.includes("first answer")); + assert.ok(!contents.includes("second prompt")); + assert.ok(!contents.includes("second answer")); + assert.equal(manager.getSession(sessionId)?.assistantReply, "first answer"); +}); + +test("restoreSessionCode restores project files from the recorded Git checkpoint", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-undo-code-workspace-"); + const home = createTempDir("deepcode-undo-code-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-undo-code"); + const sessionId = "session-code-restore"; + const checkpointHash = createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "before\n" }); + const fileHistory = new GitFileHistory(workspace, getFileHistoryGitDir(home, workspace)); + assert.ok(fileHistory.recordCheckpoint(sessionId, [path.join(workspace, "new.txt")], "pre-create new.txt")); + createFileHistoryCommit(home, workspace, sessionId, { "tracked.txt": "after\n", "new.txt": "remove me\n" }); + fs.writeFileSync(path.join(workspace, "tracked.txt"), "after\n", "utf8"); + fs.writeFileSync(path.join(workspace, "new.txt"), "remove me\n", "utf8"); + + (manager as any).appendSessionMessage(sessionId, { + ...buildTestMessage("user-with-checkpoint", sessionId, "user", "restore here"), + checkpointHash, + }); + + manager.restoreSessionCode(sessionId, "user-with-checkpoint"); + + assert.equal(fs.readFileSync(path.join(workspace, "tracked.txt"), "utf8"), "before\n"); + assert.equal(fs.existsSync(path.join(workspace, "new.txt")), false); +}); + +test("restoreSessionCode preserves files that predate their first tracked mutation", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-undo-preexisting-files-workspace-"); + const home = createTempDir("deepcode-undo-preexisting-files-home-"); + setHomeDir(home); + + const readmePath = path.join(workspace, "README.md"); + const readmeEnPath = path.join(workspace, "README-en.md"); + const readmeZhPath = path.join(workspace, "README-zh_CN.md"); + fs.writeFileSync(readmePath, "这是一个hello world演示项目\n", "utf8"); + fs.writeFileSync(readmeEnPath, "This is a hello world demo project.\n", "utf8"); + fs.writeFileSync(readmeZhPath, "", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-undo-preexisting-files"); + const sessionId = "session-undo-preexisting-files"; + const gitDir = getFileHistoryGitDir(home, workspace); + const fileHistory = new GitFileHistory(workspace, gitDir); + fileHistory.ensureSession(sessionId); + + const targetCheckpoint = fileHistory.recordCheckpoint( + sessionId, + [readmePath, readmeEnPath], + "checkpoint before syncing all readmes" + ); + assert.ok(targetCheckpoint); + + assert.ok(fileHistory.recordCheckpoint(sessionId, [readmeZhPath], "pre-sync zh readme")); + fs.writeFileSync(readmePath, "Synced readme\n", "utf8"); + fs.writeFileSync(readmeEnPath, "Synced readme\n", "utf8"); + fs.writeFileSync(readmeZhPath, "Synced readme\n", "utf8"); + assert.ok(fileHistory.recordCheckpoint(sessionId, [readmePath, readmeEnPath, readmeZhPath], "synced readmes")); + + (manager as any).appendSessionMessage(sessionId, { + ...buildTestMessage("user-with-readme-checkpoint", sessionId, "user", "sync README*.md"), + checkpointHash: targetCheckpoint, + }); + + manager.restoreSessionCode(sessionId, "user-with-readme-checkpoint"); + + assert.equal(fs.readFileSync(readmePath, "utf8"), "这是一个hello world演示项目\n"); + assert.equal(fs.readFileSync(readmeEnPath, "utf8"), "This is a hello world demo project.\n"); + assert.equal(fs.readFileSync(readmeZhPath, "utf8"), ""); +}); + +test("restoreSessionCode restores deleted tracked files and leaves unrelated files alone", async (t) => { + if (!hasGit()) { + t.skip("git is not available"); + return; + } + + const workspace = createTempDir("deepcode-undo-deleted-files-workspace-"); + const home = createTempDir("deepcode-undo-deleted-files-home-"); + setHomeDir(home); + + const trackedPath = path.join(workspace, "tracked.txt"); + const unrelatedPath = path.join(workspace, "unrelated.txt"); + fs.writeFileSync(trackedPath, "before delete\n", "utf8"); + fs.writeFileSync(unrelatedPath, "do not touch\n", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-undo-deleted-files"); + const sessionId = "session-undo-deleted-files"; + const gitDir = getFileHistoryGitDir(home, workspace); + const fileHistory = new GitFileHistory(workspace, gitDir); + fileHistory.ensureSession(sessionId); + const targetCheckpoint = fileHistory.recordCheckpoint(sessionId, [trackedPath], "before delete"); + assert.ok(targetCheckpoint); + + fs.unlinkSync(trackedPath); + assert.ok(fileHistory.recordCheckpoint(sessionId, [trackedPath], "after delete")); + + (manager as any).appendSessionMessage(sessionId, { + ...buildTestMessage("user-before-delete", sessionId, "user", "restore deleted file"), + checkpointHash: targetCheckpoint, + }); + + manager.restoreSessionCode(sessionId, "user-before-delete"); + + assert.equal(fs.readFileSync(trackedPath, "utf8"), "before delete\n"); + assert.equal(fs.readFileSync(unrelatedPath, "utf8"), "do not touch\n"); +}); + +test("replySession /continue runs trailing pending tool calls before requesting another response", async () => { + const workspace = createTempDir("deepcode-continue-tool-workspace-"); + const home = createTempDir("deepcode-continue-tool-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("continued after tool", { + prompt_tokens: 9, + completion_tokens: 2, + total_tokens: 11, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const pendingAssistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to read a file", + [ + { + id: "call-pending-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) }, + }, + ], + null + ) as SessionMessage; + fs.writeFileSync(path.join(workspace, "note.txt"), "hello from pending tool\n", "utf8"); + (manager as any).appendSessionMessage(sessionId, pendingAssistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { text: "/continue" }); + + const messages = manager.listSessionMessages(sessionId); + const toolMessage = messages.find((message) => { + const params = message.messageParams as { tool_call_id?: string } | null; + return message.role === "tool" && params?.tool_call_id === "call-pending-read"; + }); + const assistantMessages = messages.filter((message) => message.role === "assistant"); + const userMessages = messages.filter((message) => message.role === "user"); + + assert.ok(toolMessage); + assert.match(toolMessage.content ?? "", /hello from pending tool/); + assert.equal(assistantMessages[assistantMessages.length - 1]?.content, "continued after tool"); + assert.equal( + userMessages.some((message) => message.content === "/continue"), + false + ); +}); + +test("replySession rebuilds snippet state from persisted read history before editing", async () => { + const workspace = createTempDir("deepcode-rebuild-snippet-workspace-"); + const home = createTempDir("deepcode-rebuild-snippet-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "note.txt"); + fs.writeFileSync(filePath, "alpha\nbeta\n", "utf8"); + + const responses = [ + createToolCallResponse( + [ + { + id: "call-edit", + type: "function", + function: { + name: "edit", + arguments: JSON.stringify({ + snippet_id: "full_file_5", + file_path: filePath, + old_string: "beta", + new_string: "gamma", + }), + }, + }, + ], + { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } + ), + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const readToolMessage = (manager as any).buildToolMessage( + sessionId, + "call-read", + JSON.stringify({ + ok: true, + name: "read", + output: " 1\talpha\n 2\tbeta\n", + metadata: { + snippet: { + id: "full_file_5", + filePath, + startLine: 1, + endLine: 3, + }, + }, + }), + { name: "read", arguments: JSON.stringify({ file_path: filePath }) } + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, readToolMessage); + + clearSessionState(sessionId); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { text: "change beta" }); + + assert.equal(fs.readFileSync(filePath, "utf8"), "alpha\ngamma\n"); + const editToolMessage = manager.listSessionMessages(sessionId).find((message) => { + const params = message.messageParams as { tool_call_id?: string } | null; + return message.role === "tool" && params?.tool_call_id === "call-edit"; + }); + assert.ok(editToolMessage); + assert.match(editToolMessage.content ?? "", /"ok":true|"ok": true/); + assert.doesNotMatch(editToolMessage.content ?? "", /Unknown snippet_id/); +}); + +test("activateSession pauses for permission when a tool call requires ask", async () => { + const workspace = createTempDir("deepcode-permission-ask-workspace-"); + const home = createTempDir("deepcode-permission-ask-home-"); + setHomeDir(home); + + const manager = createPermissionSessionManager( + workspace, + [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "rg TODO src", + description: "Search TODO markers", + sideEffects: ["read-in-cwd"], + }), + }, + }, + ], + }, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ], + { + allow: [], + deny: [], + ask: [], + defaultMode: "askAll", + } + ); + + const sessionId = await manager.createSession({ text: "search todos" }); + const session = manager.getSession(sessionId); + const assistant = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls); + + assert.equal(session?.status, "ask_permission"); + assert.equal(session?.askPermissions?.[0]?.toolCallId, "call-bash"); + assert.deepEqual(session?.askPermissions?.[0]?.scopes, ["read-in-cwd"]); + assert.deepEqual(assistant?.meta?.permissions, [{ toolCallId: "call-bash", permission: "ask" }]); + assert.equal( + manager.listSessionMessages(sessionId).some((message) => message.role === "tool"), + false + ); +}); + +test("SessionManager preserves permission_denied status when sessions are reloaded", async () => { + const workspace = createTempDir("deepcode-permission-denied-workspace-"); + const home = createTempDir("deepcode-permission-denied-home-"); + setHomeDir(home); + + const permissions = { + allow: [], + deny: [], + ask: [], + defaultMode: "askAll" as const, + }; + const manager = createPermissionSessionManager( + workspace, + [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call-bash", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ + command: "rg TODO src", + description: "Search TODO markers", + sideEffects: ["read-in-cwd"], + }), + }, + }, + ], + }, + }, + ], + }, + ], + permissions + ); + + const sessionId = await manager.createSession({ text: "search todos" }); + manager.denySessionPermission(sessionId); + + const reloadedManager = createPermissionSessionManager(workspace, [], permissions); + const reloadedSession = reloadedManager.getSession(sessionId); + + assert.equal(reloadedSession?.status, "permission_denied"); + assert.equal(reloadedSession?.failReason, "Permission denied by user"); +}); + +test("replySession applies permission replies, runs pending tools, and stores always allow scopes", async () => { + const workspace = createTempDir("deepcode-permission-allow-workspace-"); + const home = createTempDir("deepcode-permission-allow-home-"); + setHomeDir(home); + fs.writeFileSync(path.join(workspace, "note.txt"), "allowed content\n", "utf8"); + + const manager = createPermissionSessionManager( + workspace, + [createChatResponse("continued", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + { + allow: [], + deny: [], + ask: ["read-in-cwd"], + defaultMode: "allowAll", + } + ); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to read", + [ + { + id: "call-read", + type: "function", + function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) }, + }, + ], + null + ) as SessionMessage; + assistant.meta = { ...(assistant.meta ?? {}), permissions: [{ toolCallId: "call-read", permission: "ask" }] }; + (manager as any).appendSessionMessage(sessionId, assistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { + text: "/continue", + permissions: [{ toolCallId: "call-read", permission: "allow" }], + alwaysAllows: ["read-in-cwd"], + }); + + const toolMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "tool"); + const settings = JSON.parse(fs.readFileSync(path.join(workspace, ".deepcode", "settings.json"), "utf8")); + + assert.match(toolMessage?.content ?? "", /allowed content/); + assert.deepEqual(settings.permissions.allow, ["read-in-cwd"]); + assert.equal(manager.getSession(sessionId)?.status, "completed"); +}); + +test("replySession turns denied permission replies into tool errors before appending user text", async () => { + const workspace = createTempDir("deepcode-permission-deny-workspace-"); + const home = createTempDir("deepcode-permission-deny-home-"); + setHomeDir(home); + + const manager = createPermissionSessionManager( + workspace, + [createChatResponse("handled denial", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })], + { + allow: [], + deny: [], + ask: ["write-out-cwd"], + defaultMode: "allowAll", + } + ); + const originalActivateSession = manager.activateSession.bind(manager); + (manager as any).activateSession = async () => {}; + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistant = (manager as any).buildAssistantMessage( + sessionId, + "Need to write", + [ + { + id: "call-write", + type: "function", + function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/outside.txt", content: "x" }) }, + }, + ], + null + ) as SessionMessage; + assistant.meta = { ...(assistant.meta ?? {}), permissions: [{ toolCallId: "call-write", permission: "ask" }] }; + (manager as any).appendSessionMessage(sessionId, assistant); + (manager as any).activateSession = originalActivateSession; + + await manager.replySession(sessionId, { + text: "Do not write outside the workspace.", + permissions: [{ toolCallId: "call-write", permission: "deny" }], + }); + + const messages = manager.listSessionMessages(sessionId); + const assistantIndex = messages.findIndex((message) => message.id === assistant.id); + const toolMessage = messages[assistantIndex + 1]; + const userMessage = messages[assistantIndex + 2]; + + assert.equal(toolMessage?.role, "tool"); + assert.match(toolMessage?.content ?? "", /User denied the required permission/); + assert.equal(userMessage?.role, "user"); + assert.equal(userMessage?.content, "Do not write outside the workspace."); +}); + +test("replySession preserves raw session messages when a previous tool call is pending", async () => { + const workspace = createTempDir("deepcode-pending-tool-workspace-"); + const home = createTempDir("deepcode-pending-tool-home-"); + setHomeDir(home); + + globalThis.fetch = (async () => + ({ + ok: true, + text: async () => "", + }) as Response) as typeof fetch; + + const manager = createSessionManager(workspace, "machine-id-pending-tool"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const assistantMessage = (manager as any).buildAssistantMessage( + sessionId, + "I will run a tool.", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"sleep 100"}' }, + }, + ], + "" + ) as SessionMessage; + (manager as any).appendSessionMessage(sessionId, assistantMessage); + + await manager.replySession(sessionId, { text: "second prompt" }); + + const messages = manager.listSessionMessages(sessionId); + const assistantIndex = messages.findIndex((message) => message.id === assistantMessage.id); + assert.notEqual(assistantIndex, -1); + assert.equal(messages[assistantIndex + 1]?.role, "user"); + assert.equal(messages[assistantIndex + 1]?.content, "second prompt"); + assert.equal( + messages.some((message) => String(message.content).includes("Previous tool call did not complete.")), + false + ); +}); + +test("buildOpenAIMessages inserts interrupted results for missing tool messages", () => { + const manager = createSessionManager(process.cwd(), "machine-id-missing-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "I will run a tool.", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"sleep 100"}' }, + }, + ], + "" + ) as SessionMessage; + const userMessage = buildTestMessage("user-after-tool-call", "session-1", "user", "continue"); + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, userMessage], + false, + "test-model" + ) as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.equal(openAIMessages.length, 3); + assert.equal(openAIMessages[0]?.role, "assistant"); + assert.equal(openAIMessages[1]?.role, "tool"); + assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); + assert.match(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); + assert.equal(openAIMessages[2]?.role, "user"); +}); + +test("buildOpenAIMessages keeps only the first non-interrupted tool result for a tool call", () => { + const manager = createSessionManager(process.cwd(), "machine-id-duplicate-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"date"}' }, + }, + ], + "" + ) as SessionMessage; + const successToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "bash", output: "2026-05-07 星期四\n" }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + const interruptedToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ + ok: false, + name: "bash", + error: "Previous tool call did not complete.", + metadata: { interrupted: true }, + }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, successToolMessage, interruptedToolMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string }>; + const toolMessages = openAIMessages.filter((message) => message.role === "tool"); + + assert.equal(toolMessages.length, 1); + assert.equal(toolMessages[0]?.tool_call_id, "call-1"); + assert.match(toolMessages[0]?.content ?? "", /2026-05-07/); + assert.doesNotMatch(toolMessages[0]?.content ?? "", /Previous tool call did not complete/); +}); + +test("buildOpenAIMessages prefers a later real tool result over an earlier interrupted placeholder", () => { + const manager = createSessionManager(process.cwd(), "machine-id-prefer-real-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"date"}' }, + }, + ], + "" + ) as SessionMessage; + const interruptedToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ + ok: false, + name: "bash", + error: "Previous tool call did not complete.", + metadata: { interrupted: true }, + }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + const successToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "bash", output: "real result" }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, interruptedToolMessage, successToolMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string }>; + const toolMessages = openAIMessages.filter((message) => message.role === "tool"); + + assert.equal(toolMessages.length, 1); + assert.equal(toolMessages[0]?.tool_call_id, "call-1"); + assert.match(toolMessages[0]?.content ?? "", /real result/); +}); + +test("buildOpenAIMessages ignores orphan tool messages", () => { + const manager = createSessionManager(process.cwd(), "machine-id-orphan-tool"); + const userMessage = buildTestMessage("user-1", "session-1", "user", "hello"); + const orphanToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-orphan", + JSON.stringify({ ok: true, name: "bash", output: "orphan" }), + { name: "bash", arguments: '{"command":"echo orphan"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [userMessage, orphanToolMessage], + false, + "test-model" + ) as Array<{ + role: string; + }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["user"] + ); +}); + +test("buildOpenAIMessages moves a later paired tool message behind its assistant", () => { + const manager = createSessionManager(process.cwd(), "machine-id-later-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"date"}' }, + }, + ], + "" + ) as SessionMessage; + const userMessage = buildTestMessage("user-between", "session-1", "user", "continue"); + const toolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "bash", output: "paired later" }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, userMessage, toolMessage], + false, + "test-model" + ) as Array<{ role: string; content: string }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "user"] + ); + assert.match(openAIMessages[1]?.content ?? "", /paired later/); +}); + +test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { + const manager = createSessionManager(process.cwd(), "machine-id-multi-tool-happy"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' }, + }, + { + id: "call-2", + type: "function", + function: { name: "bash", arguments: '{"command":"pwd"}' }, + }, + ], + "" + ) as SessionMessage; + const firstToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "read", content: "file content" }), + { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } + ) as SessionMessage; + const secondToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-2", + JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), + { name: "bash", arguments: '{"command":"pwd"}' } + ) as SessionMessage; + const userMessage = buildTestMessage("user-after-complete-tools", "session-1", "user", "thanks"); + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, firstToolMessage, secondToolMessage, userMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + openAIMessages.filter((message) => message.role === "tool").map((message) => message.tool_call_id), + ["call-1", "call-2"] + ); + assert.equal( + openAIMessages.some((message) => message.content.includes("Previous tool call did not complete.")), + false + ); +}); + +test("buildOpenAIMessages preserves a real failed tool result", () => { + const manager = createSessionManager(process.cwd(), "machine-id-real-failed-tool"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"false"}' }, + }, + ], + "" + ) as SessionMessage; + const failedToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: false, name: "bash", error: "Command failed", metadata: { exitCode: 1 } }), + { name: "bash", arguments: '{"command":"false"}' } + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, failedToolMessage], + false, + "test-model" + ) as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool"] + ); + assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); + assert.match(openAIMessages[1]?.content ?? "", /Command failed/); + assert.doesNotMatch(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); +}); + +test("UpdatePlan tool params only show explanation when provided", () => { + const manager = createSessionManager(process.cwd(), "machine-id-update-plan-params"); + const plan = "## Task List\n\n- [ ] Inspect project"; + + const withExplanation = (manager as any).buildToolMessage( + "session-1", + "call-plan-1", + JSON.stringify({ ok: true, name: "UpdatePlan", output: "Plan updated." }), + { name: "UpdatePlan", arguments: JSON.stringify({ plan, explanation: "Start planning" }) } + ) as SessionMessage; + const withoutExplanation = (manager as any).buildToolMessage( + "session-1", + "call-plan-2", + JSON.stringify({ ok: true, name: "UpdatePlan", output: "Plan updated." }), + { name: "UpdatePlan", arguments: JSON.stringify({ plan }) } + ) as SessionMessage; + + assert.equal(withExplanation.meta?.paramsMd, "Start planning"); + assert.equal(withoutExplanation.meta?.paramsMd, ""); +}); + +test("Write tool params prefer file_path even when content appears first", () => { + const manager = createSessionManager(process.cwd(), "machine-id-write-params"); + const filePath = path.join(process.cwd(), "index.html"); + + const toolMessage = (manager as any).buildToolMessage( + "session-1", + "call-write-1", + JSON.stringify({ ok: true, name: "write", output: "Created file." }), + { + name: "write", + arguments: JSON.stringify({ + content: "// === entry ===\nconsole.log('demo');\n", + file_path: filePath, + }), + } + ) as SessionMessage; + + assert.equal(toolMessage.meta?.paramsMd, filePath); +}); + +test("LLM tool calls without ids receive generated 32 character ids", async () => { + const workspace = createTempDir("deepcode-tool-call-id-workspace-"); + const home = createTempDir("deepcode-tool-call-id-home-"); + setHomeDir(home); + + const filePath = path.join(workspace, "note.txt"); + fs.writeFileSync(filePath, "hello\n", "utf8"); + const plan = "## Task List\n\n- [ ] Inspect current behavior"; + const manager = createMockedClientSessionManager(workspace, [ + { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "", + type: "function", + function: { + name: "UpdatePlan", + arguments: JSON.stringify({ plan, explanation: "Initial plan" }), + }, + }, + { + type: "function", + function: { + name: "read", + arguments: JSON.stringify({ file_path: filePath }), + }, + }, + ], + }, + }, + ], + }, + createChatResponse("done", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }), + ]); + + const sessionId = await manager.createSession({ text: "inspect note" }); + const assistantMessage = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls); + const toolCalls = (assistantMessage?.messageParams as { tool_calls?: Array<{ id?: unknown }> } | null)?.tool_calls; + + assert.equal(toolCalls?.length, 2); + assert.match(String(toolCalls?.[0]?.id), /^[0-9a-f]{32}$/); + assert.match(String(toolCalls?.[1]?.id), /^[0-9a-f]{32}$/); + assert.notEqual(toolCalls?.[0]?.id, toolCalls?.[1]?.id); + + const toolMessages = manager.listSessionMessages(sessionId).filter((message) => message.role === "tool"); + assert.deepEqual( + toolMessages.map((message) => (message.messageParams as { tool_call_id?: unknown } | null)?.tool_call_id), + toolCalls?.map((toolCall) => toolCall.id) + ); + + const readToolMessage = toolMessages.find((message) => JSON.parse(message.content ?? "{}").name === "read"); + assert.equal((readToolMessage?.meta?.function as { name?: string } | undefined)?.name, "read"); + assert.equal(readToolMessage?.meta?.paramsMd, "note.txt"); +}); + +test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messages", () => { + const manager = createSessionManager(process.cwd(), "machine-id-mixed-tool-badcase"); + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "read", arguments: '{"file_path":"/tmp/missing.txt"}' }, + }, + { + id: "call-2", + type: "function", + function: { name: "bash", arguments: '{"command":"pwd"}' }, + }, + ], + "" + ) as SessionMessage; + const orphanToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-orphan", + JSON.stringify({ ok: true, name: "bash", output: "orphan" }), + { name: "bash", arguments: '{"command":"echo orphan"}' } + ) as SessionMessage; + const pairedToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-2", + JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), + { name: "bash", arguments: '{"command":"pwd"}' } + ) as SessionMessage; + const duplicateToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-2", + JSON.stringify({ ok: true, name: "bash", output: "duplicate" }), + { name: "bash", arguments: '{"command":"pwd"}' } + ) as SessionMessage; + const userMessage = buildTestMessage("user-after-mixed-tools", "session-1", "user", "continue"); + + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, orphanToolMessage, pairedToolMessage, duplicateToolMessage, userMessage], + false, + "test-model" + ) as Array<{ role: string; content: string; tool_call_id?: string }>; + const toolMessages = openAIMessages.filter((message) => message.role === "tool"); + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool", "tool", "user"] + ); + assert.deepEqual( + toolMessages.map((message) => message.tool_call_id), + ["call-1", "call-2"] + ); + assert.match(toolMessages[0]?.content ?? "", /Previous tool call did not complete/); + assert.match(toolMessages[1]?.content ?? "", /\/tmp/); + assert.equal( + openAIMessages.some((message) => message.content.includes("orphan")), + false + ); + assert.equal( + openAIMessages.some((message) => message.content.includes("duplicate")), + false + ); +}); + +test("buildOpenAIMessages ignores tool messages that appear before their assistant", () => { + const manager = createSessionManager(process.cwd(), "machine-id-tool-before-assistant"); + const earlyToolMessage = (manager as any).buildToolMessage( + "session-1", + "call-1", + JSON.stringify({ ok: true, name: "bash", output: "too early" }), + { name: "bash", arguments: '{"command":"date"}' } + ) as SessionMessage; + const assistantMessage = (manager as any).buildAssistantMessage( + "session-1", + "", + [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: '{"command":"date"}' }, + }, + ], + "" + ) as SessionMessage; + + const openAIMessages = (manager as any).buildOpenAIMessages( + [earlyToolMessage, assistantMessage], + false, + "test-model" + ) as Array<{ + role: string; + content: string; + tool_call_id?: string; + }>; + + assert.deepEqual( + openAIMessages.map((message) => message.role), + ["assistant", "tool"] + ); + assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); + assert.match(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); + assert.doesNotMatch(openAIMessages[1]?.content ?? "", /too early/); +}); + +test("SessionManager accumulates response usage while active tokens track the latest response", async () => { + const workspace = createTempDir("deepcode-usage-workspace-"); + const home = createTempDir("deepcode-usage-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("first", { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + prompt_tokens_details: { cached_tokens: 7 }, + completion_tokens_details: { reasoning_tokens: 3 }, + prompt_cache_hit_tokens: 7, + prompt_cache_miss_tokens: 3, + }), + createChatResponse("second", { + prompt_tokens: 20, + completion_tokens: 7, + total_tokens: 27, + prompt_tokens_details: { cached_tokens: 11 }, + completion_tokens_details: { reasoning_tokens: 4 }, + prompt_cache_hit_tokens: 11, + prompt_cache_miss_tokens: 9, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + + const sessionId = await manager.createSession({ text: "" }); + await manager.replySession(sessionId, { text: "" }); + + const session = manager.getSession(sessionId); + const usage = session?.usage as Record; + const usagePerModel = session?.usagePerModel?.["test-model"] as Record; + assert.equal(session?.activeTokens, 27); + assert.equal(usage.prompt_tokens, 30); + assert.equal(usage.completion_tokens, 12); + assert.equal(usage.total_tokens, 42); + assert.equal(usage.prompt_tokens_details.cached_tokens, 18); + assert.equal(usage.completion_tokens_details.reasoning_tokens, 7); + assert.equal(usage.prompt_cache_hit_tokens, 18); + assert.equal(usage.prompt_cache_miss_tokens, 12); + assert.equal(usagePerModel.prompt_tokens, 30); + assert.equal(usagePerModel.completion_tokens, 12); + assert.equal(usagePerModel.total_tokens, 42); + assert.equal(usagePerModel.prompt_tokens_details.cached_tokens, 18); + assert.equal(usagePerModel.completion_tokens_details.reasoning_tokens, 7); + assert.equal(usagePerModel.prompt_cache_hit_tokens, 18); + assert.equal(usagePerModel.prompt_cache_miss_tokens, 12); + assert.equal(usagePerModel.total_reqs, 2); +}); + +test("SessionManager stores usage per model across model changes", async () => { + const workspace = createTempDir("deepcode-usage-per-model-workspace-"); + const home = createTempDir("deepcode-usage-per-model-home-"); + setHomeDir(home); + + let currentModel = "deepseek-v4-pro"; + const responses = [ + createChatResponse("pro response", { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }), + createChatResponse("flash response", { + prompt_tokens: 20, + completion_tokens: 7, + total_tokens: 27, + prompt_cache_hit_tokens: 6, + }), + ]; + const client = { + chat: { + completions: { + create: async (request: any) => { + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(); + } + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + return response; + }, + }, + }, + }; + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: client as any, + model: currentModel, + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: currentModel }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); + + const sessionId = await manager.createSession({ text: "" }); + currentModel = "deepseek-v4-flash"; + await manager.replySession(sessionId, { text: "" }); + + const session = manager.getSession(sessionId); + assert.deepEqual(Object.keys(session?.usagePerModel ?? {}).sort(), ["deepseek-v4-flash", "deepseek-v4-pro"]); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.prompt_tokens, 10); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.completion_tokens, 5); + assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.total_reqs, 1); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_tokens, 20); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.completion_tokens, 7); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_cache_hit_tokens, 6); + assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.total_reqs, 1); + assert.equal(session?.usage?.prompt_tokens, 30); + assert.equal(session?.usage?.completion_tokens, 12); + assert.equal(session?.usage?.total_tokens, 42); +}); + +test("SessionManager resets active tokens to latest post-compaction response usage", async () => { + const workspace = createTempDir("deepcode-compact-usage-workspace-"); + const home = createTempDir("deepcode-compact-usage-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("large", { + prompt_tokens: 139_990, + completion_tokens: 10, + total_tokens: 140_000, + }), + createChatResponse("summary", { + prompt_tokens: 100, + completion_tokens: 23, + total_tokens: 123, + }), + createChatResponse("after compact", { + prompt_tokens: 5, + completion_tokens: 2, + total_tokens: 7, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + + const sessionId = await manager.createSession({ text: "" }); + assert.equal(manager.getSession(sessionId)?.activeTokens, 140_000); + + await manager.replySession(sessionId, { text: "" }); + + const session = manager.getSession(sessionId); + const usage = session?.usage as Record; + const usagePerModel = session?.usagePerModel?.["test-model"] as Record; + assert.equal(session?.activeTokens, 7); + assert.equal(usage.prompt_tokens, 140_095); + assert.equal(usage.completion_tokens, 35); + assert.equal(usage.total_tokens, 140_130); + assert.equal(usagePerModel.prompt_tokens, 140_095); + assert.equal(usagePerModel.completion_tokens, 35); + assert.equal(usagePerModel.total_tokens, 140_130); + assert.equal(usagePerModel.total_reqs, 3); +}); + +test("SessionManager streams chat completions and counts reasoning progress", async () => { + const workspace = createTempDir("deepcode-stream-workspace-"); + const home = createTempDir("deepcode-stream-home-"); + setHomeDir(home); + + const progressEvents: Array<{ + phase: string; + estimatedTokens: number; + formattedTokens: string; + }> = []; + const client = { + chat: { + completions: { + create: async (request: Record) => { + assert.equal(request.stream, true); + assert.deepEqual(request.stream_options, { include_usage: true }); + assert.equal(request.temperature, 0.25); + return createChatStreamResponse([ + { choices: [{ delta: { reasoning_content: "思考" } }] }, + { choices: [{ delta: { content: "hello" } }] }, + { + choices: [], + usage: { + prompt_tokens: 2, + completion_tokens: 3, + total_tokens: 5, + }, + }, + ]); + }, + }, + }, + }; + + const manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + temperature: 0.25, + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + onLlmStreamProgress: (progress) => { + progressEvents.push({ + phase: progress.phase, + estimatedTokens: progress.estimatedTokens, + formattedTokens: progress.formattedTokens, + }); + }, + }); + + const sessionId = await manager.createSession({ text: "" }); + const assistantMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "assistant"); + + assert.equal(assistantMessage?.content, "hello"); + assert.equal((assistantMessage?.messageParams as any)?.reasoning_content, "思考"); + assert.equal(manager.getSession(sessionId)?.activeTokens, 5); + assert.deepEqual( + progressEvents.map((event) => event.phase), + ["start", "update", "update", "end"] + ); + assert.equal(progressEvents[1]?.estimatedTokens, 1); + assert.equal(progressEvents[2]?.formattedTokens, "3"); +}); + +test("SessionManager persists session and user message before skill matching is cancelled", async () => { + const workspace = createTempDir("deepcode-skill-abort-workspace-"); + const home = createTempDir("deepcode-skill-abort-home-"); + setHomeDir(home); + + const skillDir = path.join(home, ".agents", "skills", "demo"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: demo\ndescription: Demo skill\n---\n# Demo\n", "utf8"); + + // eslint-disable-next-line prefer-const -- must be declared before client which references it + let manager: SessionManager; + const client = { + chat: { + completions: { + create: async (request: Record, options?: { signal?: AbortSignal }) => { + assert.equal(request.temperature, 0.1); + return new Promise((_resolve, reject) => { + const signal = options?.signal; + signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); + queueMicrotask(() => manager.interruptActiveSession()); + }); + }, + }, + }, + }; + + manager = createMockedClientSessionManagerWithClient(workspace, client); + + await manager.handleUserPrompt({ text: "please use demo" }); + + // Session and user message are persisted before skill matching triggers an abort. + assert.equal(manager.listSessions().length, 1); + const [session] = manager.listSessions(); + assert.equal(session?.status, "pending"); + const messages = manager.listSessionMessages(session!.id); + const userMessage = messages.find((m) => m.role === "user"); + assert.equal(userMessage?.content, "please use demo"); +}); + +test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () => { + const workspace = createTempDir("deepcode-api-abort-workspace-"); + const home = createTempDir("deepcode-api-abort-home-"); + setHomeDir(home); + + let manager: SessionManager; + const client = { + chat: { + completions: { + create: async (_request: Record, options?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + const signal = options?.signal; + signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); + }); + }, + }, + }, + }; + + // eslint-disable-next-line prefer-const -- declared before client, assigned after + manager = new SessionManager({ + projectRoot: workspace, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + onSessionEntryUpdated: (entry) => { + if (entry.status === "processing") { + queueMicrotask(() => manager.interruptActiveSession()); + } + }, + }); + + await manager.handleUserPrompt({ text: "" }); + + const activeSessionId = manager.getActiveSessionId(); + assert.ok(activeSessionId); + const session = manager.getSession(activeSessionId); + assert.equal(session?.status, "interrupted"); + assert.equal(session?.failReason, "interrupted"); +}); + +test("SessionManager marks MCP server as failed on single failed attempt (no auto-retry)", async () => { + const workspace = createTempDir("deepcode-mcp-fail-noworkspace-"); + const serverPath = path.join(workspace, "mcp-server-fail.cjs"); + fs.writeFileSync(serverPath, "process.exit(7);", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-mcp-fail-no"); + await manager.initMcpServers({ broken: { command: process.execPath, args: [serverPath] } }); + + const status = manager.getMcpStatus(); + assert.equal(status.length, 1); + assert.equal(status[0]?.status, "failed"); + assert.match(status[0]?.error ?? "", /exited with code 7/); + + manager.dispose(); +}); + +test("SessionManager reconnect succeeds on previously failed server", async () => { + const workspace = createTempDir("deepcode-mcp-reconn-ok-workspace-"); + const serverPath = path.join(workspace, "mcp-server-ok.cjs"); + fs.writeFileSync( + serverPath, + ` +const readline = require("readline"); +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +function send(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} +rl.on("line", (line) => { + const request = JSON.parse(line); + if (!("id" in request)) return; + if (request.method === "initialize") { + send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: {} } }); + return; + } + if (request.method === "tools/list") { + send({ jsonrpc: "2.0", id: request.id, result: { tools: [{ name: "ping", inputSchema: { type: "object", properties: {} } }] } }); + return; + } + send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); +}); +`, + "utf8" + ); + + const manager = createSessionManager(workspace, "machine-id-mcp-reconn-ok"); + await manager.initMcpServers({ fixable: { command: process.execPath, args: [serverPath] } }); + + const status = manager.getMcpStatus(); + assert.equal(status.length, 1); + assert.equal(status[0]?.status, "ready"); + assert.equal(status[0]?.toolCount, 1); + + manager.dispose(); +}); + +test("SessionManager adjusts the active Bash timeout control and session metadata", async () => { + const workspace = createTempDir("deepcode-bash-timeout-session-"); + const home = createTempDir("deepcode-bash-timeout-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, ""); + const sessionId = await manager.createSession({ text: "hello" }); + + (manager as any).addSessionProcess(sessionId, 123, "sleep 10"); + + let timeoutInfo = { + timeoutMs: 10 * 60 * 1000, + startedAtMs: 1000, + deadlineAtMs: 1000 + 10 * 60 * 1000, + timedOut: false, + }; + (manager as any).setSessionProcessTimeoutControl(sessionId, 123, { + getInfo: () => timeoutInfo, + setTimeoutMs: (timeoutMs: number) => { + timeoutInfo = { + ...timeoutInfo, + timeoutMs, + deadlineAtMs: timeoutInfo.startedAtMs + timeoutMs, + }; + return timeoutInfo; + }, + }); + + const adjustment = manager.adjustActiveBashTimeout(5 * 60 * 1000); + const processInfo = manager.getSession(sessionId)?.processes?.get("123"); + + assert.equal(adjustment?.processId, "123"); + assert.equal(adjustment?.timeoutMs, 15 * 60 * 1000); + assert.equal(processInfo?.timeoutMs, 15 * 60 * 1000); + assert.equal(processInfo?.deadlineAt, new Date(timeoutInfo.deadlineAtMs).toISOString()); +}); + +test("SessionManager.deleteSession removes session entry from the index", () => { + const workspace = createTempDir("deepcode-delete-workspace-"); + const home = createTempDir("deepcode-delete-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete"); + (manager as any).activateSession = async () => {}; + + // Create two sessions + const session1 = createSessionAndMessages(manager, "session-delete-1", "First session"); + const session2 = createSessionAndMessages(manager, "session-delete-2", "Second session"); + + assert.equal(manager.listSessions().length, 2); + + // Delete the first session + const result = manager.deleteSession(session1); + assert.equal(result, true); + + const remaining = manager.listSessions(); + assert.equal(remaining.length, 1); + assert.equal(remaining[0]?.id, session2); +}); + +test("SessionManager.deleteSession removes the messages file", () => { + const workspace = createTempDir("deepcode-delete-msg-workspace-"); + const home = createTempDir("deepcode-delete-msg-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-msg"); + (manager as any).activateSession = async () => {}; + + const sessionId = createSessionAndMessages(manager, "session-delete-msg", "Test session"); + const messagePath = path.join(home, ".deepcode", "projects", getProjectCode(workspace), `${sessionId}.jsonl`); + + // Verify messages file exists + assert.ok(fs.existsSync(messagePath)); + + manager.deleteSession(sessionId); + + // Verify messages file is removed + assert.equal(fs.existsSync(messagePath), false); +}); + +test("SessionManager.deleteSession returns false when session does not exist", () => { + const workspace = createTempDir("deepcode-delete-nonexist-workspace-"); + const home = createTempDir("deepcode-delete-nonexist-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-nonexist"); + + const result = manager.deleteSession("nonexistent-session-id"); + assert.equal(result, false); + assert.equal(manager.listSessions().length, 0); +}); + +test("SessionManager.deleteSession does not affect other sessions", () => { + const workspace = createTempDir("deepcode-delete-others-workspace-"); + const home = createTempDir("deepcode-delete-others-home-"); + setHomeDir(home); + + const manager = createSessionManager(workspace, "machine-id-delete-others"); + (manager as any).activateSession = async () => {}; + + const session1 = createSessionAndMessages(manager, "session-keep-1", "Keep session 1"); + const session2 = createSessionAndMessages(manager, "session-keep-2", "Keep session 2"); + + // Delete non-existent session + const result = manager.deleteSession("non-existent"); + assert.equal(result, false); + assert.equal(manager.listSessions().length, 2); + + // Delete one session + assert.equal(manager.deleteSession(session1), true); + assert.equal(manager.listSessions().length, 1); + assert.equal(manager.listSessions()[0]?.id, session2); + + // The remaining session should still have its messages accessible + const messages = manager.listSessionMessages(session2); + assert.ok(messages.length > 0); +}); + +/** + * Helper: creates a session and writes a few messages to it so we can test + * that deleteSession removes both the index entry and the messages file. + */ +function createSessionAndMessages(manager: SessionManager, sessionId: string, summary: string): string { + const now = new Date().toISOString(); + const index = (manager as any).loadSessionsIndex(); + index.entries.push({ + id: sessionId, + summary, + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage: null, + usagePerModel: null, + activeTokens: 0, + createTime: now, + updateTime: now, + processes: null, + }); + (manager as any).saveSessionsIndex(index); + + // Write a couple of message lines to the messages file + const projectDir = (manager as any).getProjectStorage().projectDir; + const messagePath = path.join(projectDir, `${sessionId}.jsonl`); + const msg = JSON.stringify({ + id: "msg-1", + sessionId, + role: "user", + content: summary, + visible: true, + createTime: now, + updateTime: now, + }); + fs.writeFileSync(messagePath, `${msg}\n`, "utf8"); + + return sessionId; +} + +function hasGit(): boolean { + try { + execFileSync("git", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function createFileHistoryCommit( + home: string, + workspace: string, + sessionId: string, + files: Record +): string { + const projectCode = getProjectCode(workspace); + const gitDir = path.join(home, ".deepcode", "projects", projectCode, "file-history", ".git"); + const fileHistory = new GitFileHistory(workspace, gitDir); + fileHistory.ensureSession(sessionId); + + const filePaths: string[] = []; + for (const [relativePath, content] of Object.entries(files)) { + const filePath = path.join(workspace, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, "utf8"); + filePaths.push(filePath); + } + const commitHash = fileHistory.recordCheckpoint(sessionId, filePaths, "checkpoint"); + assert.ok(commitHash); + return commitHash; +} + +function getFileHistoryGitDir(home: string, workspace: string): string { + const projectCode = getProjectCode(workspace); + return path.join(home, ".deepcode", "projects", projectCode, "file-history", ".git"); +} + +function readFileHistoryManifest(home: string, workspace: string, checkpointHash: string): any { + const gitDir = getFileHistoryGitDir(home, workspace); + return JSON.parse( + runFileHistoryGit(gitDir, workspace, ["cat-file", "blob", `${checkpointHash}:.deepcode-file-history.json`]) + ); +} + +function runFileHistoryGit( + gitDir: string, + workspace: string, + args: string[], + input = "", + env: NodeJS.ProcessEnv = process.env +): string { + return execFileSync( + "git", + ["-c", "core.autocrlf=false", "-c", "core.eol=lf", `--git-dir=${gitDir}`, `--work-tree=${workspace}`, ...args], + { + encoding: "utf8", + input, + env, + stdio: ["pipe", "pipe", "pipe"], + } + ); +} + +function createSessionManager(projectRoot: string, machineId: string): SessionManager { + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: null, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + machineId, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + +async function getPlanSkill(manager: SessionManager): Promise { + const planSkill = (await manager.listSkills()).find((skill) => skill.name === "plan"); + assert.ok(planSkill); + return planSkill; +} + +function countPlanModeStatusMessages(messages: SessionMessage[]): number { + return messages.filter((message) => message.role === "system" && message.content === PLAN_MODE_STATUS_MESSAGE).length; +} + +function countLoadedSkillMessages(messages: SessionMessage[], skillName: string): number { + return messages.filter((message) => message.role === "system" && message.meta?.skill?.name === skillName).length; +} + +function createNotifyingSessionManager( + projectRoot: string, + responses: unknown[], + notifyPath: string, + notifyOutput: string +): SessionManager { + const client = { + chat: { + completions: { + create: async (request: any) => { + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(); + } + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + if (response instanceof Error) { + throw response; + } + return response; + }, + }, + }, + }; + + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + notify: notifyPath, + env: { + NOTIFY_OUTPUT: notifyOutput, + STATUS: "stale-status", + FAIL_REASON: "stale-failure", + BODY: "stale-body", + TITLE: "stale-title", + }, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + +function createMockedClientSessionManager(projectRoot: string, responses: unknown[]): SessionManager { + const client = { + chat: { + completions: { + create: async (request: any) => { + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(); + } + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + return response; + }, + }, + }, + }; + + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + +function createPermissionSessionManager( + projectRoot: string, + responses: unknown[], + permissions: { + allow: any[]; + deny: any[]; + ask: any[]; + defaultMode: "allowAll" | "askAll"; + } +): SessionManager { + const client = { + chat: { + completions: { + create: async (request: any) => { + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(); + } + const response = responses.shift(); + assert.ok(response, "expected a queued chat response"); + return response; + }, + }, + }, + }; + + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model", permissions }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + +function createMockedClientSessionManagerWithClient(projectRoot: string, client: unknown): SessionManager { + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.deepseek.com", + thinkingEnabled: false, + }), + getResolvedSettings: () => ({ model: "test-model" }), + renderMarkdown: (text) => text, + onAssistantMessage: () => {}, + }); +} + +class APIUserAbortError extends Error {} + +function isSkillMatchingRequest(request: any): boolean { + return request?.response_format?.type === "json_object"; +} + +function createSkillMatchingResponse(skillNames: string[] = []): unknown { + return { choices: [{ message: { content: JSON.stringify({ skillNames }) } }] }; +} + +function createChatResponse(content: string, usage: Record): unknown { + return { + choices: [{ message: { content } }], + usage, + }; +} + +function createToolCallResponse(toolCalls: unknown[], usage: Record): unknown { + return { + choices: [{ message: { content: "", tool_calls: toolCalls } }], + usage, + }; +} + +function buildTestMessage( + id: string, + sessionId: string, + role: SessionMessage["role"], + content: string +): SessionMessage { + return { + id, + sessionId, + role, + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z", + }; +} + +async function* createChatStreamResponse(chunks: Record[]): AsyncGenerator> { + for (const chunk of chunks) { + yield chunk; + } +} + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function createNotifyRecorderScript(dir: string): string { + const scriptPath = path.join(dir, "notify-recorder.cjs"); + fs.writeFileSync( + scriptPath, + `#!/usr/bin/env node +const fs = require("fs"); +const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"]; +const record = {}; +for (const key of keys) { + record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null; +} +fs.appendFileSync(process.env.NOTIFY_OUTPUT, JSON.stringify(record) + "\\n", "utf8"); +`, + "utf8" + ); + fs.chmodSync(scriptPath, 0o755); + return scriptPath; +} + +async function waitForNotifyRecords( + outputPath: string, + expectedCount: number +): Promise>> { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (fs.existsSync(outputPath)) { + const records = fs + .readFileSync(outputPath, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); + if (records.length >= expectedCount) { + return records; + } + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`expected ${expectedCount} notify records in ${outputPath}`); +} + +async function waitForMcpStatus(manager: SessionManager, expectedStatus: string): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (manager.getMcpStatus()[0]?.status === expectedStatus) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail(`expected MCP status ${expectedStatus}`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function flushPromises(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} diff --git a/src/tests/settings-and-notify.test.ts b/packages/core/src/tests/settings-and-notify.test.ts similarity index 62% rename from src/tests/settings-and-notify.test.ts rename to packages/core/src/tests/settings-and-notify.test.ts index 6990288e..ceddc43e 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/packages/core/src/tests/settings-and-notify.test.ts @@ -1,6 +1,12 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../common/notify"; +import { + buildNotifyEnv, + formatDurationSeconds, + launchNotifyScript, + type NotifyContext, + type NotifySpawn, +} from "../common/notify"; import { applyModelConfigSelection, resolveSettings, resolveSettingsSources } from "../settings"; const TEST_PROCESS_ENV = {}; @@ -13,6 +19,7 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool BASE_URL: "https://example.com/v1", API_KEY: "sk-test", }, + temperature: 0.3, thinkingEnabled: true, reasoningEffort: "high", debugLogEnabled: true, @@ -29,6 +36,7 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool assert.equal(resolved.model, "deepseek-v3.2"); assert.equal(resolved.baseURL, "https://example.com/v1"); assert.equal(resolved.apiKey, "sk-test"); + assert.equal(resolved.temperature, 0.3); assert.equal(resolved.thinkingEnabled, true); assert.equal(resolved.reasoningEffort, "high"); assert.equal(resolved.debugLogEnabled, true); @@ -54,10 +62,11 @@ test("resolveSettings gives top-level model priority over env MODEL", () => { assert.equal(resolved.model, "deepseek-v4-flash"); }); -test("resolveSettings reads THINKING_ENABLED, REASONING_EFFORT, and DEBUG_LOG_ENABLED from env", () => { +test("resolveSettings reads TEMPERATURE, THINKING_ENABLED, REASONING_EFFORT, and DEBUG_LOG_ENABLED from env", () => { const resolved = resolveSettings( { env: { + TEMPERATURE: "0.7", THINKING_ENABLED: "true", REASONING_EFFORT: "high", DEBUG_LOG_ENABLED: "true", @@ -71,12 +80,43 @@ test("resolveSettings reads THINKING_ENABLED, REASONING_EFFORT, and DEBUG_LOG_EN ); assert.equal(resolved.thinkingEnabled, true); + assert.equal(resolved.temperature, 0.7); assert.equal(resolved.reasoningEffort, "high"); assert.equal(resolved.debugLogEnabled, true); assert.equal(resolved.model, "default-model"); assert.equal(resolved.baseURL, "https://default.example.com"); }); +test("resolveSettings defaults telemetryEnabled to true", () => { + const resolved = resolveSettings( + {}, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.telemetryEnabled, true); +}); + +test("resolveSettings reads TELEMETRY_ENABLED from env", () => { + const resolved = resolveSettings( + { env: { TELEMETRY_ENABLED: "0" } }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.telemetryEnabled, false); +}); + +test("resolveSettings gives top-level telemetryEnabled priority over env TELEMETRY_ENABLED", () => { + const resolved = resolveSettings( + { + telemetryEnabled: false, + env: { TELEMETRY_ENABLED: "true" }, + }, + { model: "default-model", baseURL: "https://default.example.com" }, + TEST_PROCESS_ENV + ); + assert.equal(resolved.telemetryEnabled, false); +}); + test("resolveSettings ignores removed legacy env.THINKING", () => { const resolved = resolveSettings( { @@ -102,13 +142,16 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre MODEL: "user-env-model", THINKING_ENABLED: "false", REASONING_EFFORT: "high", + TEMPERATURE: "0.2", DEBUG_LOG_ENABLED: "false", WEBHOOK: "user-webhook", }, model: "user-top-model", thinkingEnabled: true, reasoningEffort: "max", + temperature: 0.4, debugLogEnabled: true, + telemetryEnabled: false, }, { env: { @@ -116,9 +159,12 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre MODEL: "project-env-model", THINKING_ENABLED: "false", DEBUG_LOG_ENABLED: "false", + TEMPERATURE: "0.6", }, model: "project-top-model", thinkingEnabled: true, + temperature: 0.8, + telemetryEnabled: true, }, { model: "default-model", @@ -128,7 +174,9 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre DEEPCODE_MODEL: "system-model", DEEPCODE_THINKING_ENABLED: "false", DEEPCODE_REASONING_EFFORT: "high", + DEEPCODE_TEMPERATURE: "1.2", DEEPCODE_DEBUG_LOG_ENABLED: "true", + DEEPCODE_TELEMETRY_ENABLED: "false", DEEPCODE_WEBHOOK: "system-webhook", } ); @@ -137,10 +185,74 @@ test("resolveSettingsSources applies user, project, and DEEPCODE environment pre assert.equal(resolved.apiKey, "project-key"); assert.equal(resolved.thinkingEnabled, false); assert.equal(resolved.reasoningEffort, "high"); + assert.equal(resolved.temperature, 1.2); assert.equal(resolved.debugLogEnabled, true); + assert.equal(resolved.telemetryEnabled, false); assert.equal(resolved.env.WEBHOOK, "system-webhook"); }); +test("resolveSettingsSources merges permission settings", () => { + const resolved = resolveSettingsSources( + { + permissions: { + allow: ["read-in-cwd", "network"], + ask: ["write-out-cwd"], + defaultMode: "askAll", + }, + }, + { + permissions: { + allow: ["write-in-cwd", "read-in-cwd"], + deny: ["delete-out-cwd"], + defaultMode: "allowAll", + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV + ); + + assert.deepEqual(resolved.permissions.allow, ["read-in-cwd", "network", "write-in-cwd"]); + assert.deepEqual(resolved.permissions.ask, ["write-out-cwd"]); + assert.deepEqual(resolved.permissions.deny, ["delete-out-cwd"]); + assert.equal(resolved.permissions.defaultMode, "allowAll"); +}); + +test("resolveSettingsSources merges enabledSkills with project precedence", () => { + const resolved = resolveSettingsSources( + { + enabledSkills: { + inherited: false, + "project-enabled": false, + "project-disabled": true, + invalid: "false" as never, + }, + }, + { + enabledSkills: { + "project-enabled": true, + "project-disabled": false, + projectOnly: true, + ignored: null as never, + }, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV + ); + + assert.deepEqual(resolved.enabledSkills, { + inherited: false, + "project-enabled": true, + "project-disabled": false, + projectOnly: true, + }); +}); + test("resolveSettingsSources merges MCP env with documented priority", () => { const resolved = resolveSettingsSources( { @@ -272,6 +384,24 @@ test("resolveSettings defaults invalid reasoning effort to max", () => { assert.equal(resolved.reasoningEffort, "max"); }); +test("resolveSettings ignores invalid temperature values", () => { + const resolved = resolveSettings( + { + env: { + TEMPERATURE: "hot", + }, + temperature: 3, + }, + { + model: "default-model", + baseURL: "https://default.example.com", + }, + TEST_PROCESS_ENV + ); + + assert.equal(resolved.temperature, undefined); +}); + test("applyModelConfigSelection writes model only when the effective model changes or already exists", () => { const result = applyModelConfigSelection( { @@ -358,14 +488,79 @@ test("formatDurationSeconds preserves sub-second precision and trims trailing ze assert.equal(formatDurationSeconds(4000), "4"); }); -test("buildNotifyEnv injects DURATION", () => { +test("buildNotifyEnv injects DURATION without context", () => { const env = buildNotifyEnv(2750, { HOME: "/tmp/home" }); assert.equal(env.HOME, "/tmp/home"); assert.equal(env.DURATION, "2"); + assert.equal(env.STATUS, undefined); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); + +test("buildNotifyEnv injects STATUS, FAIL_REASON, BODY, and TITLE from context", () => { + const context: NotifyContext = { + status: "failed", + failReason: "API key not found", + body: "Hello, this is the last assistant message.", + title: "Fix login bug", + }; + const env = buildNotifyEnv(5000, { HOME: "/tmp/home" }, context); + assert.equal(env.HOME, "/tmp/home"); + assert.equal(env.DURATION, "5"); + assert.equal(env.STATUS, "failed"); + assert.equal(env.FAIL_REASON, "API key not found"); + assert.equal(env.BODY, "Hello, this is the last assistant message."); + assert.equal(env.TITLE, "Fix login bug"); +}); + +test("buildNotifyEnv omits optional context fields when not provided", () => { + const env = buildNotifyEnv( + 1000, + { + HOME: "/tmp/home", + STATUS: "stale-status", + FAIL_REASON: "stale-failure", + BODY: "stale-body", + TITLE: "stale-title", + }, + { status: "completed" } + ); + assert.equal(env.STATUS, "completed"); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); + +test("buildNotifyEnv ignores empty strings in context", () => { + const env = buildNotifyEnv( + 1000, + { HOME: "/tmp/home" }, + { + status: "", + failReason: "", + body: "", + title: "", + } + ); + assert.equal(env.STATUS, undefined); + assert.equal(env.FAIL_REASON, undefined); + assert.equal(env.BODY, undefined); + assert.equal(env.TITLE, undefined); +}); + +test("buildNotifyEnv preserves special characters in body and title", () => { + const context: NotifyContext = { + body: 'Line 1\nLine 2\tindented "quoted"', + title: "Fix: login & signup (urgent)", + }; + const env = buildNotifyEnv(1000, {}, context); + assert.equal(env.BODY, 'Line 1\nLine 2\tindented "quoted"'); + assert.equal(env.TITLE, "Fix: login & signup (urgent)"); }); test( - "launchNotifyScript passes DURATION and falls back to /bin/sh for non-executable scripts", + "launchNotifyScript passes DURATION, context vars, and falls back to /bin/sh for non-executable scripts", { skip: process.platform === "win32" }, () => { const calls: Array<{ @@ -390,7 +585,13 @@ test( }; }; - launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }); + const context: NotifyContext = { + status: "completed", + body: "Task finished successfully.", + title: "Fix login bug", + }; + + launchNotifyScript("/tmp/notify.sh", 2750, "/tmp/project", spawnProcess, { WEBHOOK: "configured" }, context); assert.equal(calls.length, 2); assert.equal(calls[0]?.command, "/tmp/notify.sh"); @@ -398,9 +599,16 @@ test( assert.equal(calls[0]?.options.cwd, "/tmp/project"); assert.equal(calls[0]?.options.env?.DURATION, "2"); assert.equal(calls[0]?.options.env?.WEBHOOK, "configured"); + assert.equal(calls[0]?.options.env?.STATUS, "completed"); + assert.equal(calls[0]?.options.env?.FAIL_REASON, undefined); + assert.equal(calls[0]?.options.env?.BODY, "Task finished successfully."); + assert.equal(calls[0]?.options.env?.TITLE, "Fix login bug"); assert.equal(calls[1]?.command, "/bin/sh"); assert.deepEqual(calls[1]?.args, ["/tmp/notify.sh"]); assert.equal(calls[1]?.options.cwd, "/tmp/project"); assert.equal(calls[1]?.options.env?.DURATION, "2"); + assert.equal(calls[1]?.options.env?.STATUS, "completed"); + assert.equal(calls[1]?.options.env?.BODY, "Task finished successfully."); + assert.equal(calls[1]?.options.env?.TITLE, "Fix login bug"); } ); diff --git a/src/tests/shell-utils.test.ts b/packages/core/src/tests/shell-utils.test.ts similarity index 90% rename from src/tests/shell-utils.test.ts rename to packages/core/src/tests/shell-utils.test.ts index 50a71f41..8ec56bb1 100644 --- a/src/tests/shell-utils.test.ts +++ b/packages/core/src/tests/shell-utils.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildDisableExtglobCommand, + buildShellInitCommand, getShellKind, posixPathToWindowsPath, resolveWindowsGitBashPath, @@ -39,6 +40,18 @@ test("Shell kind detection supports Windows bash.exe paths", () => { assert.equal(buildDisableExtglobCommand("/bin/zsh"), "setopt NO_EXTENDED_GLOB 2>/dev/null || true"); }); +test("Shell init commands suppress startup file output", () => { + assert.equal( + buildShellInitCommand("/bin/zsh"), + 'ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"; if [ -f "$ZSHRC" ]; then { . "$ZSHRC"; } >/dev/null 2>&1; fi' + ); + assert.equal( + buildShellInitCommand("/bin/bash"), + 'BASHRC="${BASH_ENV:-$HOME/.bashrc}"; if [ -f "$BASHRC" ]; then { . "$BASHRC"; } >/dev/null 2>&1; fi' + ); + assert.equal(buildShellInitCommand("/bin/fish"), null); +}); + test("Windows Git Bash detection prefers bash.exe from PATH", () => { const bashPath = "D:\\Tools\\Git\\bin\\bash.exe"; const resolved = resolveWindowsGitBashPath({ diff --git a/packages/core/src/tests/telemetry.test.ts b/packages/core/src/tests/telemetry.test.ts new file mode 100644 index 00000000..6db0261e --- /dev/null +++ b/packages/core/src/tests/telemetry.test.ts @@ -0,0 +1,109 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { reportNewPrompt } from "../common/telemetry"; + +test("reportNewPrompt does not call fetch when enabled is false", () => { + let called = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((..._args: unknown[]) => { + called = true; + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: false, machineId: "test-machine" }); + assert.equal(called, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt does not call fetch when machineId is undefined", () => { + let called = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((..._args: unknown[]) => { + called = true; + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: true }); + assert.equal(called, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt does not call fetch when machineId is empty string", () => { + let called = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((..._args: unknown[]) => { + called = true; + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: true, machineId: "" }); + assert.equal(called, false); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt calls fetch with correct URL, method, headers, and body", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: true, machineId: "test-machine" }); + + // Wait for the fire-and-forget fetch to settle. + await new Promise((resolve) => setTimeout(resolve, 50)); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://deepcode.vegamo.cn/api/plugin/new"); + assert.equal(calls[0].init.method, "POST"); + assert.equal((calls[0].init.headers as Record)["Content-Type"], "application/json"); + assert.equal((calls[0].init.headers as Record)["Token"], "test-machine"); + assert.equal(calls[0].init.body, JSON.stringify({})); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt swallows fetch errors without throwing", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + return Promise.reject(new Error("Network error")); + }) as typeof globalThis.fetch; + + try { + // Should not throw. + reportNewPrompt({ enabled: true, machineId: "test-machine" }); + await new Promise((resolve) => setTimeout(resolve, 50)); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("reportNewPrompt respects custom timeoutMs", async () => { + const calls: Array<{ signal: AbortSignal }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = ((_url: string | URL | Request, init?: RequestInit) => { + calls.push({ signal: init?.signal as AbortSignal }); + return Promise.resolve(new Response()); + }) as typeof globalThis.fetch; + + try { + reportNewPrompt({ enabled: true, machineId: "test-machine", timeoutMs: 100 }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(calls.length, 1); + assert.equal(calls[0].signal.aborted, false); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/core/src/tests/tool-executor.test.ts b/packages/core/src/tests/tool-executor.test.ts new file mode 100644 index 00000000..f36def28 --- /dev/null +++ b/packages/core/src/tests/tool-executor.test.ts @@ -0,0 +1,41 @@ +import { afterEach, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ToolExecutor } from "../tools/executor"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +test("ToolExecutor accepts title-case built-in tool aliases", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-tool-executor-")); + tempDirs.push(workspace); + const filePath = path.join(workspace, "sample.txt"); + fs.writeFileSync(filePath, "alpha\nbeta\n", "utf8"); + + const executor = new ToolExecutor(workspace); + const executions = await executor.executeToolCalls("alias-session", [ + { + id: "call-read", + type: "function", + function: { + name: "Read", + arguments: JSON.stringify({ file_path: filePath }), + }, + }, + ]); + + assert.equal(executions.length, 1); + assert.equal(executions[0]?.result.ok, true); + assert.equal(executions[0]?.result.name, "read"); + assert.match(executions[0]?.result.output ?? "", /alpha/); +}); diff --git a/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts similarity index 63% rename from src/tests/tool-handlers.test.ts rename to packages/core/src/tests/tool-handlers.test.ts index 0b21eddb..735c0274 100644 --- a/src/tests/tool-handlers.test.ts +++ b/packages/core/src/tests/tool-handlers.test.ts @@ -4,7 +4,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { setTimeout as delay } from "node:timers/promises"; -import type { ToolExecutionContext } from "../tools/executor"; +import type { BackgroundProcessCompletion, ProcessTimeoutControl, ToolExecutionContext } from "../tools/executor"; import { handleBashTool } from "../tools/bash-handler"; import { handleEditTool } from "../tools/edit-handler"; import { handleReadTool } from "../tools/read-handler"; @@ -52,6 +52,169 @@ test("Bash streams stdout and stderr before command completion", async () => { assert.match(streamedOutput, /err/); }); +test("Bash terminates commands that exceed the configured timeout", async () => { + const workspace = createTempWorkspace(); + const exitedPids: Array = []; + + const result = await handleBashTool( + { + command: "printf 'start\\n'; sleep 5; printf 'done\\n'", + }, + createContext("bash-timeout", workspace, { + bashTimeoutMs: 100, + bashMinTimeoutMs: 1, + onProcessExit: (pid) => { + exitedPids.push(pid); + }, + }) + ); + + assert.equal(result.ok, false); + assert.equal(result.error, "Command timed out."); + assert.equal(result.metadata?.timedOut, true); + assert.equal(result.metadata?.timeoutMs, 100); + assert.doesNotMatch(result.output ?? "", /done/); + assert.equal(exitedPids.length, 1); +}); + +test("Bash timeout control can extend the active command deadline", async () => { + const workspace = createTempWorkspace(); + let timeoutControl: ProcessTimeoutControl | null = null; + + const result = await handleBashTool( + { + command: "sleep 0.2; printf 'done\\n'", + }, + createContext("bash-timeout-extend", workspace, { + bashTimeoutMs: 100, + bashMinTimeoutMs: 1, + onProcessTimeoutControl: (_pid, control) => { + if (control) { + timeoutControl = control; + control.setTimeoutMs(1000); + } + }, + }) + ); + + assert.ok(timeoutControl); + assert.equal(result.ok, true); + assert.match(result.output ?? "", /done/); + assert.equal(result.metadata?.timedOut, false); + assert.equal(result.metadata?.timeoutMs, 1000); +}); + +test("Bash can run commands in the background and report completion output", async () => { + const workspace = createTempWorkspace(); + let completion: BackgroundProcessCompletion | null = null; + const starts: Array = []; + const exits: Array = []; + const startedAt = Date.now(); + + const result = await handleBashTool( + { + command: "printf 'start\\n'; sleep 0.2; printf 'done\\n'", + run_in_background: true, + }, + createContext("bash-background", workspace, { + bashTimeoutMs: 10, + bashMinTimeoutMs: 1, + onProcessStart: (pid) => starts.push(pid), + onProcessExit: (pid) => exits.push(pid), + onBackgroundProcessComplete: (event) => { + completion = event; + }, + }) + ); + + assert.equal(result.ok, true); + assert.equal(result.metadata?.runInBackground, true); + assert.equal(typeof result.metadata?.backgroundTaskId, "string"); + assert.equal(typeof result.metadata?.outputPath, "string"); + assert.equal(typeof result.metadata?.processId, "number"); + const stopCommand = + process.platform === "win32" + ? `cmd.exe /c "taskkill /PID ${result.metadata.processId} /T /F"` + : `kill -- -${result.metadata.processId}`; + assert.equal(result.metadata?.stopCommand, stopCommand); + assert.match(result.output ?? "", /Stop it with:/); + assert.ok(Date.now() - startedAt < 500); + assert.equal(starts.length, 1); + + await waitFor(() => completion !== null, 2000); + + assert.ok(completion); + const done = completion as BackgroundProcessCompletion; + assert.equal(done.ok, true); + assert.equal(done.exitCode, 0); + assert.equal(exits.length, 1); + const outputPath = done.outputPath; + const output = fs.readFileSync(outputPath, "utf8"); + assert.match(output, /start/); + assert.match(output, /done/); + assert.doesNotMatch(output, /__DEEPCODE_PWD__/); +}); + +test("Bash background completion reports failed exit codes", async () => { + const workspace = createTempWorkspace(); + let completion: BackgroundProcessCompletion | null = null; + + const result = await handleBashTool( + { + command: "printf 'bad\\n'; exit 7", + run_in_background: true, + }, + createContext("bash-background-failure", workspace, { + onBackgroundProcessComplete: (event) => { + completion = event; + }, + }) + ); + + assert.equal(result.ok, true); + await waitFor(() => completion !== null, 2000); + + assert.ok(completion); + const done = completion as BackgroundProcessCompletion; + assert.equal(done.ok, false); + assert.equal(done.exitCode, 7); + assert.match(done.error ?? "", /exit code 7/); + const output = fs.readFileSync(done.outputPath, "utf8"); + assert.match(output, /bad/); +}); + +test("Bash removes a trailing ampersand when run_in_background is true", async () => { + const workspace = createTempWorkspace(); + let startedCommand = ""; + let completion: BackgroundProcessCompletion | null = null; + + const result = await handleBashTool( + { + command: "printf 'trimmed\\n' &", + run_in_background: true, + }, + createContext("bash-background-trailing-ampersand", workspace, { + onProcessStart: (_pid, command) => { + startedCommand = command; + }, + onBackgroundProcessComplete: (event) => { + completion = event; + }, + }) + ); + + assert.equal(result.ok, true); + assert.equal(startedCommand, "printf 'trimmed\\n'"); + + await waitFor(() => completion !== null, 2000); + + assert.ok(completion); + const done = completion as BackgroundProcessCompletion; + assert.equal(done.command, "printf 'trimmed\\n'"); + assert.equal(done.ok, true); + assert.equal(fs.readFileSync(done.outputPath, "utf8"), "trimmed\n"); +}); + test("UpdatePlan accepts a markdown task list string", async () => { const workspace = createTempWorkspace(); const plan = ["## Task List", "", "- [>] Inspect current behavior", "- [ ] Implement UpdatePlan"].join("\n"); @@ -115,17 +278,29 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i ); }); +test("Read returns full-file snippet ids with a semantic prefix", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "full.txt"); + fs.writeFileSync(filePath, "alpha\nbeta\n", "utf8"); + + const firstSnippet = await readSnippet(filePath, "full-file-snippet", workspace); + const secondSnippet = await readSnippet(filePath, "full-file-snippet", workspace); + + assert.equal(firstSnippet.id, "full_file_0"); + assert.equal(secondSnippet.id, "full_file_1"); +}); + test("Edit returns candidate match snippets when old_string is not unique", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "duplicate.txt"); fs.writeFileSync(filePath, ["city", "city", "salary"].join("\n"), "utf8"); const sessionId = "candidate-matches"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = await readSnippet(filePath, sessionId, workspace); const editResult = await handleEditTool( { - file_path: filePath, + snippet_id: snippet.id, old_string: "city", new_string: "location", }, @@ -146,7 +321,7 @@ test("Edit returns candidate match snippets when old_string is not unique", asyn assert.match(candidates[0]?.preview ?? "", /city/); }); -test("Edit returns closest matches only above threshold with surrounding context", async () => { +test("Edit reports missing old_string without closest-match metadata when no LLM is configured", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "closest.ts"); fs.writeFileSync( @@ -162,11 +337,11 @@ test("Edit returns closest matches only above threshold with surrounding context ); const sessionId = "closest-match-context"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const fullSnippet = await readSnippet(filePath, sessionId, workspace); const closeResult = await handleEditTool( { - file_path: filePath, + snippet_id: fullSnippet.id, old_string: "function computeTotal(value: number) {", new_string: "function computeTotal(input: number) {", }, @@ -175,19 +350,11 @@ test("Edit returns closest matches only above threshold with surrounding context assert.equal(closeResult.ok, false); assert.equal(closeResult.error, "old_string not found in file."); - const closestMatch = closeResult.metadata?.closest_match as - | { snippet_id?: string; start_line?: number; end_line?: number; similarity?: number; preview?: string } - | undefined; - assert.ok(closestMatch?.snippet_id); - assert.equal(closestMatch.start_line, 1); - assert.equal(closestMatch.end_line, 4); - assert.ok((closestMatch.similarity ?? 0) >= 0.8); - assert.match(closestMatch.preview ?? "", /const before = true/); - assert.match(closestMatch.preview ?? "", /return value/); + assert.equal(closeResult.metadata?.closest_match, undefined); const lowResult = await handleEditTool( { - file_path: filePath, + snippet_id: fullSnippet.id, old_string: 'query: string = Field(description="search query")', new_string: "query: string", }, @@ -215,12 +382,119 @@ test("Edit returns closest matches only above threshold with surrounding context ); assert.equal(scopedCloseResult.ok, false); - const scopedClosestMatch = scopedCloseResult.metadata?.closest_match as - | { start_line?: number; end_line?: number; preview?: string } - | undefined; - assert.equal(scopedClosestMatch?.start_line, 2); - assert.equal(scopedClosestMatch?.end_line, 3); - assert.doesNotMatch(scopedClosestMatch?.preview ?? "", /const before = true/); + assert.equal(scopedCloseResult.error, "old_string not found in file."); + assert.equal(scopedCloseResult.metadata?.closest_match, undefined); +}); + +test("Edit appends an LLM diagnosis when old_string is not found", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "diagnose.ts"); + fs.writeFileSync( + filePath, + [ + "const beforeOne = true;", + "const beforeTwo = true;", + "function computeSubtotal(value: number) {", + " return value;", + "}", + "const afterOne = true;", + "const afterTwo = true;", + "const afterThree = true;", + ].join("\n"), + "utf8" + ); + + const sessionId = "llm-not-found-diagnosis"; + const readResult = await handleReadTool( + { file_path: filePath, offset: 3, limit: 2 }, + createContext(sessionId, workspace) + ); + const snippet = (readResult.metadata?.snippet ?? null) as { id: string } | null; + assert.ok(snippet); + + let llmCalls = 0; + let prompt = ""; + const editResult = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "function computeTotal(value: number) {\n return value;", + new_string: "function computeTotal(input: number) {\n return input;", + }, + createContext(sessionId, workspace, { + createOpenAIClient: () => ({ + client: { + chat: { + completions: { + create: async (request: { messages?: Array<{ content?: string }> }) => { + llmCalls += 1; + prompt = String(request.messages?.[1]?.content ?? ""); + return { + choices: [ + { + message: { + content: + "", + }, + }, + ], + }; + }, + }, + }, + } as any, + model: "test-model", + thinkingEnabled: false, + }), + }) + ); + + assert.equal(editResult.ok, false); + assert.equal(llmCalls, 1); + assert.equal( + editResult.error, + "old_string not found in file. The requested function name is computeTotal, but the snippet contains computeSubtotal." + ); + assert.equal(editResult.metadata?.closest_match, undefined); + assert.match(prompt, //); + assert.match(prompt, //); + assert.doesNotMatch(prompt, /const afterTwo = true/); +}); + +test("Edit keeps the base not-found error when the LLM diagnosis is unavailable", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "invalid-diagnosis.ts"); + fs.writeFileSync(filePath, "const existing = true;\n", "utf8"); + + const sessionId = "invalid-llm-not-found-diagnosis"; + const snippet = await readSnippet(filePath, sessionId, workspace); + + const editResult = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "const missing = true;", + new_string: "const missing = false;", + }, + createContext(sessionId, workspace, { + createOpenAIClient: () => ({ + client: { + chat: { + completions: { + create: async () => ({ + choices: [{ message: { content: "" } }], + }), + }, + }, + } as any, + model: "test-model", + thinkingEnabled: false, + }), + }) + ); + + assert.equal(editResult.ok, false); + assert.equal(editResult.error, "old_string not found in file."); + assert.equal(editResult.metadata?.closest_match, undefined); }); test("Edit allows outdated snippet matches but reports outdated snippet when no match is found", async () => { @@ -331,11 +605,11 @@ test("replace_all requires expected_occurrences for broad short-fragment replace fs.writeFileSync(filePath, [fragment, fragment, fragment].join("\n---\n"), "utf8"); const sessionId = "replace-all-guard"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = await readSnippet(filePath, sessionId, workspace); const blockedResult = await handleEditTool( { - file_path: filePath, + snippet_id: snippet.id, old_string: fragment, new_string: " schema:\n type: array", replace_all: true, @@ -348,7 +622,7 @@ test("replace_all requires expected_occurrences for broad short-fragment replace const allowedResult = await handleEditTool( { - file_path: filePath, + snippet_id: snippet.id, old_string: fragment, new_string: " schema:\n type: array", replace_all: true, @@ -374,11 +648,11 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn fs.writeFileSync(filePath, "params['city_json'] = f'\"{city}\"'\n", "utf8"); const sessionId = "closest-match"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = await readSnippet(filePath, sessionId, workspace); const editResult = await handleEditTool( { - file_path: filePath, + snippet_id: snippet.id, old_string: "params['city_json'] = f'\\\\\"{city}\\\\\"'", new_string: "params['city_json'] = city", }, @@ -420,12 +694,12 @@ test("Edit accepts a unique loose-escape match for over-escaped unicode sequence fs.writeFileSync(filePath, 'const sequence = "\\u001B[13;2~";\n', "utf8"); const sessionId = "unicode-loose-escape"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = await readSnippet(filePath, sessionId, workspace); let llmCalls = 0; const editResult = await handleEditTool( { - file_path: filePath, + snippet_id: snippet.id, old_string: 'const sequence = "\\\\u001B[13;2~";', new_string: 'const sequence = "\\\\u001B[13;130u";', }, @@ -472,11 +746,11 @@ test("Edit strips accidental read-result tabs after newlines when that creates a fs.writeFileSync(filePath, ["function demo() {", " return 1;", "}"].join("\n") + "\n", "utf8"); const sessionId = "line-leading-tab-correction"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = await readSnippet(filePath, sessionId, workspace); const editResult = await handleEditTool( { - file_path: filePath, + snippet_id: snippet.id, old_string: "function demo() {\n\t return 1;\n\t}", new_string: "function demo() {\n\t return 2;\n\t}", }, @@ -513,7 +787,7 @@ test("Write repairs JSON object content for .json files", async () => { assert.equal(fs.readFileSync(filePath, "utf8"), '{\n "name": "demo",\n "private": true\n}'); }); -test("Write updates file state so a follow-up Edit can succeed without another Read", async () => { +test("Edit requires snippet_id even after Write refreshes file state", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "note.txt"); @@ -538,11 +812,55 @@ test("Write updates file state so a follow-up Edit can succeed without another R createContext("write-then-edit", workspace) ); + assert.equal(editResult.ok, false); + assert.match(editResult.error ?? "", /snippet_id/); + assert.equal(fs.readFileSync(filePath, "utf8"), "alpha\nbeta\n"); +}); + +test("Edit allows empty old_string when the file is empty", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "empty-edit.txt"); + fs.writeFileSync(filePath, "", "utf8"); + + const sessionId = "edit-empty-existing"; + const snippet = await readSnippet(filePath, sessionId, workspace); + + const editResult = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "", + new_string: "initialized\n", + }, + createContext(sessionId, workspace) + ); + assert.equal(editResult.ok, true); - assert.equal(editResult.metadata?.read_scope_type, "full"); - assert.match(String(editResult.metadata?.diff_preview ?? ""), /-beta/); - assert.match(String(editResult.metadata?.diff_preview ?? ""), /\+gamma/); - assert.equal(fs.readFileSync(filePath, "utf8"), "alpha\ngamma\n"); + assert.equal(editResult.metadata?.matched_via, "empty_file"); + assert.equal(editResult.metadata?.replaced_count, 1); + assert.match(String(editResult.metadata?.diff_preview ?? ""), /\+initialized/); + assert.equal(fs.readFileSync(filePath, "utf8"), "initialized\n"); +}); + +test("Edit rejects empty old_string when the file is not empty", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, "non-empty-edit.txt"); + fs.writeFileSync(filePath, "alpha\n", "utf8"); + + const sessionId = "edit-empty-old-string-non-empty-file"; + const snippet = await readSnippet(filePath, sessionId, workspace); + + const editResult = await handleEditTool( + { + snippet_id: snippet.id, + old_string: "", + new_string: "initialized\n", + }, + createContext(sessionId, workspace) + ); + + assert.equal(editResult.ok, false); + assert.equal(editResult.error, "old_string must not be empty unless the file is empty."); + assert.equal(fs.readFileSync(filePath, "utf8"), "alpha\n"); }); test("Write requires a full read before overwriting an existing file", async () => { @@ -591,7 +909,7 @@ test("Edit rejects stale reads after the file changes on disk", async () => { fs.writeFileSync(filePath, "before\n", "utf8"); const sessionId = "stale-edit"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = await readSnippet(filePath, sessionId, workspace); fs.writeFileSync(filePath, "after\n", "utf8"); const futureTime = new Date(Date.now() + 2000); @@ -599,7 +917,7 @@ test("Edit rejects stale reads after the file changes on disk", async () => { const editResult = await handleEditTool( { - file_path: filePath, + snippet_id: snippet.id, old_string: "after", new_string: "final", }, @@ -633,11 +951,11 @@ test("Edit preserves CRLF line endings for existing files", async () => { fs.writeFileSync(filePath, "alpha\r\nbeta\r\n", "utf8"); const sessionId = "crlf-edit"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + const snippet = await readSnippet(filePath, sessionId, workspace); const editResult = await handleEditTool( { - file_path: filePath, + snippet_id: snippet.id, old_string: "beta", new_string: "gamma", }, @@ -706,6 +1024,22 @@ function createTempWorkspace(): string { return dir; } +async function readSnippet( + filePath: string, + sessionId: string, + workspace: string +): Promise<{ id: string; startLine: number; endLine: number }> { + const readResult = await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); + assert.equal(readResult.ok, true); + const snippet = (readResult.metadata?.snippet ?? null) as { + id: string; + startLine: number; + endLine: number; + } | null; + assert.ok(snippet); + return snippet; +} + async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/src/tests/web-search-handler.test.ts b/packages/core/src/tests/web-search-handler.test.ts similarity index 100% rename from src/tests/web-search-handler.test.ts rename to packages/core/src/tests/web-search-handler.test.ts diff --git a/src/tools/ask-user-question-handler.ts b/packages/core/src/tools/ask-user-question-handler.ts similarity index 100% rename from src/tools/ask-user-question-handler.ts rename to packages/core/src/tools/ask-user-question-handler.ts diff --git a/packages/core/src/tools/bash-handler.ts b/packages/core/src/tools/bash-handler.ts new file mode 100644 index 00000000..5da07944 --- /dev/null +++ b/packages/core/src/tools/bash-handler.ts @@ -0,0 +1,524 @@ +import { spawn } from "child_process"; +import { randomUUID } from "crypto"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { DEFAULT_BASH_TIMEOUT_MS, clampBashTimeoutMs } from "../common/bash-timeout"; +import { killProcessTree } from "../common/process-tree"; +import type { ProcessTimeoutControl, ProcessTimeoutInfo, ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { + buildDisableExtglobCommand, + buildShellEnv, + buildShellInitCommand, + resolveShellPath, + rewriteWindowsNullRedirect, + toNativeCwd, +} from "../common/shell-utils"; + +const MAX_OUTPUT_CHARS = 30000; +const MAX_CAPTURE_CHARS = 10 * 1024 * 1024; +const BACKGROUND_OUTPUT_DIR = path.join(os.tmpdir(), "deepcode-background"); +const TRAILING_BACKGROUND_OPERATOR_PATTERN = /(^|[^\\&])\s*&\s*$/; +const sessionWorkingDirs = new Map(); + +export function clearSessionWorkingDir(sessionId: string): void { + if (!sessionId) { + return; + } + sessionWorkingDirs.delete(sessionId); +} + +type ToolCommandResult = { + ok: boolean; + output: string; + cwd: string | null; + exitCode: number | null; + signal: string | null; + truncated: boolean; + shellPath?: string; + startCwd?: string; + timedOut?: boolean; + timeoutMs?: number; + deadlineAt?: string; +}; + +export async function handleBashTool( + args: Record, + context: ToolExecutionContext +): Promise { + const rawCommand = typeof args.command === "string" ? args.command : ""; + const runInBackground = isTrue(args.run_in_background); + const command = runInBackground ? stripTrailingBackgroundOperator(rawCommand) : rawCommand; + if (!command.trim()) { + return { + ok: false, + name: "bash", + error: 'Missing required "command" string.', + }; + } + + const startCwd = getSessionCwd(context.sessionId, context.projectRoot); + const { shellPath, shellArgs, marker } = buildShellCommand(command); + + if (runInBackground) { + return startBackgroundShellCommand(shellPath, shellArgs, startCwd, command, marker, context); + } + + const execution = await executeShellCommand(shellPath, shellArgs, startCwd, command, context); + const result = buildToolCommandResult( + execution.stdout, + execution.stderr, + marker, + execution.exitCode, + execution.signal, + shellPath, + startCwd, + execution.timedOut, + execution.timeoutMs, + execution.deadlineAtMs + ); + updateSessionCwd(context.sessionId, startCwd, result.cwd); + + if (execution.error || result.exitCode !== 0 || result.signal !== null) { + const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error, execution.timedOut); + return formatResult({ ...result, ok: false }, "bash", errorMessage); + } + + return formatResult(result, "bash"); +} + +function isTrue(value: unknown): boolean { + return value === true || value === "true"; +} + +function stripTrailingBackgroundOperator(command: string): string { + return command.replace(TRAILING_BACKGROUND_OPERATOR_PATTERN, "$1").trimEnd(); +} + +function getSessionCwd(sessionId: string, fallback: string): string { + return sessionWorkingDirs.get(sessionId) ?? fallback; +} + +function updateSessionCwd(sessionId: string, fallback: string, cwd: string | null): void { + const nextCwd = cwd ?? fallback; + sessionWorkingDirs.set(sessionId, nextCwd); +} + +function buildShellCommand(command: string): { + shellPath: string; + shellArgs: string[]; + marker: string; +} { + const shellPath = resolveShellPath(); + const marker = buildMarker(); + const initCommand = buildShellInitCommand(shellPath); + const disableExtglobCommand = buildDisableExtglobCommand(shellPath); + const normalizedCommand = rewriteWindowsNullRedirect(command); + const wrappedParts = []; + if (initCommand) { + wrappedParts.push(initCommand); + } + if (disableExtglobCommand) { + wrappedParts.push(disableExtglobCommand); + } + wrappedParts.push( + normalizedCommand, + "__DEEPCODE_STATUS__=$?", + `printf '%s%s\\n' "${marker}" "$PWD"`, + "exit $__DEEPCODE_STATUS__" + ); + const wrappedCommand = `{ ${wrappedParts.join("; ")}; } < /dev/null`; + return { shellPath, shellArgs: ["-c", wrappedCommand], marker }; +} + +async function executeShellCommand( + shellPath: string, + shellArgs: string[], + cwd: string, + command: string, + context: ToolExecutionContext +): Promise<{ + stdout: string; + stderr: string; + exitCode: number | null; + signal: string | null; + error?: string; + timedOut: boolean; + timeoutMs: number; + deadlineAtMs: number; +}> { + return new Promise((resolve) => { + const detached = process.platform !== "win32"; + const configuredEnv = context.createOpenAIClient?.().env ?? {}; + const minTimeoutMs = context.bashMinTimeoutMs; + const initialTimeoutMs = clampBashTimeoutMs(context.bashTimeoutMs ?? DEFAULT_BASH_TIMEOUT_MS, minTimeoutMs); + const startedAtMs = Date.now(); + let timeoutMs = initialTimeoutMs; + let deadlineAtMs = startedAtMs + timeoutMs; + let timedOut = false; + let settled = false; + let timeoutTimer: ReturnType | null = null; + const child = spawn(shellPath, shellArgs, { + cwd, + env: buildShellEnv(shellPath, configuredEnv), + detached, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const pid = child.pid; + + const getTimeoutInfo = (): ProcessTimeoutInfo => ({ + timeoutMs, + startedAtMs, + deadlineAtMs, + timedOut, + }); + const stopTimeoutTimer = () => { + if (timeoutTimer) { + clearTimeout(timeoutTimer); + timeoutTimer = null; + } + }; + const triggerTimeout = () => { + if (settled || timedOut || typeof pid !== "number") { + return; + } + timedOut = true; + stopTimeoutTimer(); + killProcessTree(pid, "SIGKILL"); + }; + const scheduleTimeout = () => { + stopTimeoutTimer(); + if (settled) { + return; + } + const remainingMs = Math.max(0, deadlineAtMs - Date.now()); + timeoutTimer = setTimeout(triggerTimeout, remainingMs); + }; + const timeoutControl: ProcessTimeoutControl = { + getInfo: getTimeoutInfo, + setTimeoutMs: (nextTimeoutMs) => { + timeoutMs = clampBashTimeoutMs(nextTimeoutMs, minTimeoutMs); + deadlineAtMs = startedAtMs + timeoutMs; + if (deadlineAtMs <= Date.now()) { + triggerTimeout(); + } else { + scheduleTimeout(); + } + return getTimeoutInfo(); + }, + }; + + if (typeof pid === "number") { + context.onProcessStart?.(pid, command); + context.onProcessTimeoutControl?.(pid, timeoutControl); + scheduleTimeout(); + } + + let stdout = ""; + let stderr = ""; + let error: string | undefined; + + child.stdout?.on("data", (chunk: string | Buffer) => { + stdout = appendChunk(stdout, chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + context.onProcessStdout?.(pid as number, text); + }); + child.stderr?.on("data", (chunk: string | Buffer) => { + stderr = appendChunk(stderr, chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + context.onProcessStdout?.(pid as number, text); + }); + + child.on("error", (spawnError) => { + error = spawnError.message; + }); + + child.on("close", (code, signal) => { + settled = true; + stopTimeoutTimer(); + if (typeof pid === "number") { + context.onProcessTimeoutControl?.(pid, null); + context.onProcessExit?.(pid); + } + resolve({ + stdout, + stderr, + exitCode: typeof code === "number" ? code : null, + signal: signal ?? null, + error, + timedOut, + timeoutMs, + deadlineAtMs, + }); + }); + }); +} + +function startBackgroundShellCommand( + shellPath: string, + shellArgs: string[], + cwd: string, + command: string, + marker: string, + context: ToolExecutionContext +): ToolExecutionResult { + fs.mkdirSync(BACKGROUND_OUTPUT_DIR, { recursive: true }); + const taskId = `bash-${randomUUID()}`; + const outputPath = path.join(BACKGROUND_OUTPUT_DIR, `${taskId}.log`); + const startedAtMs = Date.now(); + const detached = process.platform !== "win32"; + const configuredEnv = context.createOpenAIClient?.().env ?? {}; + const child = spawn(shellPath, shellArgs, { + cwd, + env: buildShellEnv(shellPath, configuredEnv), + detached, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }); + const pid = child.pid; + const processId = typeof pid === "number" ? pid : -1; + const stopCommand = typeof pid === "number" ? buildStopBackgroundProcessCommand(pid) : null; + + let stdout = ""; + let stderr = ""; + let error: string | undefined; + + const appendOutputFile = (chunk: string | Buffer) => { + try { + fs.appendFileSync(outputPath, chunk); + } catch { + // Keep the background process running even if temp-file writes fail. + } + }; + + if (typeof pid === "number") { + context.onProcessStart?.(pid, command); + } + + child.stdout?.on("data", (chunk: string | Buffer) => { + stdout = appendChunk(stdout, chunk); + appendOutputFile(chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + if (typeof pid === "number") { + context.onProcessStdout?.(pid, text); + } + }); + child.stderr?.on("data", (chunk: string | Buffer) => { + stderr = appendChunk(stderr, chunk); + appendOutputFile(chunk); + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + if (typeof pid === "number") { + context.onProcessStdout?.(pid, text); + } + }); + + child.on("error", (spawnError) => { + error = spawnError.message; + }); + + child.on("close", (code, signal) => { + const markerResult = stripMarker(stdout, marker); + const finalOutput = joinOutput(markerResult.output, stderr); + const result = buildToolCommandResult( + stdout, + stderr, + marker, + typeof code === "number" ? code : null, + signal ?? null, + shellPath, + cwd + ); + updateSessionCwd(context.sessionId, cwd, result.cwd); + writeFinalBackgroundOutput(outputPath, finalOutput); + if (typeof pid === "number") { + context.onProcessExit?.(pid); + } + const ok = !error && result.exitCode === 0 && result.signal === null; + context.onBackgroundProcessComplete?.({ + taskId, + processId, + command, + outputPath, + ok, + exitCode: result.exitCode, + signal: result.signal, + error: ok ? undefined : buildErrorMessage(result.exitCode, result.signal, error), + cwd: result.cwd, + shellPath, + startedAtMs, + completedAtMs: Date.now(), + }); + }); + + return { + ok: true, + name: "bash", + output: buildBackgroundStartMessage(taskId, outputPath, stopCommand), + metadata: { + backgroundTaskId: taskId, + processId: typeof pid === "number" ? pid : null, + outputPath, + stopCommand, + cwd, + shellPath, + startCwd: cwd, + runInBackground: true, + }, + }; +} + +function buildBackgroundStartMessage(taskId: string, outputPath: string, stopCommand: string | null): string { + const parts = [`Command running in background with ID: ${taskId}.`]; + if (stopCommand) { + parts.push(`Stop it with: ${stopCommand}`); + } + parts.push(`Output is being written to: ${outputPath}`); + return parts.join(" "); +} + +function buildStopBackgroundProcessCommand(processId: number): string { + if (process.platform === "win32") { + return `cmd.exe /c "taskkill /PID ${processId} /T /F"`; + } + return `kill -- -${processId}`; +} + +function writeFinalBackgroundOutput(outputPath: string, output: string | undefined): void { + try { + fs.writeFileSync(outputPath, output ?? "", "utf8"); + } catch { + // Ignore notification/output persistence failures; the tool result already returned. + } +} + +function appendChunk(existing: string, chunk: string | Buffer): string { + if (existing.length >= MAX_CAPTURE_CHARS) { + return existing; + } + const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); + const remaining = MAX_CAPTURE_CHARS - existing.length; + return `${existing}${text.slice(0, remaining)}`; +} + +function buildMarker(): string { + const token = Math.random().toString(36).slice(2); + return `__DEEPCODE_PWD__${token}__`; +} + +function buildToolCommandResult( + stdout: string, + stderr: string, + marker: string, + exitCode: number | null, + signal: string | null, + shellPath: string, + startCwd: string, + timedOut: boolean = false, + timeoutMs?: number, + deadlineAtMs?: number +): ToolCommandResult { + const { output: cleanedStdout, cwd } = stripMarker(stdout, marker); + const combined = joinOutput(cleanedStdout, stderr); + const { text, truncated } = truncateOutput(combined); + return { + ok: exitCode === 0 && signal === null, + output: text, + cwd, + exitCode, + signal, + truncated, + shellPath, + startCwd, + timedOut, + timeoutMs, + deadlineAt: typeof deadlineAtMs === "number" ? new Date(deadlineAtMs).toISOString() : undefined, + }; +} + +function stripMarker(stdout: string, marker: string): { output: string; cwd: string | null } { + if (!stdout) { + return { output: "", cwd: null }; + } + + const lines = stdout.split(/\r?\n/); + let markerIndex = -1; + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (lines[i].startsWith(marker)) { + markerIndex = i; + break; + } + } + + if (markerIndex === -1) { + return { output: stdout, cwd: null }; + } + + const markerLine = lines[markerIndex]; + const shellCwd = markerLine.slice(marker.length).trim(); + const cwd = shellCwd ? toNativeCwd(shellCwd) : null; + lines.splice(markerIndex, 1); + return { output: lines.join("\n"), cwd }; +} + +function joinOutput(stdout: string, stderr: string): string { + const trimmedStdout = stdout ?? ""; + const trimmedStderr = stderr ?? ""; + if (trimmedStdout && trimmedStderr) { + return `${trimmedStdout}\n${trimmedStderr}`; + } + return trimmedStdout || trimmedStderr; +} + +function truncateOutput(output: string): { text: string; truncated: boolean } { + if (output.length <= MAX_OUTPUT_CHARS) { + return { text: output, truncated: false }; + } + return { text: output.slice(0, MAX_OUTPUT_CHARS), truncated: true }; +} + +function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string, timedOut = false): string { + if (error) { + return error; + } + if (timedOut) { + return "Command timed out."; + } + if (signal) { + return `Command terminated by signal ${signal}.`; + } + if (exitCode !== null) { + return `Command failed with exit code ${exitCode}.`; + } + return "Command failed."; +} + +function formatResult(result: ToolCommandResult, name: string, errorMessage?: string): ToolExecutionResult { + const metadata: Record = { + exitCode: result.exitCode, + signal: result.signal, + cwd: result.cwd, + truncated: result.truncated, + shellPath: result.shellPath, + startCwd: result.startCwd, + }; + if (typeof result.timedOut === "boolean") { + metadata.timedOut = result.timedOut; + } + if (typeof result.timeoutMs === "number") { + metadata.timeoutMs = result.timeoutMs; + } + if (result.deadlineAt) { + metadata.deadlineAt = result.deadlineAt; + } + + const outputValue = result.output ? result.output : undefined; + + return { + ok: result.ok, + name, + output: outputValue, + error: errorMessage, + metadata, + }; +} diff --git a/src/tools/edit-handler.ts b/packages/core/src/tools/edit-handler.ts similarity index 80% rename from src/tools/edit-handler.ts rename to packages/core/src/tools/edit-handler.ts index 29108e5b..b687c4e4 100644 --- a/src/tools/edit-handler.ts +++ b/packages/core/src/tools/edit-handler.ts @@ -8,14 +8,13 @@ import { readTextFileWithMetadata, writeTextFile, } from "../common/file-utils"; -import { executeValidatedTool, semanticBoolean } from "../common/runtime"; +import { executeValidatedTool, semanticBoolean } from "../common/validate"; import { createSnippet, getFileState, getSnippet, hasSnippetOutdatedFileVersion, isAbsoluteFilePath, - isFullFileView, normalizeFilePath, recordFileState, } from "../common/state"; @@ -23,8 +22,6 @@ import { const MAX_CANDIDATE_COUNT = 5; const REPLACE_ALL_MATCH_THRESHOLD = 5; const SHORT_REPLACE_ALL_LENGTH = 40; -const MIN_FUZZY_SCORE = 0.8; -const CLOSEST_MATCH_CONTEXT_LINES = 2; const OUTDATED_SNIPPET_NOT_FOUND_ERROR = "old_string was not found in this snippet scope. The file has changed since this snippet was created. Read the file again before editing."; @@ -49,14 +46,6 @@ type MatchOccurrence = { endLine: number; }; -type ClosestMatch = { - text: string; - startLine: number; - endLine: number; - score: number; - strategy: "loose_escape" | "fuzzy_window"; -}; - type LooseEscapeMatch = MatchOccurrence & { text: string; score: number; @@ -69,7 +58,7 @@ type CorrectedEditStrings = { const editSchema = z.strictObject({ file_path: z.string().optional(), - snippet_id: z.string().optional(), + snippet_id: z.string().min(1, "snippet_id is required."), old_string: z.string(), new_string: z.string(), replace_all: semanticBoolean(false).optional(), @@ -94,19 +83,19 @@ export async function handleEditTool( args, context, async (input) => { - const snippetId = input.snippet_id?.trim() ?? ""; - const snippet = snippetId ? getSnippet(context.sessionId, snippetId) : null; + const snippetId = input.snippet_id.trim(); + const snippet = getSnippet(context.sessionId, snippetId); let filePath = input.file_path?.trim() ?? ""; - if (!filePath && !snippet) { + if (!snippet) { return { ok: false, name: "edit", - error: 'Missing required "file_path" string or "snippet_id" string.', + error: `Unknown snippet_id: ${snippetId}`, }; } - if (!filePath && snippet) { + if (!filePath) { filePath = snippet.filePath; } @@ -119,15 +108,7 @@ export async function handleEditTool( }; } - if (snippetId && !snippet) { - return { - ok: false, - name: "edit", - error: `Unknown snippet_id: ${snippetId}`, - }; - } - - if (snippet && snippet.filePath !== filePath) { + if (snippet.filePath !== filePath) { return { ok: false, name: "edit", @@ -135,14 +116,6 @@ export async function handleEditTool( }; } - if (input.old_string === "") { - return { - ok: false, - name: "edit", - error: "old_string must not be empty.", - }; - } - if (input.old_string === input.new_string) { return { ok: false, @@ -188,14 +161,6 @@ export async function handleEditTool( }; } - if (!snippet && !isFullFileView(fileState)) { - return { - ok: false, - name: "edit", - error: "File was only partially read. Use snippet_id or read the full file before editing.", - }; - } - if (hasFileChangedSinceState(filePath, fileState)) { return { ok: false, @@ -211,12 +176,41 @@ export async function handleEditTool( const newString = input.new_string; const replaceAll = input.replace_all ?? false; const lineIndex = buildLineIndex(raw); - const scope = buildSearchScope(filePath, raw, lineIndex, snippet ?? null); - let matches = findOccurrences(raw, oldString, scope); - let matchedVia: "exact" | "line_leading_tab_correction" | "loose_escape" | "llm_escape_correction" = "exact"; + const scope = buildSearchScope(filePath, raw, lineIndex, snippet); + let matches: MatchOccurrence[] = []; + let matchedVia: + | "exact" + | "empty_file" + | "line_leading_tab_correction" + | "loose_escape" + | "llm_escape_correction" = "exact"; let replacementOldString = oldString; let replacementNewString = newString; + if (oldString === "") { + if (raw !== "") { + return { + ok: false, + name: "edit", + error: "old_string must not be empty unless the file is empty.", + metadata: { + scope: formatScopeMetadata(scope), + }, + }; + } + matches = [ + { + startOffset: 0, + endOffset: 0, + startLine: 1, + endLine: 1, + }, + ]; + matchedVia = "empty_file"; + } else { + matches = findOccurrences(raw, oldString, scope); + } + if (matches.length === 0) { const tabStrippedOldString = stripReadResultLineTabs(oldString); if (tabStrippedOldString !== oldString) { @@ -270,19 +264,21 @@ export async function handleEditTool( }; } - const closestMatch = findClosestMatch(raw, oldString, scope, lineIndex); + const notFoundReason = await inferOldStringNotFoundReasonWithLLM( + raw, + lineIndex, + scope, + oldString, + newString, + context + ); return { ok: false, name: "edit", - error: "old_string not found in file.", - metadata: closestMatch - ? { - scope: formatScopeMetadata(scope), - closest_match: buildClosestMatchMetadata(context.sessionId, filePath, closestMatch), - } - : { - scope: formatScopeMetadata(scope), - }, + error: notFoundReason ? `old_string not found in file. ${notFoundReason}` : "old_string not found in file.", + metadata: { + scope: formatScopeMetadata(scope), + }, }; } @@ -321,7 +317,9 @@ export async function handleEditTool( const updated = applyReplacement(raw, replacementOldString, replacementNewString, matches, replaceAll); const diffPreview = buildDiffPreview(filePath, raw, updated); + context.onBeforeFileMutation?.(filePath); writeTextFile(filePath, updated, metadata.encoding, metadata.lineEndings); + context.onAfterFileMutation?.(filePath); const freshMetadata = readTextFileWithMetadata(filePath); recordFileState( context.sessionId, @@ -344,7 +342,7 @@ export async function handleEditTool( replaced_count: replacedCount, matched_via: matchedVia, cache_refreshed: true, - read_scope_type: snippet ? "snippet" : "full", + read_scope_type: snippet.scopeType, encoding: freshMetadata.encoding, line_endings: freshMetadata.lineEndings, diff_preview: diffPreview, @@ -580,24 +578,6 @@ function buildCandidateMetadata( }); } -function buildClosestMatchMetadata( - sessionId: string, - filePath: string, - closestMatch: ClosestMatch -): Record { - const preview = formatWithLineNumbers(closestMatch.text.split(/\r?\n/), closestMatch.startLine); - const snippet = createSnippet(sessionId, filePath, closestMatch.startLine, closestMatch.endLine, preview); - - return { - snippet_id: snippet?.id ?? null, - start_line: closestMatch.startLine, - end_line: closestMatch.endLine, - similarity: Number(closestMatch.score.toFixed(3)), - strategy: closestMatch.strategy, - preview, - }; -} - function formatScopeMetadata(scope: SearchScope): Record { return { file_path: scope.filePath, @@ -617,84 +597,6 @@ function formatWithLineNumbers(lines: string[], startLine: number): string { return lines.map((line, index) => `${String(startLine + index).padStart(6, " ")}\t${line}`).join("\n"); } -function findClosestMatch( - raw: string, - oldString: string, - scope: SearchScope, - lineIndex: LineIndex -): ClosestMatch | null { - const looseEscapeMatches = findLooseEscapeMatches(raw, oldString, scope); - if (looseEscapeMatches.length > 0) { - let bestLooseMatch: ClosestMatch | null = null; - for (const match of looseEscapeMatches) { - const candidate: ClosestMatch = { - text: match.text, - startLine: match.startLine, - endLine: match.endLine, - score: match.score, - strategy: "loose_escape", - }; - if (!bestLooseMatch || candidate.score > bestLooseMatch.score) { - bestLooseMatch = candidate; - } - } - - if (bestLooseMatch && bestLooseMatch.score >= MIN_FUZZY_SCORE) { - return expandClosestMatch(raw, lineIndex, scope, bestLooseMatch); - } - } - - const targetLineCount = Math.max(1, oldString.split(/\r?\n/).length); - const windowSizes = Array.from(new Set([Math.max(1, targetLineCount - 1), targetLineCount, targetLineCount + 1])); - const normalizedTarget = normalizeLooseText(oldString); - - let bestMatch: ClosestMatch | null = null; - for (let startLine = scope.startLine; startLine <= scope.endLine; startLine += 1) { - for (const windowSize of windowSizes) { - const endLine = startLine + windowSize - 1; - if (endLine > scope.endLine) { - continue; - } - - const candidateText = sliceLines(raw, lineIndex, startLine, endLine); - const score = similarityScore(normalizedTarget, normalizeLooseText(candidateText)); - if (score < MIN_FUZZY_SCORE) { - continue; - } - - const candidate: ClosestMatch = { - text: candidateText, - startLine, - endLine, - score, - strategy: "fuzzy_window", - }; - - if (!bestMatch || candidate.score > bestMatch.score) { - bestMatch = candidate; - } - } - } - - return bestMatch ? expandClosestMatch(raw, lineIndex, scope, bestMatch) : null; -} - -function expandClosestMatch( - raw: string, - lineIndex: LineIndex, - scope: SearchScope, - closestMatch: ClosestMatch -): ClosestMatch { - const startLine = clamp(closestMatch.startLine - CLOSEST_MATCH_CONTEXT_LINES, scope.startLine, scope.endLine); - const endLine = clamp(closestMatch.endLine + CLOSEST_MATCH_CONTEXT_LINES, startLine, scope.endLine); - return { - ...closestMatch, - text: sliceLines(raw, lineIndex, startLine, endLine), - startLine, - endLine, - }; -} - function buildLooseEscapeRegex(source: string): RegExp | null { if (!source) { return null; @@ -726,6 +628,91 @@ function buildLooseEscapeRegex(source: string): RegExp | null { return new RegExp(pattern, "g"); } +async function inferOldStringNotFoundReasonWithLLM( + raw: string, + lineIndex: LineIndex, + scope: SearchScope, + oldString: string, + newString: string, + context: ToolExecutionContext +): Promise { + const clientFactory = context.createOpenAIClient; + if (!clientFactory) { + return null; + } + + const { client, model, baseURL, thinkingEnabled, reasoningEffort } = clientFactory(); + if (!client) { + return null; + } + + const contextLineLimit = Math.max(1, oldString.split(/\r?\n/).length); + const snippetText = raw.slice(scope.startOffset, scope.endOffset); + const contentBeforeSnippet = getLinesBeforeScope(lineIndex, scope, contextLineLimit); + const contentAfterSnippet = getLinesAfterScope(lineIndex, scope, contextLineLimit); + + try { + const response = await client.chat.completions.create({ + model, + messages: [ + { + role: "system", + content: + "You diagnose failed file edits when old_string was not found. " + + "Return XML only using .... " + + "Be concise and specific. Explain the likely mismatch between old_string and the content. " + + "Do not suggest unrelated changes.", + }, + { + role: "user", + content: + "\n" + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + ` \n` + + "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + "", + }, + ], + ...buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort), + }); + + return parseOldStringNotFoundReason(response.choices?.[0]?.message?.content ?? ""); + } catch { + return null; + } +} + +function getLinesBeforeScope(lineIndex: LineIndex, scope: SearchScope, lineLimit: number): string { + const startIndex = Math.max(0, scope.startLine - 1 - lineLimit); + const endIndex = Math.max(0, scope.startLine - 1); + return lineIndex.lines.slice(startIndex, endIndex).join("\n"); +} + +function getLinesAfterScope(lineIndex: LineIndex, scope: SearchScope, lineLimit: number): string { + const startIndex = Math.min(lineIndex.lines.length, scope.endLine); + const endIndex = Math.min(lineIndex.lines.length, startIndex + lineLimit); + return lineIndex.lines.slice(startIndex, endIndex).join("\n"); +} + +function parseOldStringNotFoundReason(content: string): string | null { + const trimmed = content.trim(); + if (!trimmed) { + return null; + } + + const normalized = trimmed.replace(/```(?:xml)?\s*([\s\S]*?)```/i, "$1").trim(); + const reasonMatch = normalized.match(/(?:|([\s\S]*?))<\/reason>/i); + const reason = (reasonMatch?.[1] ?? reasonMatch?.[2])?.trim(); + return reason || null; +} + async function correctEscapedStringsWithLLM( snippetText: string, oldString: string, @@ -878,9 +865,3 @@ function toBigrams(value: string): string[] { } return result; } - -function sliceLines(raw: string, lineIndex: LineIndex, startLine: number, endLine: number): string { - const startOffset = lineIndex.lineStarts[startLine]; - const endOffset = lineIndex.lineStarts[endLine + 1]; - return raw.slice(startOffset, endOffset); -} diff --git a/src/tools/executor.ts b/packages/core/src/tools/executor.ts similarity index 76% rename from src/tools/executor.ts rename to packages/core/src/tools/executor.ts index 70ceab13..6af57c4c 100644 --- a/src/tools/executor.ts +++ b/packages/core/src/tools/executor.ts @@ -1,5 +1,3 @@ -import type OpenAI from "openai"; -import type { ReasoningEffort } from "../settings"; import { handleAskUserQuestionTool } from "./ask-user-question-handler"; import { handleBashTool } from "./bash-handler"; import { handleEditTool } from "./edit-handler"; @@ -8,72 +6,35 @@ import { handleUpdatePlanTool } from "./update-plan-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; import type { McpManager } from "../mcp/mcp-manager"; - -export type CreateOpenAIClient = () => { - client: OpenAI | null; - model: string; - baseURL?: string; - thinkingEnabled: boolean; - reasoningEffort?: ReasoningEffort; - debugLogEnabled?: boolean; - notify?: string; - webSearchTool?: string; - env?: Record; - machineId?: string; -}; - -export type ToolCall = { - id: string; - type: "function"; - function: { - name: string; - arguments: string; - }; -}; - -export type ToolExecutionContext = { - sessionId: string; - projectRoot: string; - toolCall: ToolCall; - createOpenAIClient?: CreateOpenAIClient; - onProcessStart?: (processId: string | number, command: string) => void; - onProcessExit?: (processId: string | number) => void; - onProcessStdout?: (processId: string | number, chunk: string) => void; -}; - -export type ToolExecutionHooks = { - onProcessStart?: (processId: string | number, command: string) => void; - onProcessExit?: (processId: string | number) => void; - onProcessStdout?: (processId: string | number, chunk: string) => void; - shouldStop?: () => boolean; -}; - -export type ToolExecutionResult = { - ok: boolean; - name: string; - output?: string; - error?: string; - metadata?: Record; - awaitUserResponse?: boolean; - followUpMessages?: ToolExecutionFollowUpMessage[]; -}; - -export type ToolExecutionFollowUpMessage = { - role: "system"; - content: string; - contentParams?: unknown | null; -}; - -export type ToolHandler = ( - args: Record, - context: ToolExecutionContext -) => Promise; - -export type ToolCallExecution = { - toolCallId: string; - content: string; - result: ToolExecutionResult; -}; +import type { + CreateOpenAIClient, + ToolCall, + ToolExecutionHooks, + ToolExecutionResult, + ToolHandler, + ToolCallExecution, +} from "../common/tool-types"; + +export type { + CreateOpenAIClient, + ToolCall, + ToolExecutionContext, + ToolExecutionHooks, + ToolExecutionResult, + ToolHandler, + ToolCallExecution, + ProcessTimeoutInfo, + ProcessTimeoutControl, + BackgroundProcessCompletion, + ToolExecutionFollowUpMessage, +} from "../common/tool-types"; + +const BUILT_IN_TOOL_NAME_ALIASES = new Map([ + ["Bash", "bash"], + ["Read", "read"], + ["Write", "write"], + ["Edit", "edit"], +]); export class ToolExecutor { private readonly projectRoot: string; @@ -167,9 +128,9 @@ export class ToolExecutor { hooks?: ToolExecutionHooks ): Promise { const toolName = toolCall.function.name; - const handler = this.toolHandlers.get(toolName); + const handlerName = BUILT_IN_TOOL_NAME_ALIASES.get(toolName) ?? toolName; + const handler = this.toolHandlers.get(handlerName); if (!handler) { - // Try MCP tools if (this.mcpManager?.isMcpTool(toolName)) { const parsedArgs = this.parseToolArguments(toolCall.function.arguments); const args = parsedArgs.ok ? parsedArgs.args : {}; @@ -200,6 +161,10 @@ export class ToolExecutor { onProcessStart: hooks?.onProcessStart, onProcessExit: hooks?.onProcessExit, onProcessStdout: hooks?.onProcessStdout, + onProcessTimeoutControl: hooks?.onProcessTimeoutControl, + onBackgroundProcessComplete: hooks?.onBackgroundProcessComplete, + onBeforeFileMutation: hooks?.onBeforeFileMutation, + onAfterFileMutation: hooks?.onAfterFileMutation, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/tools/read-handler.ts b/packages/core/src/tools/read-handler.ts similarity index 97% rename from src/tools/read-handler.ts rename to packages/core/src/tools/read-handler.ts index 964cdd72..3771a7e0 100644 --- a/src/tools/read-handler.ts +++ b/packages/core/src/tools/read-handler.ts @@ -3,7 +3,13 @@ import * as path from "path"; import ignore from "ignore"; import type { ToolExecutionContext, ToolExecutionFollowUpMessage, ToolExecutionResult } from "./executor"; import { readTextFileWithMetadata } from "../common/file-utils"; -import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "../common/state"; +import { + createFullFileSnippet, + createSnippet, + isAbsoluteFilePath, + markFileRead, + normalizeFilePath, +} from "../common/state"; const DEFAULT_LINE_LIMIT = 2000; const MAX_LINE_LENGTH = 2000; @@ -249,13 +255,9 @@ export async function handleReadTool( encoding: textResult.encoding, lineEndings: textResult.lineEndings, }); - const snippet = createSnippet( - context.sessionId, - filePath, - textResult.startLine, - textResult.endLine, - textResult.output - ); + const snippet = textResult.isPartialView + ? createSnippet(context.sessionId, filePath, textResult.startLine, textResult.endLine, textResult.output) + : createFullFileSnippet(context.sessionId, filePath, textResult.startLine, textResult.endLine, textResult.output); return { ok: true, name: "read", diff --git a/src/tools/update-plan-handler.ts b/packages/core/src/tools/update-plan-handler.ts similarity index 92% rename from src/tools/update-plan-handler.ts rename to packages/core/src/tools/update-plan-handler.ts index 7c7198ea..11439784 100644 --- a/src/tools/update-plan-handler.ts +++ b/packages/core/src/tools/update-plan-handler.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { executeValidatedTool } from "../common/runtime"; +import { executeValidatedTool } from "../common/validate"; const updatePlanSchema = z.strictObject({ plan: z.string().trim().min(1, "plan must not be empty."), diff --git a/src/tools/web-search-handler.ts b/packages/core/src/tools/web-search-handler.ts similarity index 100% rename from src/tools/web-search-handler.ts rename to packages/core/src/tools/web-search-handler.ts diff --git a/src/tools/write-handler.ts b/packages/core/src/tools/write-handler.ts similarity index 97% rename from src/tools/write-handler.ts rename to packages/core/src/tools/write-handler.ts index 153c1c63..35ecdb2d 100644 --- a/src/tools/write-handler.ts +++ b/packages/core/src/tools/write-handler.ts @@ -9,7 +9,7 @@ import { readTextFileWithMetadata, writeTextFile, } from "../common/file-utils"; -import { executeValidatedTool } from "../common/runtime"; +import { executeValidatedTool } from "../common/validate"; import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "../common/state"; const writeSchema = z.strictObject({ @@ -97,7 +97,9 @@ export async function handleWriteTool( const encoding = existingMetadata?.encoding ?? "utf8"; const lineEndings = existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : "LF"); const diffPreview = buildDiffPreview(filePath, existingMetadata?.content ?? null, normalizedContent); + context.onBeforeFileMutation?.(filePath); const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings); + context.onAfterFileMutation?.(filePath); const freshMetadata = readTextFileWithMetadata(filePath); recordFileState( diff --git a/templates/prompts/init_command.md.ejs b/packages/core/templates/prompts/init_command.md.ejs similarity index 93% rename from templates/prompts/init_command.md.ejs rename to packages/core/templates/prompts/init_command.md.ejs index ec2f2f69..c8a22ce7 100644 --- a/templates/prompts/init_command.md.ejs +++ b/packages/core/templates/prompts/init_command.md.ejs @@ -1,7 +1,7 @@ <% if (agentsMdFile == null) { %> Generate a file named ./AGENTS.md that serves as a contributor guide for this repository. <% } else { %> -Update <%= agentsMdFile %> that serves as a contributor guide for this repository. +Update <%= agentsMdFile %> to align it with repository changes made after the last time <%= agentsMdFile %> was modified. <% } %> Your goal is to produce a clear, concise, and well-structured document with descriptive headings and actionable explanations for each section. Follow the outline below, but adapt as needed — add sections if relevant, and omit those that do not apply to this project. diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/SKILL.md b/packages/core/templates/skills/bundled/deepcode-self-refer/SKILL.md new file mode 100644 index 00000000..5a8b377c --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/SKILL.md @@ -0,0 +1,145 @@ +--- +name: deepcode-self-refer +description: Answers questions about Deep Code CLI itself — including features, configuration options, slash commands, Skills, MCP integration, permissions, notifications, session persistence, and troubleshooting. Use this when users ask how to configure or use Deep Code, how to set up an MCP server, configure notifications (such as Slack/Feishu), manage permissions, view available skills, understand slash commands, configure thinking mode, etc. +--- + +# Deep Code Self-Refer + +This Skill helps you answer user questions about Deep Code CLI itself by consulting the reference documentation bundled with this Skill. All docs live in the `references/` subdirectory — always refer to them for authoritative answers. + +## When to use this Skill + +Use this Skill when the user asks any question about Deep Code itself, such as: + +- "列出可用的 skills" +- "如何配置 MCP?" +- "给当前项目配置 playwright mcp" +- "怎么启用搜索功能?" +- "支持哪些模型?" +- "如何配置思考模式?" +- "怎么设置权限?" +- "任务完成后怎么发通知?" +- "支持哪些斜杠命令?" +- "会话历史保存在哪里?" +- "/undo 是怎么工作的?" +- "Deep Code 和 VSCode 插件怎么配合?" +- Any other question about Deep Code CLI's features, configuration, or usage. + +## Instructions + +### Step 1: Identify the topic + +Map the user's question to the appropriate document(s): + +| Topic | Document | Key contents | +| ----------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **Overview, features, quick start** | `references/README.md` | Installation, slash commands, keyboard shortcuts, supported models, FAQ | +| **Configuration & settings** | `references/configuration.md` | `settings.json` fields, config hierarchy, env vars, thinking mode, reasoning effort, webSearchTool, enabledSkills | +| **MCP setup & usage** | `references/mcp.md` | MCP server config format, GitHub/Playwright/Filesystem examples, tool naming (`mcp____`), troubleshooting | +| **Permissions** | `references/permission.md` | Permission scopes (10 types), allow/deny/ask/defaultMode config, priority rules, persistence | +| **Notifications** | `references/notify.md` | Notify script path, injected env vars, Slack/Feishu/iTerm2/macOS/Linux/Windows examples | +| **Session persistence** | `references/session-persistence.md` | Storage paths, JSONL format, session index, compaction, `/undo` mechanics, code snapshots | + +### Step 2: Read the relevant document(s) + +Use the `Read` tool to read the appropriate document(s) from the list above. All paths are relative to this Skill's loaded root directory, where the `references/` subdirectory lives. + +- If the question spans multiple topics, read multiple documents. +- If a document doesn't exist in the user's preferred language (e.g., Chinese), try the other language variant (e.g., `references/configuration_en.md`). +- When answering from references/README.md, focus on the relevant sections. + +### Step 3: Answer with precision + +- **Quote the doc directly** for config examples, JSON snippets, or command syntax. +- **Don't guess** — if the answer isn't in the docs, say so and suggest checking GitHub Issues. +- **Provide copy-paste-ready configurations** when the user asks to set something up (e.g., MCP servers, notify scripts, permissions). +- **Mention related docs** when appropriate (e.g., MCP setup references `references/mcp.md`, the permissions section references `references/permission.md`). + +### Step 4: Handle common request patterns + +**"列出/查看可用的 skills":** + +- Treat `/skills` as the canonical UI for listing currently available skills. +- If answering directly, do not infer the list only from loaded skill prompts or from project/user directories. Enumerate all discovery roots: + 1. `./.deepcode/skills//SKILL.md` + 2. `./.agents/skills//SKILL.md` + 3. `~/.deepcode/skills//SKILL.md` + 4. `~/.agents/skills//SKILL.md` + 5. bundled built-in skills as `bundled:/SKILL.md` +- For a source checkout, bundled skills live under `templates/skills/bundled//SKILL.md`. For a packaged install, bundled skills may live under `dist/bundled//SKILL.md`. +- Read each candidate `SKILL.md` frontmatter to get the resolved `name` and `description`; the folder name is only a fallback. +- De-duplicate by resolved `name`, keeping the highest-priority root from the order above. +- Apply `enabledSkills` from `settings.json`: if `enabledSkills[""] === false`, do not list that skill as available. +- Clearly separate discoverable skills from other concepts: + - Discoverable skills are selectable through `/skills` and come from the roots above. + - Bundled skills are discoverable skills shipped with Deep Code, such as `bundled:deepcode-self-refer/SKILL.md`. + - Default prompt templates or always-injected guidance are not necessarily discoverable skills unless they also exist as `*/SKILL.md` in one of the scan roots. + - Slash commands such as `/skills`, `/mcp`, and `/undo` are commands, not skills. +- Mention that `/skills` can be used to verify the result and `enabledSkills` can enable/disable specific skills by name. + +**"配置 MCP":** + +- Read `references/mcp.md` for the MCP format and examples +- Ask the user for any required credentials (e.g., GitHub token) +- Provide the exact `mcpServers` JSON block to add to `settings.json` +- Mention using `/mcp` to verify the setup afterwards + +**"如何配置/修改 <设置项>":** + +- Read `references/configuration.md` +- Explain which `settings.json` field controls the setting +- Clarify user-level (`~/.deepcode/settings.json`) vs project-level (`.deepcode/settings.json`) +- Provide the exact JSON snippet + +**"<斜杠命令> 是做什么的?":** + +- Read the slash command table from references/README.md +- Provide a brief explanation with any additional context from relevant docs + +### Best practices + +1. **Always consult the docs first** — never answer from memory alone; the docs are the source of truth. +2. **Provide copy-paste-ready JSON** — users want to copy config blocks directly into their `settings.json`. +3. **Be specific about file paths** — always specify whether it's `~/.deepcode/settings.json` or `.deepcode/settings.json`. +4. **Mention `/mcp` verification** — after any MCP configuration change, remind users to use `/mcp` to verify. +5. **Acknowledge both Chinese and English docs** — the project has docs in both languages (`references/xxx.md` for Chinese, `references/xxx_en.md` for English). + +## Examples + +### Example 1: "列出可用的skills" + +Read references/README.md, locate the Skills section, then enumerate all scan roots including bundled skills. Answer: + +- Skills are discovered from: `./.deepcode/skills/`, `./.agents/skills/`, `~/.deepcode/skills/`, `~/.agents/skills/`, and bundled built-in skills such as `bundled:deepcode-self-refer/SKILL.md`. +- In a source checkout, check `templates/skills/bundled/*/SKILL.md`; in a packaged install, check `dist/bundled/*/SKILL.md`. +- Built-in bundled skills may include `deepcode-self-refer`, `plan`, `skill-digester`, and `skill-writer`; verify the actual list by scanning the bundled root because it can change between versions. +- Use `/skills` slash command in the Deep Code CLI to list all available skills +- Use `enabledSkills` in `settings.json` to enable/disable skills by name + +### Example 2: "给当前项目配置playwright mcp" + +Read `references/mcp.md`, locate the Playwright example. Answer: + +- Add to `settings.json` (user-level `~/.deepcode/settings.json` or project-level `.deepcode/settings.json`): + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +- If merging with existing config, add the `"playwright"` entry into the existing `mcpServers` object +- After saving, use `/mcp` in Deep Code to verify the server is running + +### Example 3: "怎么设置通知到Slack?" + +Read `references/notify.md`, locate the Slack section. Answer with the script + config. + +### Example 4: "如何只允许AI读写当前目录?" + +Read `references/permission.md`, locate the strict mode example. Provide the exact JSON. diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md new file mode 100644 index 00000000..9a4e27e0 --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/README.md @@ -0,0 +1,210 @@ +
+
+
+

+ + deepcode-cli + +

+

Deep Code CLI

+ +[![][npm-release-shield]][npm-release-link] [![][npm-downloads-shield]][npm-downloads-link] [![][github-contributors-shield]][github-contributors-link] [![][github-forks-shield]][github-forks-link] [![][github-stars-shield]][github-stars-link] +[![][github-issues-shield]][github-issues-link] [![][github-issues-pr-shield]][github-issues-pr-link] [![][github-license-shield]][github-license-link] + +[English](README-en.md) · 中文 + +
+
+ +[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制、Agent Skills 以及 MCP 集成。 + +## 安装 + +```bash +npm install -g @vegamo/deepcode-cli +``` + +在任意项目目录下运行 `deepcode` 即可启动。 + +![intro2](resources/intro2.png) + +## 配置 + +创建 `~/.deepcode/settings.json` 文件,内容如下: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 + +完整配置说明(多层级优先级、环境变量等)请参阅 [docs/configuration.md](docs/configuration.md)。 + +## 主要功能 + +### **Skills** + +Deep Code CLI 支持 agent skills,允许您扩展助手的能力: + +Skills 会按以下优先级扫描: + +| Scope | Path | Purpose | +| :------ | :------------------------- | :-------------------------------- | +| Project | `./.deepcode/skills/` | Deep Code 原生位置,最高优先级 | +| Project | `./.agents/skills/` | 跨客户端互操作 | +| User | `~/.deepcode/skills/` | Deep Code 原生位置 | +| User | `~/.agents/skills/` | 跨客户端互操作 | +| Bundled | `bundled:/SKILL.md` | Deep Code 内置 skills,最低优先级 | + +### **为 DeepSeek 优化** + +- 专门为 DeepSeek 模型性能调优。 +- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 +- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 + +## 斜杠命令与按键功能 + +| 斜杠命令 | 操作 | +| ----------- | -------------------------------------------- | +| `/` | 打开 skills / 命令菜单 | +| `/new` | 开始新对话 | +| `/resume` | 选择历史对话继续 | +| `/continue` | 继续当前对话,或选择历史对话恢复 | +| `/model` | 切换模型、思考模式和推理强度 | +| `/raw` | 切换显示模式(Normal / Lite / Raw 滚动回溯) | +| `/init` | 初始化 AGENTS.md 文件 | +| `/skills` | 列出可用 skills | +| `/mcp` | 查看 MCP 服务器状态和可用工具 | +| `/undo` | 将代码和/或对话恢复到之前的状态 | +| `/exit` | 退出(也可用连续 `Ctrl+D`) | + +| 按键 | 操作 | +| ------------- | --------------------------- | +| `Enter` | 发送消息 | +| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | +| `Ctrl+V` | 从剪贴板粘贴图片 | +| `Esc` | 中断当前模型回复 | +| 连续 `Ctrl+D` | 退出 | + +## 支持的模型 + +- `deepseek-v4-pro`(推荐使用) +- `deepseek-v4-flash` +- 任何其他 OpenAI 兼容模型 + +## 常见问题 + +### Deep Code 是否有 VSCode 插件? + +有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 + +### Deep Code 是否支持理解图片? + +Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 deepseek-v4 不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的 Doubao-Seed-2.0-pro 模型,适配效果最好。 + +### 怎样在任务完成后自动给 Slack 发消息? + +编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤请参考 [docs/notify.md](docs/notify.md)。 + +### 怎样启用联网搜索功能? + +Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli + +### 如何配置 MCP? + +Deep Code 支持 MCP(Model Context Protocol),可以连接 GitHub、浏览器、数据库等外部服务。在 `settings.json` 中配置 `mcpServers` 字段即可启用,启动后使用 `/mcp` 命令查看已配置的 MCP 服务器状态和可用工具。 + +详细配置指南:[docs/mcp.md](docs/mcp.md) + +### 如何配置 Deep Code 任务完成后发送通知? + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +详细配置指南:[docs/notify.md](docs/notify.md) + +### Deep Code 只支持 YOLO 模式吗? + +不是。Deep Code 内置了细粒度的权限控制机制,支持在 AI 助手执行 Shell 命令、读写文件、访问网络等操作前进行确认。你可以通过 `settings.json` 中的 `permissions` 字段按需配置每种权限范围的策略:始终允许、始终询问、或直接拒绝。详见 [docs/permission.md](docs/permission.md)。 + +### 是否支持 Coding Plan? + +支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: + +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` + +## 贡献 + +欢迎贡献代码!以下是参与方式: + +```bash +# 克隆仓库 +git clone https://github.com/lessweb/deepcode-cli.git +cd deepcode-cli + +# 安装依赖 +npm install + +# 本地开发(类型检查 + lint + 格式检查 + 构建) +npm run build + +# 运行测试 +npm test + +# 链接到全局(即本地全局安装) +npm link +``` + +- 提交 PR 前请确保 `npm run check` 通过(类型检查 + lint + 格式检查) +- 建议在执行构建前,先执行 `npm run format` 自动格式化代码,避免构建报错 + +## 获取帮助 + +- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) + +## 协议 + +- MIT + +## 支持我们 + +如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: + +- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) +- 向我们提交反馈和建议 +- 分享给你的朋友和同事 + + + +[npm-release-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-release-shield]: https://img.shields.io/npm/v/@vegamo/deepcode-cli?color=4d6BFE&labelColor=black&logo=npm&logoColor=white&style=flat-square&cacheSeconds=1800 +[npm-downloads-link]: https://www.npmjs.com/package/@vegamo/deepcode-cli +[npm-downloads-shield]: https://img.shields.io/npm/dt/@vegamo/deepcode-cli?labelColor=black&style=flat-square&color=4d6BFE&cacheSeconds=1800 +[github-contributors-link]: https://github.com/lessweb/deepcode-cli/graphs/contributors +[github-contributors-shield]: https://img.shields.io/github/contributors/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-forks-link]: https://github.com/lessweb/deepcode-cli/network/members +[github-forks-shield]: https://img.shields.io/github/forks/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-stars-link]: https://github.com/lessweb/deepcode-cli/network/stargazers +[github-stars-shield]: https://img.shields.io/github/stars/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-link]: https://github.com/lessweb/deepcode-cli/issues +[github-issues-shield]: https://img.shields.io/github/issues/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-issues-pr-link]: https://github.com/lessweb/deepcode-cli/pulls +[github-issues-pr-shield]: https://img.shields.io/github/issues-pr/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 +[github-license-link]: https://github.com/lessweb/deepcode-cli/blob/main/LICENSE +[github-license-shield]: https://img.shields.io/github/license/lessweb/deepcode-cli?color=4d6BFE&labelColor=black&style=flat-square&cacheSeconds=1800 diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/configuration.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/configuration.md new file mode 100644 index 00000000..ad437ab8 --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/configuration.md @@ -0,0 +1,217 @@ +# Deep Code 配置 + +## 配置层级 + +配置按以下优先级顺序应用(数字较小的会被数字较大的覆盖): + +| 层级 | 配置来源 | 说明 | +| ---- | ------------ | ------------------------ | +| 1 | 默认值 | 应用程序内硬编码的默认值 | +| 2 | 用户设置文件 | 当前用户的全局设置 | +| 3 | 项目设置文件 | 项目特定的设置 | +| 4 | 环境变量 | 系统范围或会话特定的变量 | + +## 设置文件 + +Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两个层级的存放位置: + +| 文件类型 | 位置 | 作用范围 | +| ------------ | ------------------------------------ | --------------------------------------------------------------- | +| 用户设置文件 | `~/.deepcode/settings.json` | 适用于当前用户的所有 Deep Code 会话。 | +| 项目设置文件 | `项目根目录/.deepcode/settings.json` | 仅在该特定项目中运行 Deep Code 时生效。项目设置会覆盖用户设置。 | + +### `settings.json` 中的可用设置 + +以下是 `settings.json` 支持的全部顶层字段,以及 `env` 内部支持的子字段: + +| 字段 | 类型 | 说明 | +| ------------------ | ------- | ------------------------------------------------------- | +| `env` | object | 环境变量分组(见下方子字段表) | +| `model` | string | 模型名称。优先级高于 `env.MODEL` | +| `thinkingEnabled` | boolean | 是否启用思考模式(DeepSeek V4 系列默认启用) | +| `reasoningEffort` | string | 推理强度,可选 `"high"` 或 `"max"`(默认 `"max"`) | +| `debugLogEnabled` | boolean | 是否启用调试日志输出(默认 `false`) | +| `telemetryEnabled` | boolean | 是否启用匿名使用数据上报(默认 `true`) | +| `notify` | string | 任务完成通知脚本的完整路径(如 Slack 通知脚本) | +| `webSearchTool` | string | 自定义联网搜索脚本的完整路径 | +| `mcpServers` | object | MCP 服务器配置(键为服务名,值为 McpServerConfig 对象) | +| `temperature` | number | 模型采样温度,范围 `0` 到 `2` | +| `enabledSkills` | object | 按 skill 名称启用或禁用 skill 的配置 | + +#### `env` 子字段 + +| 字段 | 类型 | 说明 | +| ------------------- | ------ | --------------------------------------------------------- | +| `MODEL` | string | 模型名称。例如 `"deepseek-v4-pro"`、`"deepseek-v4-flash"` | +| `BASE_URL` | string | API 请求的基础 URL。例如 `"https://api.deepseek.com"` | +| `API_KEY` | string | API 密钥 | +| `TEMPERATURE` | string | Chat Completions 采样温度,范围 `"0"` 到 `"2"` | +| `THINKING_ENABLED` | string | 是否启用思考模式 | +| `REASONING_EFFORT` | string | 推理强度 | +| `DEBUG_LOG_ENABLED` | string | 是否启用调试日志输出 | +| `TELEMETRY_ENABLED` | string | 是否启用匿名使用数据上报 | +| `<其他任意KEY>` | string | 自定义环境变量 | + +#### `thinkingEnabled` — 思考模式 + +是否启用 DeepSeek 思考模式。设置为 `true` 启用、`false` 禁用。 + +- 对于 `deepseek-v4-pro` 和 `deepseek-v4-flash`,思考模式**默认启用**。 +- 对于其他模型,思考模式**默认关闭**。 + +#### `reasoningEffort` — 推理强度 + +当思考模式启用时,控制模型思考的深度: + +| 值 | 说明 | +| ------ | ------------------------------- | +| `max` | 最大推理深度(默认值) | +| `high` | 较高推理深度,token消耗相对较小 | + +#### `notify` — 任务完成通知 + +设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。 + +通知脚本执行时,会通过环境变量注入以下上下文信息: + +| 环境变量 | 说明 | +| ------------- | ------------------------------------- | +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + +```json +{ + "notify": "/path/to/notify-script.sh" +} +``` + +> 详细的 Slack、飞书、终端通知、系统通知等配置示例,请参阅 [notify.md](notify.md)。 + +#### `webSearchTool` — 自定义联网搜索 + +Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径: + +```json +{ + "webSearchTool": "/path/to/my-search-script.sh" +} +``` + +脚本接收一个搜索查询参数,输出 JSON 格式的结果供 AI 使用。 + +#### `enabledSkills` — Skill 启用配置 + +控制 skill 扫描时是否包含指定 skill。键是解析后的 skill 名称,值必须是布尔值: + +```json +{ + "enabledSkills": { + "skill-writer": false, + "code-review": true + } +} +``` + +- 未配置的 skill 默认启用。 +- 将某个 skill 设置为 `false` 后,所有项目级和用户级目录中解析名称相同的 skill 都会被隐藏。 +- 项目设置会按 skill 覆盖用户设置。如果项目设置没有配置某个 skill,则使用用户设置。 + +#### `mcpServers` — MCP 服务器 + +MCP(Model Context Protocol)服务器配置。值是键值对,键为服务名称,值为服务器配置对象。 + +```json +{ + "mcpServers": { + "<服务名>": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +| McpServerConfig 字段 | 类型 | 必填 | 说明 | +| -------------------- | -------- | ---- | -------------------------------------------------- | +| `command` | string | 是 | 可执行文件路径或命令(如 `npx`、`node`、`python`) | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量 | + +> 当 `command` 为 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 + +详细 MCP 使用说明请参考 [mcp.md](mcp.md)。 + +#### `debugLogEnabled` — 调试日志 + +设为 `true` 可让程序输出详细的调试日志(默认 `false`),用于排查 API 调用和工具执行的问题。 + +#### `telemetryEnabled` — 匿名使用数据上报 + +设为 `false` 可关闭匿名使用数据上报(默认 `true`)。上报仅包含匿名的机器标识,不包含对话内容、代码或 API 密钥。 + +也可以通过环境变量关闭: + +```bash +DEEPCODE_TELEMETRY_ENABLED=0 deepcode +``` + +## 环境变量优先级 + +环境变量是配置应用程序的常用方式,尤其适用于敏感信息(如 api-key)或可能在不同环境之间更改的设置。 + +### 优先级原则 + +环境变量优先级遵循“越具体、越局部的配置,优先级越高”和“env文件默认保护现有环境,系统变量高于env文件”的覆盖逻辑。(settings.json的env对象可以认为是一种env文件) + +优先级层级 (由低到高) + +1. settings.json 外层的 env:这是针对整个工具及其所有子进程的通用配置(全局变量)。可被外层环境变量覆盖,但环境变量KEY会移除`DEEPCODE_`前缀。 +2. settings.json mcpServers 内定义的 env:这是针对特定 MCP 服务的最具体配置(局部变量)。可被外层环境变量覆盖,但环境变量KEY会移除`MCP_`前缀。 +3. Shell 环境系统变量:操作系统层面的环境变量。 + +### 场景 + +#### 一、设置模型的api_key, base_url + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以api_key为例): + +1. 硬编码默认值: `""` +2. 用户级settings.json: `{"env": {"API_KEY": "abc123"}}` +3. 项目级settings.json: `{"env": {"API_KEY": "abc123"}}` +4. 系统环境变量: `DEEPCODE_API_KEY=abc123 deepcode` + +#### 二、设置模型的model, thinkingEnabled, reasoningEffort + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以thinkingEnabled为例): + +1. 硬编码默认值: `true` +2. 用户级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +3. 用户级settings.json: `{"thinkingEnabled": true}` +4. 项目级settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +5. 项目级settings.json: `{"thinkingEnabled": true}` +6. 系统环境变量: `DEEPCODE_THINKING_ENABLED=true deepcode` + +#### 三、设置启动notify, webSearchTool等外挂脚本的环境变量 + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以notify为例): + +1. 硬编码默认值:`os.environ.get('WEBHOOK', '...') # notify脚本代码` +2. 用户级settings.json: `{"env": {"WEBHOOK": "..."}}` +3. 项目级settings.json: `{"env": {"WEBHOOK": "true"}}` +4. 系统环境变量: `DEEPCODE_WEBHOOK=... deepcode` + +#### 四、设置MCP Service的环境变量 + +按以下优先级顺序应用(数字较小的会被数字较大的覆盖)(以github MCP server为例): + +1. 用户级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +2. 用户级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +3. 项目级settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +4. 项目级settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +5. 系统环境变量: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/configuration_en.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/configuration_en.md new file mode 100644 index 00000000..2c58b5d0 --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/configuration_en.md @@ -0,0 +1,217 @@ +# Deep Code Configuration + +## Configuration Hierarchy + +Configuration is applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones): + +| Layer | Configuration Source | Description | +| ----- | --------------------- | ----------------------------------------- | +| 1 | Defaults | Hardcoded defaults within the application | +| 2 | User settings file | Global settings for the current user | +| 3 | Project settings file | Project-specific settings | +| 4 | Environment variables | System-wide or session-specific variables | + +## Settings File + +Deep Code uses the `settings.json` file for persistent configuration, supporting two storage locations: + +| File Type | Location | Scope | +| --------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| User settings file | `~/.deepcode/settings.json` | Applies to all Deep Code sessions for the current user. | +| Project settings file | `/.deepcode/settings.json` | Takes effect only when running Deep Code in that specific project. Project settings override user settings. | + +### Available Settings in `settings.json` + +The following are all the top-level fields supported in `settings.json`, along with the sub-fields inside `env`: + +| Field | Type | Description | +| ------------------ | ------- | -------------------------------------------------------------------------------------- | +| `env` | object | Group of environment variables (see sub-field table below) | +| `model` | string | Model name. Takes precedence over `env.MODEL` | +| `thinkingEnabled` | boolean | Whether to enable thinking mode (enabled by default for DeepSeek V4 series) | +| `reasoningEffort` | string | Reasoning intensity, either `"high"` or `"max"` (default `"max"`) | +| `debugLogEnabled` | boolean | Enable debug log output (default `false`) | +| `telemetryEnabled` | boolean | Enable anonymous usage reporting (default `true`) | +| `notify` | string | Full path to a task-completion notification script (e.g., Slack notification script) | +| `webSearchTool` | string | Full path to a custom web search script | +| `mcpServers` | object | MCP server configurations (keys are service names, values are McpServerConfig objects) | +| `temperature` | number | Sampling temperature for LLM, from `0` to `2` | +| `enabledSkills` | object | Per-skill enable/disable map, keyed by skill name | + +#### `env` Sub-fields + +| Field | Type | Description | +| ------------------- | ------ | -------------------------------------------------------------- | +| `MODEL` | string | Model name, e.g. `"deepseek-v4-pro"`, `"deepseek-v4-flash"` | +| `BASE_URL` | string | Base URL for API requests, e.g. `"https://api.deepseek.com"` | +| `API_KEY` | string | API key | +| `TEMPERATURE` | string | Sampling temperature for chat completions, from `"0"` to `"2"` | +| `THINKING_ENABLED` | string | Enable thinking mode | +| `REASONING_EFFORT` | string | Reasoning intensity | +| `DEBUG_LOG_ENABLED` | string | Enable debug log output | +| `TELEMETRY_ENABLED` | string | Enable anonymous usage reporting | +| `` | string | Custom environment variable | + +#### `thinkingEnabled` — Thinking Mode + +Whether to enable DeepSeek thinking mode. Set to `true` to enable, `false` to disable. + +- For `deepseek-v4-pro` and `deepseek-v4-flash`, thinking mode is **enabled by default**. +- For other models, thinking mode is **disabled by default**. + +#### `reasoningEffort` — Reasoning Intensity + +When thinking mode is enabled, controls the depth of the model’s reasoning: + +| Value | Description | +| ------ | -------------------------------------------------------- | +| `max` | Maximum reasoning depth (default) | +| `high` | Higher reasoning depth with relatively lower token usage | + +#### `notify` — Task Completion Notification + +Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message). + +The following context is injected as environment variables when the notify script runs: + +| Variable | Description | +| ------------- | ----------------------------------------------- | +| `DURATION` | Session duration in seconds (integer) | +| `STATUS` | Session status: `"completed"` or `"failed"` | +| `FAIL_REASON` | Failure reason (only set on failure) | +| `BODY` | The text content of the last AI assistant reply | +| `TITLE` | Session title (matches the resume list title) | + +```json +{ + "notify": "/path/to/notify-script.sh" +} +``` + +> For detailed configuration examples (Slack, Feishu, terminal notifications, system notifications, etc.), see [notify_en.md](notify_en.md). + +#### `webSearchTool` — Custom Web Search + +Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script: + +```json +{ + "webSearchTool": "/path/to/my-search-script.sh" +} +``` + +The script receives a search query as an argument and outputs results in JSON format for the AI. + +#### `enabledSkills` — Skill Enablement + +Controls whether skills are included during skill scanning. Keys are resolved skill names, and values must be booleans: + +```json +{ + "enabledSkills": { + "skill-writer": false, + "code-review": true + } +} +``` + +- Missing entries are enabled by default. +- Setting a skill to `false` hides every skill with that resolved `name`, across project and user skill roots. +- Project settings override user settings per skill. If the project setting omits a skill, the user setting is used. + +#### `mcpServers` — MCP Servers + +Configuration for MCP (Model Context Protocol) servers. The value is a key-value pair, where the key is the service name and the value is a server configuration object. + +```json +{ + "mcpServers": { + "": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +| McpServerConfig field | Type | Required | Description | +| --------------------- | -------- | -------- | --------------------------------------------------------- | +| `command` | string | Yes | Executable path or command (e.g. `npx`, `node`, `python`) | +| `args` | string[] | No | List of arguments passed to the command | +| `env` | object | No | Environment variables passed to the MCP server process | + +> When `command` is `npx`, Deep Code automatically prepends `-y` to the arguments. + +For detailed MCP usage instructions, refer to [mcp.md](mcp.md). + +#### `debugLogEnabled` — Debug Log + +Set to `true` to enable detailed debug logging (default `false`), useful for troubleshooting API calls and tool execution. + +#### `telemetryEnabled` — Anonymous Usage Reporting + +Set to `false` to disable anonymous usage reporting (default `true`). The report only includes an anonymous machine identifier and does not contain conversation content, code, or API keys. + +You can also disable it via environment variable: + +```bash +DEEPCODE_TELEMETRY_ENABLED=0 deepcode +``` + +## Environment Variable Priority + +Environment variables are a common way to configure applications, especially for sensitive information (such as api-key) or settings that may change between environments. + +### Priority Principle + +Environment variable priority follows the logic of “the more specific and localized the configuration, the higher the priority”, and the override rule of “env files protect existing environment by default, system variables override env files”. (The `env` object in settings.json can be thought of as a type of env file.) + +Priority levels (from lowest to highest): + +1. `env` defined at the top level of `settings.json` – this is a general configuration for the entire tool and all its subprocesses (global variables). Can be overridden by outer environment variables, but the environment variable KEY has the `DEEPCODE_` prefix removed. +2. `env` defined inside `mcpServers` in `settings.json` – this is the most specific configuration for a particular MCP service (local variables). Can be overridden by outer environment variables, but the KEY has the `MCP_` prefix removed. +3. Shell/system environment variables – operating system level. + +### Scenarios + +#### 1. Setting the model’s api_key and base_url + +Applied in the following priority order (lower-numbered sources are overridden by higher-numbered ones) – using api_key as an example: + +1. Hardcoded default: `""` +2. User-level settings.json: `{"env": {"API_KEY": "abc123"}}` +3. Project-level settings.json: `{"env": {"API_KEY": "abc123"}}` +4. System environment variable: `DEEPCODE_API_KEY=abc123 deepcode` + +#### 2. Setting model, thinkingEnabled, and reasoningEffort + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using thinkingEnabled as an example: + +1. Hardcoded default: `true` +2. User-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +3. User-level settings.json: `{"thinkingEnabled": true}` +4. Project-level settings.json: `{"env": {"THINKING_ENABLED": "true"}}` +5. Project-level settings.json: `{"thinkingEnabled": true}` +6. System environment variable: `DEEPCODE_THINKING_ENABLED=true deepcode` + +#### 3. Setting environment variables for external scripts like notify and webSearchTool + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using notify as an example: + +1. Hardcoded default: `os.environ.get('WEBHOOK', '...') # notify script code` +2. User-level settings.json: `{"env": {"WEBHOOK": "..."}}` +3. Project-level settings.json: `{"env": {"WEBHOOK": "true"}}` +4. System environment variable: `DEEPCODE_WEBHOOK=... deepcode` + +#### 4. Setting environment variables for an MCP Service + +Applied in the following priority order (lower-numbered overridden by higher-numbered) – using a GitHub MCP server as an example: + +1. User-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +2. User-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +3. Project-level settings.json: `{"mcpServers":{"github":{"env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"..."}}}}` +4. Project-level settings.json: `{"env": {"MCP_GITHUB_PERSONAL_ACCESS_TOKEN": "..."}}` +5. System environment variable: `DEEPCODE_MCP_GITHUB_PERSONAL_ACCESS_TOKEN=... deepcode` diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp.md new file mode 100644 index 00000000..73034a38 --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp.md @@ -0,0 +1,200 @@ +# Deep Code CLI MCP 配置指南 + +Deep Code CLI 支持 MCP(Model Context Protocol),让 AI 助手能够连接外部工具和服务,如 GitHub、浏览器、数据库等。 + +## 概述 + +配置 MCP 后,Deep Code 可以: + +- 操作 GitHub 仓库(查看 Issues、创建 PR、搜索代码等) +- 操控浏览器(截图、点击、填表单等) +- 访问文件系统 +- 连接数据库和 API +- ...以及任何兼容 MCP 协议的外部服务 + +MCP 工具在 Deep Code 中的命名格式为 `mcp__<服务名>__<工具名>`,例如 `mcp__github__search_code`。 + +## 配置 MCP 服务器 + +编辑 `~/.deepcode/settings.json`,添加 `mcpServers` 字段: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "<服务名称>": { + "command": "<可执行文件>", + "args": ["<参数1>", "<参数2>"], + "env": { + "<环境变量>": "<值>" + } + } + } +} +``` + +### 配置项说明 + +| 字段 | 类型 | 必填 | 说明 | +| --------- | -------- | ---- | ---------------------------------------------------------------------------------------------------------------------- | +| `command` | string | 是 | MCP 服务器的可执行文件路径或命令(如 `npx`、`node`、`python`)。当命令是 `npx` 时,Deep Code 会自动在参数前补充 `-y`。 | +| `args` | string[] | 否 | 传递给命令的参数列表 | +| `env` | object | 否 | 传递给 MCP 服务器进程的环境变量(如 API Key) | + +## 常用 MCP 示例 + +### GitHub MCP + +让 Deep Code 直接操作 GitHub 仓库(搜索代码、管理 Issue/PR、读写文件等): + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +> GitHub Personal Access Token 可在 [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens) 生成。 + +### 浏览器控制(Playwright) + +让 Deep Code 操控浏览器进行截图、页面操作等: + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +### 文件系统 + +让 Deep Code 在指定目录中读写文件: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"] + } + } +} +``` + +### 自定义 Python MCP + +```json +{ + "mcpServers": { + "my-tool": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "env": { + "API_KEY": "xxx" + } + } + } +} +``` + +## 完整配置示例 + +以下是一个配置了 GitHub 和 Playwright 两个 MCP 服务器的完整 `~/.deepcode/settings.json`: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-xxxxxxxxxxxx" + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + }, + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +## 使用 MCP + +配置完成后,启动 `deepcode`,在聊天中输入 `/mcp` 即可查看所有已配置的 MCP 服务器状态以及每个服务器提供的工具列表。 + +在对话中直接使用 MCP 工具名称即可调用,例如: + +``` +帮我搜索 GitHub 上 deepcode-cli 仓库的 issues +``` + +AI 会自动调用 `mcp__github__search_issues` 工具完成操作。 + +## 工具命名规则 + +MCP 工具名称由三部分组成:`mcp__<服务名>__<工具名>` + +| 服务名 | 工具名 | 完整调用名 | +| ---------- | ----------------------- | ------------------------------------------ | +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | + +你可以通过 `/mcp` 查看每个服务器提供的具体工具列表。 + +## 故障排查 + +### 启动失败 + +如果 MCP 服务器无法启动,检查: + +1. `command` 是否已安装(如 `npx` 需要 Node.js) +2. `env` 中的环境变量是否正确(如 `GITHUB_PERSONAL_ACCESS_TOKEN`) +3. 运行 `deepcode` 的终端是否有网络访问权限 + +### 工具不显示 + +1. 确认 `settings.json` 中的 `mcpServers` 字段格式正确 +2. 启动 deepcode 后使用 `/mcp` 查看服务器状态 +3. 如果服务器状态显示错误,根据错误信息排查 + +### Windows 用户 + +在 Windows 上,Deep Code CLI 会自动为 `.cmd` 命令添加 shell 支持。如果你的 MCP 命令是批处理脚本,确保文件名以 `.cmd` 结尾。 + +## 编写你自己的 MCP 服务器 + +MCP 服务器遵循 [Model Context Protocol](https://modelcontextprotocol.io/) 规范,使用 JSON-RPC 2.0 通信。你可以用任何语言编写 MCP 服务器,只要实现以下协议即可: + +1. `initialize` — 握手和协议协商 +2. `tools/list` — 返回可用工具列表 +3. `tools/call` — 执行工具调用 + +更多参考:[MCP 官方文档](https://modelcontextprotocol.io/) diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp_en.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp_en.md new file mode 100644 index 00000000..7933db6a --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/mcp_en.md @@ -0,0 +1,200 @@ +# Deep Code CLI MCP Configuration Guide + +Deep Code CLI supports MCP (Model Context Protocol), enabling AI assistants to connect with external tools and services such as GitHub, browsers, databases, and more. + +## Overview + +Once MCP is configured, Deep Code can: + +- Operate on GitHub repositories (view issues, create PRs, search code, etc.) +- Control browsers (screenshots, clicks, form filling, etc.) +- Access the file system +- Connect to databases and APIs +- ...and any external service compatible with the MCP protocol + +MCP tools are named in Deep Code using the format `mcp____`, for example `mcp__github__search_code`. + +## Configuring MCP Servers + +Edit `~/.deepcode/settings.json` and add the `mcpServers` field: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "": { + "command": "", + "args": ["", ""], + "env": { + "": "" + } + } + } +} +``` + +### Configuration Fields + +| Field | Type | Required | Description | +| --------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `command` | string | Yes | Path or command of the MCP server executable (e.g., `npx`, `node`, `python`). When the command is `npx`, Deep Code automatically prepends `-y` to the arguments. | +| `args` | string[] | No | List of arguments to pass to the command | +| `env` | object | No | Environment variables (e.g., API keys) to pass to the MCP server process | + +## Common MCP Examples + +### GitHub MCP + +Allows Deep Code to directly operate on GitHub repositories (search code, manage issues/PRs, read/write files, etc.): + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + } + } +} +``` + +> Generate a GitHub Personal Access Token at [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens). + +### Browser Control (Playwright) + +Lets Deep Code control a browser for screenshots, page interactions, etc.: + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +### File System + +Enables Deep Code to read and write files within a specified directory: + +```json +{ + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"] + } + } +} +``` + +### Custom Python MCP + +```json +{ + "mcpServers": { + "my-tool": { + "command": "python", + "args": ["-m", "my_mcp_server"], + "env": { + "API_KEY": "xxx" + } + } + } +} +``` + +## Full Configuration Example + +Below is a complete `~/.deepcode/settings.json` with both GitHub and Playwright MCP servers configured: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-xxxxxxxxxxxx" + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxxxxxxxxxx" + } + }, + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} +``` + +## Using MCP + +After configuration, start `deepcode` and type `/mcp` in the chat to view the status of all configured MCP servers and the list of tools each server provides. + +Simply use the MCP tool name in your conversation to invoke it, for example: + +``` +Help me search for issues in the deepcode-cli repository on GitHub +``` + +The AI will automatically invoke the `mcp__github__search_issues` tool to complete the action. + +## Tool Naming Convention + +An MCP tool name consists of three parts: `mcp____` + +| Service | Tool Name | Full Invocation Name | +| ---------- | ----------------------- | ------------------------------------------ | +| github | search_code | `mcp__github__search_code` | +| github | create_pull_request | `mcp__github__create_pull_request` | +| playwright | browser_navigate | `mcp__playwright__browser_navigate` | +| playwright | browser_take_screenshot | `mcp__playwright__browser_take_screenshot` | + +You can view the list of tools provided by each server using `/mcp`. + +## Troubleshooting + +### Startup Failure + +If an MCP server fails to start, check: + +1. Whether `command` is installed (e.g., `npx` requires Node.js) +2. Whether environment variables in `env` are correct (e.g., `GITHUB_PERSONAL_ACCESS_TOKEN`) +3. Whether the terminal running `deepcode` has network access + +### Tools Not Showing Up + +1. Verify that the `mcpServers` field in `settings.json` is correctly formatted +2. After starting deepcode, use `/mcp` to check server status +3. If the server status shows an error, debug based on the error message + +### Windows Users + +On Windows, Deep Code CLI automatically adds shell support for `.cmd` commands. If your MCP command is a batch script, ensure the filename ends with `.cmd`. + +## Writing Your Own MCP Server + +MCP servers follow the [Model Context Protocol](https://modelcontextprotocol.io/) specification and communicate using JSON‑RPC 2.0. You can write an MCP server in any language as long as it implements the following methods: + +1. `initialize` — Handshake and protocol negotiation +2. `tools/list` — Return the list of available tools +3. `tools/call` — Execute a tool call + +For more information, see the [official MCP documentation](https://modelcontextprotocol.io/). diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/notify.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/notify.md new file mode 100644 index 00000000..553722f3 --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/notify.md @@ -0,0 +1,211 @@ +# Deep Code 任务完成通知 + +当 AI 助手完成一轮任务后,Deep Code 可以自动执行一个通知脚本,将任务结果发送到你指定的渠道(如 Slack、系统通知等)。 + +## 工作原理 + +在 `settings.json` 中配置 `notify` 字段,指向一个可执行脚本的完整路径。每次 AI 助手完成任务应答后,Deep Code 会执行该脚本,并通过环境变量注入上下文信息。 + +## 注入的环境变量 + +| 环境变量 | 说明 | +| ------------- | ------------------------------------- | +| `DURATION` | 会话耗时,单位秒(整数) | +| `STATUS` | 会话状态:`"completed"` 或 `"failed"` | +| `FAIL_REASON` | 失败原因(仅失败时设置) | +| `BODY` | 最后一条 AI 助手回复的文本内容 | +| `TITLE` | 会话标题(对应 resume 列表中的标题) | + +## 配置方法 + +编辑 `~/.deepcode/settings.json`,添加 `notify` 字段: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "notify": "/path/to/your-notify-script.sh" +} +``` + +你也可以在 `env` 中配置通知脚本所需的自定义环境变量,例如 Slack Webhook URL: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-...", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +这些 `env` 中的变量会被注入到脚本的执行环境中。 + +## Slack 通知 + +### 1. 获取 Slack Webhook URL + +1. 创建 [Slack App](https://api.slack.com/apps) +2. 在 App 页面点击 **Incoming Webhooks** → **Add New Webhook to Workspace**,生成 Webhook URL + +### 2. 创建通知脚本 + +创建 `~/.deepcode/notify-slack.sh`: + +```bash +#!/usr/bin/env bash +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" +CURRENT_DIR=$(pwd) +BRANCH=$(git branch --show-current 2>/dev/null) +curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-type: application/json" \ + --data "{ + \"text\": \"✅ Deep Code 任务已完成\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION 秒\" + }" +``` + +给脚本添加可执行权限: + +```bash +chmod +x ~/.deepcode/notify-slack.sh +``` + +### 3. 配置 settings.json + +```json +{ + "env": { + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +> Python 版本的脚本同样支持,你可以在 `env` 中传入并引用任意自定义环境变量。 + +## 飞书 / 企业微信等 Webhook 通知 + +以下示例使用 `node` 构建 JSON(自动转义特殊字符),`curl` 发送。通过 `env` 传入 `WEBHOOK_URL`: + +```bash +#!/bin/bash +WEBHOOK_URL="${WEBHOOK_URL:-}" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") + +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +```json +{ + "env": { + "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + }, + "notify": "/Users/you/.deepcode/notify-feishu.sh" +} +``` + +将 `WEBHOOK_URL` 替换为你的飞书机器人 Webhook 地址。此模式同样适用于 Slack、企业微信等 webhook 类通知,只需修改 JSON payload 格式。 + +## 终端通知(iTerm2 / Windows Terminal) + +如果你的终端是 iTerm2 或 Windows Terminal,可以直接通过 OSC 9 转义序列弹出终端原生通知,无需额外依赖。 + +创建 `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# iTerm2 / Windows Terminal OSC 9 通知 +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +Windows 用户如使用 Git Bash,上述脚本同样可用;也可创建 `.bat` 脚本: + +```batch +@echo off +REM Windows Terminal OSC 9 通知 +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + +## macOS 系统通知 + +```bash +#!/bin/bash +# macOS 系统通知 +osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\"" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +## Linux 系统通知 + +需要安装 `libnotify-bin`: + +```bash +sudo apt install libnotify-bin # Debian/Ubuntu +``` + +创建 `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# Linux notify-send 通知 +notify-send "DeepCode" "任务已${STATUS:-完成},耗时 ${DURATION}s" +``` + +```json +{ + "notify": "/home/you/.deepcode/notify.sh" +} +``` + +## Windows msg 弹窗通知 + +```batch +@echo off +REM Windows msg 弹窗通知 +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + +```json +{ + "notify": "C:\\Users\\you\\.deepcode\\notify.bat" +} +``` + +## 自定义通知脚本 + +你可以根据通知脚本注入的环境变量自行编写任意逻辑的通知脚本(Python、Node.js、Ruby 等均可),只要脚本可执行即可。脚本中可通过 `env` 字段传入额外需要的配置变量。 diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/notify_en.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/notify_en.md new file mode 100644 index 00000000..90fcf706 --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/notify_en.md @@ -0,0 +1,211 @@ +# Deep Code Task Completion Notification + +When the AI assistant finishes a round of tasks, Deep Code can automatically execute a notification script to send task results to your chosen channel (Slack, system notifications, etc.). + +## How It Works + +Configure the `notify` field in `settings.json` with the full path to an executable script. Every time the AI assistant completes a task response, Deep Code executes that script and injects context as environment variables. + +## Injected Environment Variables + +| Variable | Description | +| ------------- | ----------------------------------------------- | +| `DURATION` | Session duration in seconds (integer) | +| `STATUS` | Session status: `"completed"` or `"failed"` | +| `FAIL_REASON` | Failure reason (only set on failure) | +| `BODY` | The text content of the last AI assistant reply | +| `TITLE` | Session title (matches the resume list title) | + +## Configuration + +Edit `~/.deepcode/settings.json` and add the `notify` field: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max", + "notify": "/path/to/your-notify-script.sh" +} +``` + +You can also configure custom environment variables for the notify script in `env`, such as a Slack Webhook URL: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-...", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +These `env` variables are injected into the script's execution environment. + +## Slack Notification + +### 1. Get a Slack Webhook URL + +1. Create a [Slack App](https://api.slack.com/apps) +2. In the App page, go to **Incoming Webhooks** → **Add New Webhook to Workspace** to generate a Webhook URL + +### 2. Create the Notification Script + +Create `~/.deepcode/notify-slack.sh`: + +```bash +#!/usr/bin/env bash +SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-}" +CURRENT_DIR=$(pwd) +BRANCH=$(git branch --show-current 2>/dev/null) +curl -X POST "$SLACK_WEBHOOK_URL" \ + -H "Content-type: application/json" \ + --data "{ + \"text\": \"✅ Deep Code task completed\n · cwd: $CURRENT_DIR\n · Branch: $BRANCH\n · Duration: $DURATION s\" + }" +``` + +Make the script executable: + +```bash +chmod +x ~/.deepcode/notify-slack.sh +``` + +### 3. Configure settings.json + +```json +{ + "env": { + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/*****/****/**********" + }, + "notify": "/Users/you/.deepcode/notify-slack.sh" +} +``` + +> A Python version is also supported; you can pass and reference any custom environment variables via `env`. + +## Feishu / WeCom Webhook Notification + +Use `node` to build JSON (auto-escapes special characters) and `curl` to send. Pass `WEBHOOK_URL` via `env`: + +```bash +#!/bin/bash +WEBHOOK_URL="${WEBHOOK_URL:-}" + +STATUS="${STATUS:-completed}" +TITLE="${TITLE:-Untitled}" +DURATION="${DURATION:-0}" +BODY="${BODY:-(no output)}" + +PAYLOAD=$(node -e " +process.stdout.write(JSON.stringify({ + msg_type: 'interactive', + card: { + header: { title: { tag: 'plain_text', content: 'DeepCode: ' + process.env.TITLE + ' ' + process.env.STATUS + ' [' + process.env.DURATION + 's]' } }, + elements: [{ tag: 'markdown', content: (process.env.BODY || '').slice(0, 2000) || '(no output)' }] + } +})) +") + +curl -s -X POST "$WEBHOOK_URL" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" +``` + +```json +{ + "env": { + "WEBHOOK_URL": "https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxxxx" + }, + "notify": "/Users/you/.deepcode/notify-feishu.sh" +} +``` + +Replace `WEBHOOK_URL` with your Feishu bot webhook URL. This pattern also works for other webhook-based notifications (Slack, WeCom, etc.) — just adjust the JSON payload format. + +## Terminal Notification (iTerm2 / Windows Terminal) + +On iTerm2 or Windows Terminal, you can use the OSC 9 escape sequence for native terminal notifications with zero dependencies. + +Create `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# iTerm2 / Windows Terminal OSC 9 notification +printf '\x1b]9;DeepCode: task %s (%ss)\x07' "${STATUS:-completed}" "${DURATION}" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +Windows users on Git Bash can use the same script; alternatively, create a `.bat` script: + +```batch +@echo off +REM Windows Terminal OSC 9 notification +echo \x1b]9;DeepCode: task %STATUS% (%DURATION%s)\x07 +``` + +## macOS System Notification + +```bash +#!/bin/bash +# macOS system notification +osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\"" +``` + +```json +{ + "notify": "/Users/you/.deepcode/notify.sh" +} +``` + +## Linux System Notification + +Requires `libnotify-bin`: + +```bash +sudo apt install libnotify-bin # Debian/Ubuntu +``` + +Create `~/.deepcode/notify.sh`: + +```bash +#!/bin/bash +# Linux notify-send notification +notify-send "DeepCode" "Task ${STATUS:-completed}, took ${DURATION}s" +``` + +```json +{ + "notify": "/home/you/.deepcode/notify.sh" +} +``` + +## Windows msg Popup Notification + +```batch +@echo off +REM Windows msg popup notification +msg %USERNAME% "DeepCode: task %STATUS% (%DURATION%s)" +``` + +```json +{ + "notify": "C:\\Users\\you\\.deepcode\\notify.bat" +} +``` + +## Custom Notification Scripts + +You can write your own notification scripts in any language (Python, Node.js, Ruby, etc.) using the injected environment variables and any additional variables passed via `env`. diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/permission.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/permission.md new file mode 100644 index 00000000..e315c47c --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/permission.md @@ -0,0 +1,101 @@ +# Deep Code 权限机制 + +Deep Code 内置了一套细粒度的权限控制机制,在 AI 助手执行工具调用(如执行 Shell 命令、读写文件、访问网络等)前,根据用户配置的策略决定是自动放行、直接拒绝、还是弹出交互式确认。 + +## 概述 + +每次 AI 助手调用工具时,系统会自动分析该操作涉及的**权限范围(Permission Scope)**,然后根据 `settings.json` 中的权限配置做出决策。对于需要用户确认的操作,会在终端中弹出交互式选择界面,用户可以选择: + +- **Yes** — 仅本次放行 +- **Yes, and always allow** — 本次放行,并将该权限范围写入项目配置文件,后续同类操作不再询问 +- **No** — 拒绝本次操作 + +## 权限范围 + +Deep Code 定义了以下 10 种权限范围,覆盖了工具调用的各类风险场景: + +| 权限范围 | 说明 | +| ---------------- | --------------------------------------------------------- | +| `read-in-cwd` | 读取当前工作区内的文件 | +| `read-out-cwd` | 读取当前工作区外的文件 | +| `write-in-cwd` | 在当前工作区内创建或覆写文件 | +| `write-out-cwd` | 在当前工作区外创建或覆写文件 | +| `delete-in-cwd` | 删除当前工作区内的文件 | +| `delete-out-cwd` | 删除当前工作区外的文件 | +| `query-git-log` | 查询 Git 历史(如 `git log`、`git show`、`git blame`) | +| `mutate-git-log` | 修改 Git 历史(如 `git commit`、`git rebase`、`git tag`) | +| `network` | 访问网络(如 `curl`、`npm install` 等联网操作) | +| `mcp` | 调用 MCP 外部工具 | + +此外还有一个特殊的 `unknown` 范围,当 LLM 无法准确分类命令的副作用时使用,**`unknown` 总是触发询问**。 + +## 权限配置 + +在 `~/.deepcode/settings.json`(用户级)或 `.deepcode/settings.json`(项目级)中通过 `permissions` 字段配置: + +```json +{ + "permissions": { + "allow": [], + "deny": [], + "ask": [], + "defaultMode": "allowAll" + } +} +``` + +### 配置项说明 + +| 字段 | 类型 | 说明 | +| ------------- | -------------------------- | --------------------------------------------------------------------------------- | +| `allow` | `string[]` | 始终自动放行的权限范围列表 | +| `deny` | `string[]` | 始终自动拒绝的权限范围列表 | +| `ask` | `string[]` | 始终弹出询问的权限范围列表 | +| `defaultMode` | `"allowAll"` \| `"askAll"` | 未在 `allow`/`deny`/`ask` 中明确列出的权限范围的默认处理方式。默认为 `"allowAll"` | + +### 优先级规则 + +当一个工具调用涉及多个权限范围时,决策按以下优先级进行: + +1. 若任一范围命中 `deny` → **拒绝** +2. 若任一范围命中 `ask` → **询问** +3. 若所有范围均在 `allow` 中 → **自动放行** +4. 否则 → 按 `defaultMode` 处理 + +### 示例:宽松模式(默认) + +```json +{ + "permissions": { + "defaultMode": "allowAll" + } +} +``` + +默认行为:所有操作自动放行,无需确认。 + +### 示例:严格模式 + +```json +{ + "permissions": { + "allow": ["read-in-cwd", "write-in-cwd", "query-git-log"], + "defaultMode": "askAll" + } +} +``` + +此配置的效果: + +- 工作区内读写、Git 查询 → 自动放行 +- 其他操作都需要用户确认。 + +## 持久化机制 + +当用户在权限提示中选择 "Yes, and always allow" 后,对应的权限范围会被写入当前项目的 `.deepcode/settings.json` 文件中: + +- 新增范围会追加到 `permissions.allow` 列表 +- 如果该范围之前存在于 `deny` 或 `ask` 中,会被自动移除 +- 不会重复写入已存在的范围 + +这样后续同类操作就不再询问。 diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/permission_en.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/permission_en.md new file mode 100644 index 00000000..1298a1d6 --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/permission_en.md @@ -0,0 +1,101 @@ +# Deep Code Permission Mechanism + +Deep Code includes a fine-grained permission control mechanism. Before the AI assistant executes a tool call (such as running a shell command, reading/writing files, accessing the network, etc.), the system determines whether to auto-allow, auto-deny, or prompt for interactive confirmation based on your configured policy. + +## Overview + +Each time the AI assistant invokes a tool, the system automatically analyzes the **permission scopes** involved and makes a decision based on the permission configuration in `settings.json`. For operations requiring user confirmation, an interactive prompt appears in the terminal with the following choices: + +- **Yes** — Allow this one time only +- **Yes, and always allow** — Allow this time and persistently save the scope to the project configuration so future calls skip the prompt +- **No** — Deny this operation + +## Permission Scopes + +Deep Code defines the following 10 permission scopes, covering various risk scenarios for tool calls: + +| Permission Scope | Description | +| ---------------- | ---------------------------------------------------------------- | +| `read-in-cwd` | Read files inside the current workspace | +| `read-out-cwd` | Read files outside the current workspace | +| `write-in-cwd` | Create or overwrite files inside the current workspace | +| `write-out-cwd` | Create or overwrite files outside the current workspace | +| `delete-in-cwd` | Delete files inside the current workspace | +| `delete-out-cwd` | Delete files outside the current workspace | +| `query-git-log` | Query Git history (e.g., `git log`, `git show`, `git blame`) | +| `mutate-git-log` | Mutate Git history (e.g., `git commit`, `git rebase`, `git tag`) | +| `network` | Access the network (e.g., `curl`, `npm install`) | +| `mcp` | Invoke MCP external tools | + +There is also a special `unknown` scope used when the LLM cannot classify a command's side effects — **`unknown` always triggers a prompt**. + +## Permission Configuration + +Configure permissions in `~/.deepcode/settings.json` (user-level) or `.deepcode/settings.json` (project-level) via the `permissions` field: + +```json +{ + "permissions": { + "allow": [], + "deny": [], + "ask": [], + "defaultMode": "allowAll" + } +} +``` + +### Configuration Fields + +| Field | Type | Description | +| ------------- | -------------------------- | --------------------------------------------------------------------------------------------------- | +| `allow` | `string[]` | Permission scopes that are always auto-allowed | +| `deny` | `string[]` | Permission scopes that are always auto-denied | +| `ask` | `string[]` | Permission scopes that always trigger a confirmation prompt | +| `defaultMode` | `"allowAll"` \| `"askAll"` | Default behavior for scopes not explicitly listed in `allow`/`deny`/`ask`. Defaults to `"allowAll"` | + +### Priority Rules + +When a tool call involves multiple permission scopes, the decision follows this priority: + +1. If any scope matches `deny` → **Deny** +2. If any scope matches `ask` → **Prompt** +3. If all scopes are in `allow` → **Auto-allow** +4. Otherwise → use `defaultMode` + +### Example: Relaxed Mode (default) + +```json +{ + "permissions": { + "defaultMode": "allowAll" + } +} +``` + +Default behavior: all operations are auto-allowed with no confirmation required. + +### Example: Strict Mode + +```json +{ + "permissions": { + "allow": ["read-in-cwd", "write-in-cwd", "query-git-log"], + "defaultMode": "askAll" + } +} +``` + +With this configuration: + +- Reading/writing inside the workspace and querying Git history → auto-allowed +- All other operations → require user confirmation + +## Persistence + +When you select "Yes, and always allow" in a permission prompt, the corresponding scope is written to the project's `.deepcode/settings.json`: + +- The scope is appended to the `permissions.allow` list +- If the scope was previously in `deny` or `ask`, it is automatically removed +- Duplicate scopes are not written again + +This means subsequent calls involving the same scope will no longer prompt for confirmation. diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/session-persistence.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/session-persistence.md new file mode 100644 index 00000000..1c629e4d --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/session-persistence.md @@ -0,0 +1,139 @@ +# 会话持久化机制 + +Deep Code 会把每个项目的会话记录保存在本机用户目录中。会话历史用于 `/resume`、`/continue` 和 `/undo`,不依赖当前终端进程是否仍在运行。 + +## 存储位置 + +每个项目都有独立的存储目录: + +```text +~/.deepcode/projects// +``` + +`` 由项目根目录路径生成。普通路径会转换为安全的目录名;路径过长时,Deep Code 会保留项目名的一部分,并追加稳定哈希,以避免存储路径过长。 + +项目存储目录包含以下主要文件和目录: + +| 路径 | 说明 | +| --------------------- | ------------------------------------------------------ | +| `sessions-index.json` | 当前项目的会话索引,保存会话列表和每个会话的概要信息。 | +| `.jsonl` | 单个会话的消息记录。每一行是一条 JSON 格式的消息。 | +| `file-history/.git` | 用于代码快照的内部 Git 仓库,供 `/undo` 恢复文件内容。 | + +## 持久化内容 + +### 会话索引 + +`sessions-index.json` 保存最近的会话条目。每个条目包含: + +- 会话 ID、标题、创建时间和更新时间。 +- 会话状态,例如 `pending`、`processing`、`completed`、`failed`、`interrupted`、`ask_permission`、`waiting_for_user`。 +- 最近一次 assistant 回复、思考内容、拒绝原因和失败原因。 +- 最近一次工具调用信息、token 用量和活跃 token 数。 +- 当前会话中仍被跟踪的子进程信息。 + +会话标题默认来自首次用户输入的前 100 个字符。使用会话列表中的重命名功能会更新索引里的标题。 + +### 消息文件 + +每个会话有一个独立的 JSONL 消息文件,文件名是 `.jsonl`。消息按追加顺序写入,常见字段包括: + +| 字段 | 说明 | +| ---------------- | ---------------------------------------------------------------- | +| `id` | 消息 ID。 | +| `sessionId` | 所属会话 ID。 | +| `role` | 消息角色:`system`、`user`、`assistant` 或 `tool`。 | +| `content` | 文本内容。 | +| `contentParams` | 结构化内容,例如图片输入。 | +| `messageParams` | 模型消息参数,例如 tool call ID、tool calls、reasoning content。 | +| `visible` | 是否在界面中显示。 | +| `compacted` | 是否已经被长会话压缩替代。 | +| `checkpointHash` | 与 `/undo` 关联的代码快照哈希。 | +| `meta` | 工具展示、skill、权限、摘要等附加信息。 | + +读取消息文件时,Deep Code 会逐行解析 JSON;无法解析的行会被忽略,以便尽量保留其余可用历史。 + +### 代码快照 + +Deep Code 使用 `file-history/.git` 保存代码快照。这个仓库只作为内部文件历史使用,不是项目仓库本身。 + +- 新会话会初始化一条以会话 ID 命名的内部分支。 +- 每次用户输入前,会记录已跟踪文件的状态。 +- 工具修改文件前后,会按需记录相关文件的状态。 +- 用户消息上的 `checkpointHash` 用来把某次对话位置和对应的代码状态关联起来。 + +快照只覆盖 Deep Code 已跟踪到的文件;无关文件不会因为 `/undo` 被任意改写。 + +## 会话生命周期 + +### 创建会话 + +创建新会话时,Deep Code 会: + +1. 生成新的会话 ID。 +2. 初始化该会话的代码快照分支。 +3. 在 `sessions-index.json` 中添加会话条目。 +4. 写入系统提示、运行时上下文、项目指令和用户消息。 +5. 启动模型请求,并在 assistant 回复和工具执行过程中持续更新索引和消息文件。 + +项目级会话列表最多保留最近 50 条记录。超过上限时,较旧会话会从索引中移除,其消息文件和相关运行时资源也会被清理。 + +### 继续会话 + +`/resume` 会显示当前项目的历史会话列表,并选择一个会话继续。 + +`/continue` 会优先继续当前活动会话;如果没有可继续的活动会话,则进入历史会话选择流程。 + +继续会话时,Deep Code 会读取会话消息文件,过滤已压缩的旧消息,修复未完成的工具调用上下文,并把可用历史转换为模型请求消息。 + +### 长会话压缩 + +当会话上下文过长时,Deep Code 会触发压缩流程: + +- 选取较早的一段非系统消息生成摘要。 +- 将这段旧消息标记为 `compacted: true`。 +- 在消息序列中插入一条不可见的系统摘要消息。 + +后续请求只会使用未压缩消息和摘要消息。原始消息仍保留在 JSONL 文件中,用于审计和界面历史展示。 + +### 中断、失败和权限等待 + +会话状态会随运行过程更新: + +- 用户中断后,状态会变为 `interrupted`,并清理当前会话控制器和被跟踪的子进程。 +- 请求失败时,状态会变为 `failed`,失败原因写入索引。 +- 工具调用需要确认时,状态会变为 `ask_permission`。 +- 工具需要用户输入时,状态会变为 `waiting_for_user`。 + +这些状态都会持久化到 `sessions-index.json`,因此重新打开 CLI 后仍能在会话列表中看到。 + +## `/undo` 如何使用持久化数据 + +`/undo` 的候选项来自可见且未压缩的用户消息。每个候选项会检查是否有关联的 `checkpointHash`,并确认对应快照是否可恢复。 + +根据选择,Deep Code 可以执行以下操作: + +| 操作 | 行为 | +| -------- | ------------------------------------------------------------------- | +| 恢复对话 | 截断所选用户消息之前的消息历史,并更新索引中的最新 assistant 信息。 | +| 恢复代码 | 从 `file-history/.git` 中读取所选快照,并还原被跟踪文件。 | +| 同时恢复 | 先恢复代码,再截断对话历史。 | + +恢复对话会重写该会话的 JSONL 文件;恢复代码会修改工作区中被快照跟踪的文件。 + +## 删除和重命名 + +在会话列表中删除会话会: + +- 从 `sessions-index.json` 移除该条目。 +- 删除对应的 `.jsonl` 文件。 +- 清理该会话的内存状态、临时工作目录状态、控制器和仍被跟踪的进程控制信息。 + +重命名会话只更新索引中的 `summary` 字段,不会改动消息文件或代码快照。 + +## 注意事项 + +- 会话数据保存在本机用户目录下,并按项目分隔。 +- 移动项目目录后,新的项目根路径会生成新的 ``;旧路径对应的历史不会自动迁移。 +- `file-history/.git` 是 Deep Code 的内部快照仓库,不应手动修改。 +- 会话删除不会清理内部 Git 仓库中的所有历史对象;它主要删除会话索引、消息文件和运行时资源。 diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/session-persistence_en.md b/packages/core/templates/skills/bundled/deepcode-self-refer/references/session-persistence_en.md new file mode 100644 index 00000000..865bf04b --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/session-persistence_en.md @@ -0,0 +1,139 @@ +# Session Persistence + +Deep Code stores per-project session history in the local user directory. This history powers `/resume`, `/continue`, and `/undo`, and it remains available after the current terminal process exits. + +## Storage Location + +Each project has its own storage directory: + +```text +~/.deepcode/projects// +``` + +`` is generated from the project root path. Normal paths are converted into safe directory names. When the path would be too long, Deep Code keeps part of the project name and appends a stable hash so the storage path stays safe. + +The project storage directory contains these main files and directories: + +| Path | Description | +| --------------------- | --------------------------------------------------------------------------------------- | +| `sessions-index.json` | Session index for the current project, including the session list and summary metadata. | +| `.jsonl` | Message log for one session. Each line is one JSON message. | +| `file-history/.git` | Internal Git repository used for code checkpoints restored by `/undo`. | + +## Persisted Data + +### Session Index + +`sessions-index.json` stores recent session entries. Each entry includes: + +- Session ID, title, creation time, and update time. +- Session status, such as `pending`, `processing`, `completed`, `failed`, `interrupted`, `ask_permission`, or `waiting_for_user`. +- Latest assistant reply, thinking content, refusal reason, and failure reason. +- Latest tool-call data, token usage, and active token count. +- Metadata for subprocesses still tracked by the session. + +The default session title comes from the first 100 characters of the first user prompt. Renaming a session from the session list updates the title in the index. + +### Message Files + +Each session has a separate JSONL message file named `.jsonl`. Messages are appended in order. Common fields include: + +| Field | Description | +| ---------------- | -------------------------------------------------------------------------------------- | +| `id` | Message ID. | +| `sessionId` | Owning session ID. | +| `role` | Message role: `system`, `user`, `assistant`, or `tool`. | +| `content` | Text content. | +| `contentParams` | Structured content, such as image input. | +| `messageParams` | Model message parameters, such as tool call IDs, tool calls, and reasoning content. | +| `visible` | Whether the message is shown in the UI. | +| `compacted` | Whether the message has been replaced by long-session compaction. | +| `checkpointHash` | Code checkpoint hash associated with `/undo`. | +| `meta` | Extra metadata for tool display, skills, permissions, summaries, and related features. | + +When loading a message file, Deep Code parses JSON one line at a time. Malformed lines are ignored so the remaining usable history can still be loaded. + +### Code Checkpoints + +Deep Code stores code checkpoints in `file-history/.git`. This repository is only internal file history; it is not the project Git repository. + +- A new session initializes an internal branch named after the session ID. +- Before each user prompt, Deep Code records the state of files it already tracks. +- Before and after tool-based file mutations, Deep Code records the relevant file state as needed. +- `checkpointHash` on user messages links a conversation position to a code state. + +Checkpoints only cover files Deep Code has tracked. Unrelated files are not arbitrarily rewritten by `/undo`. + +## Session Lifecycle + +### Creating A Session + +When creating a new session, Deep Code: + +1. Generates a new session ID. +2. Initializes the code checkpoint branch for that session. +3. Adds an entry to `sessions-index.json`. +4. Writes system prompts, runtime context, project instructions, and the user message. +5. Starts the model request and keeps updating the index and message file as assistant replies and tool executions complete. + +The per-project session list keeps the 50 most recent entries. When the limit is exceeded, older sessions are removed from the index, and their message files and related runtime resources are cleaned up. + +### Continuing A Session + +`/resume` shows the current project's session history and lets you select a session to continue. + +`/continue` first continues the active session. If there is no active session to continue, it opens the session selection flow. + +When continuing a session, Deep Code reads the message file, filters compacted old messages, repairs incomplete tool-call context, and converts the usable history into model request messages. + +### Long-Session Compaction + +When the conversation context grows too large, Deep Code can compact earlier messages: + +- It summarizes an older range of non-system messages. +- It marks those old messages as `compacted: true`. +- It inserts an invisible system summary message into the message sequence. + +Future requests use the remaining active messages and the summary message. The original messages stay in the JSONL file for auditability and UI history. + +### Interruptions, Failures, And Permission Waits + +Session status changes during execution: + +- After a user interruption, status becomes `interrupted`, and Deep Code clears the current session controller and tracked subprocesses. +- After a request failure, status becomes `failed`, and the failure reason is written to the index. +- When a tool call needs confirmation, status becomes `ask_permission`. +- When a tool needs user input, status becomes `waiting_for_user`. + +These states are persisted in `sessions-index.json`, so they remain visible in the session list after reopening the CLI. + +## How `/undo` Uses Persistent Data + +`/undo` candidates come from visible, non-compacted user messages. Each candidate is checked for an associated `checkpointHash`, and Deep Code verifies whether the checkpoint can be restored. + +Depending on the selected mode, Deep Code can perform these operations: + +| Operation | Behavior | +| -------------------- | -------------------------------------------------------------------------------------------------------------- | +| Restore conversation | Truncates message history before the selected user message and updates the latest assistant data in the index. | +| Restore code | Reads the selected checkpoint from `file-history/.git` and restores tracked files. | +| Restore both | Restores code first, then truncates the conversation history. | + +Restoring conversation rewrites the session JSONL file. Restoring code modifies workspace files tracked by the selected checkpoint. + +## Delete And Rename + +Deleting a session from the session list: + +- Removes the entry from `sessions-index.json`. +- Deletes the matching `.jsonl` file. +- Clears in-memory state, temporary working-directory state, controllers, and tracked process controls for that session. + +Renaming a session only updates the `summary` field in the index. It does not change message files or code checkpoints. + +## Notes + +- Session data is stored in the local user directory and separated by project. +- If a project directory is moved, the new project root path generates a new ``; history for the old path is not migrated automatically. +- `file-history/.git` is Deep Code's internal checkpoint repository and should not be edited manually. +- Deleting a session does not remove every historical object from the internal Git repository. It mainly removes the session index entry, message file, and runtime resources. diff --git a/packages/core/templates/skills/bundled/plan/SKILL.md b/packages/core/templates/skills/bundled/plan/SKILL.md new file mode 100644 index 00000000..41c43013 --- /dev/null +++ b/packages/core/templates/skills/bundled/plan/SKILL.md @@ -0,0 +1,133 @@ +--- +name: plan +description: Plan tasks through a strict non-mutating collaboration workflow before implementation. Use ONLY when the user asks for Plan Mode, planning only or non-mutating exploration. +--- + +# Plan Mode (Conversational) + +You work in 3 phases, and you should _chat your way_ to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. + +## Mode rules (strict) + +You are in **Plan Mode** until a developer message explicitly ends it. + +Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to **plan the execution**, not perform it. + +## Plan Mode vs UpdatePlan tool + +Plan Mode is a collaboration mode that can involve requesting user input and eventually issuing a `` block. + +Separately, `UpdatePlan` is Deep Code's checklist/progress tool. It updates the current task plan with a complete markdown task list, but it does not enter or exit Plan Mode and it is not the final planning artifact. Do not use `UpdatePlan` as a substitute for the `` block. + +## Execution vs. mutation in Plan Mode + +You may explore and execute **non-mutating** actions that improve the plan. You must not perform **mutating** actions. + +### Allowed (non-mutating, plan-improving) + +Actions that gather truth, reduce ambiguity, or validate feasibility without changing repo-tracked state. Examples: + +- Reading or searching files, configs, schemas, types, manifests, and docs +- Static analysis, inspection, and repo exploration +- Dry-run style commands when they do not edit repo-tracked files +- Tests, builds, or checks that may write to caches or build artifacts (for example, `target/`, `.cache/`, or snapshots) so long as they do not edit repo-tracked files + +### Not allowed (mutating, plan-executing) + +Actions that implement the plan or change repo-tracked state. Examples: + +- Editing or writing files +- Running formatters or linters that rewrite files +- Applying patches, migrations, or codegen that updates repo-tracked files +- Side-effectful commands whose purpose is to carry out the plan rather than refine it + +When in doubt: if the action would reasonably be described as "doing the work" rather than "planning the work," do not do it. + +## PHASE 1 — Ground in the environment (explore first, ask second) + +Begin by grounding yourself in the actual environment. Eliminate unknowns in the prompt by discovering facts, not by asking the user. Resolve all questions that can be answered through exploration or inspection. Identify missing or ambiguous details only if they cannot be derived from the environment. Silent exploration between turns is allowed and encouraged. + +Before asking the user any question, perform at least one targeted non-mutating exploration pass (for example: search relevant files, inspect likely entrypoints/configs, confirm current implementation shape), unless no local environment/repo is available. + +Exception: you may ask clarifying questions about the user's prompt before exploring, ONLY if there are obvious ambiguities or contradictions in the prompt itself. However, if ambiguity might be resolved by exploring, always prefer exploring first. + +Do not ask questions that can be answered from the repo or system (for example, "where is this struct?" or "which UI component should we use?" when exploration can make it clear). Only ask once you have exhausted reasonable non-mutating exploration. + +## PHASE 2 — Intent chat (what they actually want) + +- Keep asking until you can clearly state: goal + success criteria, audience, in/out of scope, constraints, current state, and the key preferences/tradeoffs. +- Bias toward questions over guessing: if any high-impact ambiguity remains, do NOT plan yet—ask. + +## PHASE 3 — Implementation chat (what/how we’ll build) + +- Once intent is stable, keep asking until the spec is decision complete: approach, interfaces (APIs/schemas/I/O), data flow, edge cases/failure modes, testing + acceptance criteria, rollout/monitoring, and any migrations/compat constraints. + +## Asking questions + +Critical rules: + +- Strongly prefer using the `AskUserQuestion` tool to ask any questions. +- Offer only meaningful multiple‑choice options; don’t include filler choices that are obviously wrong or irrelevant. +- In rare cases where an unavoidable, important question can’t be expressed with reasonable multiple‑choice options (due to extreme ambiguity), you may ask it directly without the tool. + +You SHOULD ask many questions, but each question must: + +- materially change the spec/plan, OR +- confirm/lock an assumption, OR +- choose between meaningful tradeoffs. +- not be answerable by non-mutating commands. + +Use the `AskUserQuestion` tool only for decisions that materially change the plan, for confirming important assumptions, or for information that cannot be discovered via non-mutating exploration. Ask one question at a time when possible, provide concrete options with `label` and optional `description`, and use `multiSelect` only when multiple choices can be combined. + +## Two kinds of unknowns (treat differently) + +1. **Discoverable facts** (repo/system truth): explore first. + + - Before asking, run targeted searches and check likely sources of truth (configs/manifests/entrypoints/schemas/types/constants). + - Ask only if: multiple plausible candidates; nothing found but you need a missing identifier/context; or ambiguity is actually product intent. + - If asking, present concrete candidates (paths/service names) + recommend one. + - Never ask questions you can answer from your environment (e.g., “where is this struct”). + +2. **Preferences/tradeoffs** (not discoverable): ask early. + + - These are intent or implementation preferences that cannot be derived from exploration. + - Provide 2–4 mutually exclusive options + a recommended default. + - If unanswered, proceed with the recommended option and record it as an assumption in the final plan. + +## Finalization rule + +Only output the final plan when it is decision complete and leaves no decisions to the implementer. + +When you present the official plan, wrap it in a `` block so the client can render it specially: + +1. The opening tag must be on its own line. +2. Start the plan content on the next line (no text on the same line as the tag). +3. The closing tag must be on its own line. +4. Use Markdown inside the block. +5. Keep the tags exactly as `` and `` (do not translate or rename them), even if the plan content is in another language. + +Example: + + +plan content + + +plan content should be human and agent digestible. The final plan must be plan-only, concise by default, and include: + +- A clear title +- A brief summary section +- Important changes or additions to public APIs/interfaces/types +- Test cases and scenarios +- Explicit assumptions and defaults chosen where needed + +When possible, prefer a compact structure with 3-5 short sections, usually: Summary, Key Changes or Implementation Changes, Test Plan, and Assumptions. Do not include a separate Scope section unless scope boundaries are genuinely important to avoid mistakes. + +Prefer grouped implementation bullets by subsystem or behavior over file-by-file inventories. Mention files only when needed to disambiguate a non-obvious change, and avoid naming more than 3 paths unless extra specificity is necessary to prevent mistakes. Prefer behavior-level descriptions over symbol-by-symbol removal lists. For v1 feature-addition plans, do not invent detailed schema, validation, precedence, fallback, or wire-shape policy unless the request establishes it or it is needed to prevent a concrete implementation mistake; prefer the intended capability and minimum interface/behavior changes. + +Keep bullets short and avoid explanatory sub-bullets unless they are needed to prevent ambiguity. Prefer the minimum detail needed for implementation safety, not exhaustive coverage. Within each section, compress related changes into a few high-signal bullets and omit branch-by-branch logic, repeated invariants, and long lists of unaffected behavior unless they are necessary to prevent a likely implementation mistake. Avoid repeated repo facts and irrelevant edge-case or rollout detail. For straightforward refactors, keep the plan to a compact summary, key edits, tests, and assumptions. If the user asks for more detail, then expand. + +Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a `` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. + +Only produce at most one `` block per turn, and only when you are presenting a complete spec. + +If the user stays in Plan mode and asks for revisions after a prior ``, any new `` must be a complete replacement. diff --git a/packages/core/templates/skills/bundled/skill-digester/SKILL.md b/packages/core/templates/skills/bundled/skill-digester/SKILL.md new file mode 100644 index 00000000..acc969e8 --- /dev/null +++ b/packages/core/templates/skills/bundled/skill-digester/SKILL.md @@ -0,0 +1,170 @@ +--- +name: skill-digester +description: Reviews and improves another DeepCode skill's SKILL.md description field, and guides Agent Skill installation into user or project .agents/skills roots. Use when the user asks to digest a skill, install an Agent Skill, install a skill to user/project scope, or says "消化技能" or "安装 agent skill". +--- + +# Skill Digester + +Use this skill for two related tasks: + +- Review and optionally rewrite the `description` field of another DeepCode skill. +- Guide installation of an Agent Skill into an interoperable `.agents/skills` root. + +## Interaction Rule + +Whenever user input is needed, call the `AskUserQuestion` tool. Do not ask follow-up questions as plain assistant text. This includes missing skill names or paths, install scope, language preference, duplicate matches, malformed frontmatter decisions, and whether to apply a recommended rewrite. + +## Workflow + +First classify the request: + +- If the user asks to install, add, copy, or place an Agent Skill, use the [Install Agent Skill Workflow](#install-agent-skill-workflow). +- Otherwise, use the [Digest Description Workflow](#digest-description-workflow). + +## Digest Description Workflow + +1. Identify the target skill from the user's request. + - If the user did not provide a skill name, use `AskUserQuestion` to ask for one. + - Locate the skill by running the bundled Node script from this skill directory: + + ```bash + node ~/.deepcode/skills/skill-digester/scripts/find-skill.js "" "" + ``` + + If this skill is loaded from a project-level or different user-level path, use the `scripts/find-skill.js` file next to this `SKILL.md` instead. + - The script searches the same roots Deep Code CLI scans, in priority order: + 1. Project native skills: `./.deepcode/skills//SKILL.md` + 2. Project interoperable skills: `./.agents/skills//SKILL.md` + 3. User native skills: `~/.deepcode/skills//SKILL.md` + 4. User interoperable skills: `~/.agents/skills//SKILL.md` + - Treat `./` as the current Deep Code project root only; do not scan parent directories unless the running project root is changed. + - The script resolves each candidate's skill name the way Deep Code does: use the trimmed frontmatter `name` when present, otherwise use the folder name with underscores converted to hyphens. + - Match the user's input against the resolved skill name first. If needed, also consider the folder name or an explicit path the user provided. + - Treat the matched skill's `path` as the source `SKILL.md` to review. + - Treat the matched skill's `digestTarget.path` as the only output `SKILL.md` path to create or edit. + - `digestTarget.path` always points to the same scope's native Deep Code root: + - Project sources from `./.deepcode/skills` or `./.agents/skills` digest to `./.deepcode/skills//SKILL.md`. + - User sources from `~/.deepcode/skills` or `~/.agents/skills` digest to `~/.deepcode/skills//SKILL.md`. + - If the script returns one active match, use its `path` for reading and `digestTarget.path` for writing. + - If the script returns active and shadowed matches, present each source path and digest target path, then use `AskUserQuestion` before using a shadowed source. + - If the script returns no match, state that the skill was not found in Deep Code's scanned skill roots and use `AskUserQuestion` to ask whether the user wants to try another name. + +2. Infer the user's preferred language before reviewing. + - Infer a likely language from the user's wording. For example, if the user says `消化pdf技能`, infer Chinese. + - Confirm the language with `AskUserQuestion` in the inferred language. For Chinese, ask: `请选择您偏好的语言。` + - Offer the inferred language first and include `English` as a fallback. The UI provides an `Other` option, so the user can type a different language. + - Use the confirmed preferred language for every later question, recommendation, and rewritten `description` field. + +3. Read the source `SKILL.md`. + - Parse the YAML frontmatter and Markdown body from the matched source path. + - Preserve all frontmatter fields and body content except for the `description` field if the user approves a rewrite. + - If frontmatter is missing or malformed, explain the issue and use `AskUserQuestion` before making structural repairs. + +4. Review the current `description` field against the Agent Skills specification. + - Required constraints: + - It must be non-empty. + - It must be 1-1024 characters. + - It should describe what the skill does. + - It should describe when to use the skill. + - It should include specific keywords that help agents identify relevant tasks. + - Compare the description with the actual `SKILL.md` body. Flag mismatches, missing capabilities, overbroad activation language, vague wording, or important trigger keywords that are absent. + - Do not rewrite for style alone if the existing description is accurate, specific, and useful. + +5. Present the review and recommendation. + - If the description is already good, say so and do not change the file unless the user asks. + - If improvements are useful, show: + - The current description. + - Concise review findings. + - A recommended replacement written in the preferred language. + - The source path being reviewed. + - The digest output path that would be created or edited. + - Use `AskUserQuestion` to ask the user to choose one of three actions in the preferred language: + - Apply the recommended change. + - Abandon the change. + - Continue discussing the wording. + +6. Apply the change only after explicit approval. + - Write only to `digestTarget.path`; never write the digested result to `.agents/skills`. + - If `digestTarget.sameAsSource` is true, update only the `description` field in that existing native `SKILL.md`. + - If `digestTarget.sameAsSource` is false and `digestTarget.exists` is false, create the native target skill directory by copying the source skill directory first, then update only the target `SKILL.md` description. This preserves bundled scripts, references, and assets. + - If `digestTarget.sameAsSource` is false and `digestTarget.exists` is true, update only the `description` field in the existing native target `SKILL.md`; do not overwrite its body or bundled files unless the user explicitly asks. + - Keep the original `name` and any other frontmatter fields unchanged in the file being written. + - Preserve body content exactly unless the user separately asks to edit it. + - After editing, report the source path, updated digest output path, and final description. + +## Install Agent Skill Workflow + +Use this workflow when the user asks to install an Agent Skill. Installation always writes to `.agents/skills`, not `.deepcode/skills`. + +1. Identify the source skill directory. + - If the user provided an explicit file or directory path, resolve it: + - `~/...` relative to the user's home directory. + - `./...` relative to the current project root. + - Absolute paths as written. + - A `SKILL.md` path means its parent directory is the source skill directory. + - If the user provided a skill name instead of a path, locate it with `scripts/find-skill.js` using the same command and match rules as the digest workflow. + - If the user did not provide a skill name or path, use `AskUserQuestion` to ask for the source skill name or path. + - The source directory must contain `SKILL.md`. If it does not, report that the path is not an Agent Skill and ask for another source only if the user still wants to install. + +2. Determine the installed skill folder name. + - Parse the source `SKILL.md` frontmatter. + - Use the trimmed frontmatter `name` when present. + - Otherwise use the source folder name with underscores converted to hyphens. + - Use that resolved name as the target folder name. + +3. Ask exactly one installation scope question. + - Use `AskUserQuestion` to ask whether to install the skill at user level or project level. + - Offer only these scope choices: + - User-level install: `~/.agents/skills//` + - Project-level install: `./.agents/skills//` + - Do not ask any other installation preference before copying. + +4. Copy the complete skill directory. + - User-level destination: `~/.agents/skills//`. + - Project-level destination: `./.agents/skills//`. + - Copy the whole source skill directory, including `SKILL.md`, `references/`, `scripts/`, `templates/`, examples, assets, and other support files. + - Preserve file contents and relative paths exactly. + - Create the `.agents/skills` parent directory if needed. + - If the destination directory already exists, stop and report the conflict. Do not overwrite or merge files unless the user explicitly asks in a later message. + +5. Report the result. + - Report the source directory and installation destination. + - Mention that the agent client may need to reload or restart before the installed skill appears. + - Do not digest, rewrite, or normalize the installed skill unless the user separately asks for that. + +## AskUserQuestion Patterns + +Use one question at a time unless two decisions are tightly coupled. Each question must include `options`; rely on the UI's `Other` option for free-form input. + +Examples: + +```json +{"questions":[{"question":"请选择您偏好的语言。","options":[{"label":"中文","description":"后续询问和推荐描述都使用中文。"},{"label":"English","description":"Use English for follow-up questions and the recommended description."}]}]} +``` + +```json +{"questions":[{"question":"How should I proceed with this description recommendation?","options":[{"label":"Apply change","description":"Update only the description field in the native digest output SKILL.md."},{"label":"Abandon change","description":"Leave the file unchanged."},{"label":"Discuss wording","description":"Continue refining the proposed description before editing."}]}]} +``` + +```json +{"questions":[{"question":"Where should I install this Agent Skill?","options":[{"label":"User-level","description":"Install to ~/.agents/skills so it is available across projects."},{"label":"Project-level","description":"Install to ./.agents/skills so it is available in this project."}]}]} +``` + +## Review Heuristics + +A strong description is short, concrete, and activation-oriented. Prefer this pattern: + +```text +. Use when . +``` + +Avoid descriptions that are only generic labels, marketing copy, or internal implementation notes. + +## Safety Notes + +- Never modify a different skill with a similar name without asking. +- Never save the digested output under `.agents/skills`; `.agents/skills` is only a source root for digestion. +- Never save installed Agent Skills under `.deepcode/skills`; installation writes only to `.agents/skills`. +- Never move a skill between project and user level during digestion. +- Never overwrite or merge an existing installed skill directory unless the user explicitly asks after seeing the conflict. +- Never change the target skill's language preference after confirmation unless the user asks. diff --git a/packages/core/templates/skills/bundled/skill-digester/scripts/find-skill.js b/packages/core/templates/skills/bundled/skill-digester/scripts/find-skill.js new file mode 100755 index 00000000..2067e023 --- /dev/null +++ b/packages/core/templates/skills/bundled/skill-digester/scripts/find-skill.js @@ -0,0 +1,215 @@ +#!/usr/bin/env node +/* global __dirname, console, process, require */ + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +function usage() { + return "Usage: node scripts/find-skill.js [project-root]"; +} + +function loadMatter() { + for (const base of [process.cwd(), __dirname]) { + try { + const resolved = require.resolve("gray-matter", { paths: [base] }); + return require(resolved); + } catch { + // Try the next lookup base, then fall back to the local parser. + } + } + return null; +} + +function parseFrontmatter(content) { + const matter = loadMatter(); + if (matter) { + try { + return matter(content).data || {}; + } catch { + // Fall back to the minimal frontmatter parser below. + } + } + + if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) { + return {}; + } + const newline = content.startsWith("---\r\n") ? "\r\n" : "\n"; + const end = content.indexOf(`${newline}---${newline}`, 4); + if (end === -1) { + return {}; + } + const raw = content.slice(4, end).split(/\r?\n/); + const data = {}; + for (const line of raw) { + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!match) continue; + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + data[match[1]] = value; + } + return data; +} + +function readSkillInfo(skillPath, displayPath, folderName) { + const fallbackName = folderName.replace(/_/g, "-"); + try { + const content = fs.readFileSync(skillPath, "utf8"); + const data = parseFrontmatter(content); + const name = typeof data.name === "string" && data.name.trim() ? data.name.trim() : fallbackName; + const description = typeof data.description === "string" ? data.description.trim() : ""; + return { name, folderName, path: skillPath, displayPath, description }; + } catch (error) { + return { name: fallbackName, folderName, path: skillPath, displayPath, description: "", error: error.message }; + } +} + +function isSkillFile(candidatePath) { + try { + return fs.statSync(candidatePath).isFile(); + } catch { + return false; + } +} + +function collect(rootInfo) { + let entries; + try { + entries = fs.readdirSync(rootInfo.root, { withFileTypes: true }); + } catch { + return []; + } + + const skills = []; + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const folderName = entry.name; + const skillPath = path.join(rootInfo.root, folderName, "SKILL.md"); + if (!isSkillFile(skillPath)) continue; + const skill = readSkillInfo(skillPath, `${rootInfo.displayRoot}/${folderName}/SKILL.md`, folderName); + const digestTargetPath = path.join(rootInfo.digestRoot, folderName, "SKILL.md"); + skill.digestTarget = { + path: digestTargetPath, + displayPath: `${rootInfo.digestDisplayRoot}/${folderName}/SKILL.md`, + root: rootInfo.digestDisplayRoot, + exists: isSkillFile(digestTargetPath), + sameAsSource: path.resolve(digestTargetPath) === path.resolve(skillPath), + }; + skills.push(skill); + } + return skills; +} + +function expandInputPath(input, projectRoot) { + if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2)); + if (input.startsWith("~\\")) return path.join(os.homedir(), input.slice(2)); + if (input.startsWith("./")) return path.join(projectRoot, input.slice(2)); + if (input.startsWith(".\\")) return path.join(projectRoot, input.slice(2)); + if (path.isAbsolute(input)) return input; + return null; +} + +function main() { + const query = process.argv[2]; + const projectRoot = process.argv[3] ? path.resolve(process.argv[3]) : process.cwd(); + if (!query) { + console.error(usage()); + process.exit(2); + } + + const projectNativeRoot = path.join(projectRoot, ".deepcode", "skills"); + const userNativeRoot = path.join(os.homedir(), ".deepcode", "skills"); + const roots = [ + { + root: projectNativeRoot, + displayRoot: "./.deepcode/skills", + scope: "project", + kind: "native", + digestRoot: projectNativeRoot, + digestDisplayRoot: "./.deepcode/skills", + }, + { + root: path.join(projectRoot, ".agents", "skills"), + displayRoot: "./.agents/skills", + scope: "project", + kind: "interoperable", + digestRoot: projectNativeRoot, + digestDisplayRoot: "./.deepcode/skills", + }, + { + root: userNativeRoot, + displayRoot: "~/.deepcode/skills", + scope: "user", + kind: "native", + digestRoot: userNativeRoot, + digestDisplayRoot: "~/.deepcode/skills", + }, + { + root: path.join(os.homedir(), ".agents", "skills"), + displayRoot: "~/.agents/skills", + scope: "user", + kind: "interoperable", + digestRoot: userNativeRoot, + digestDisplayRoot: "~/.deepcode/skills", + }, + ]; + + const scanned = []; + for (const rootInfo of roots) { + for (const skill of collect(rootInfo)) { + scanned.push({ ...skill, root: rootInfo.displayRoot, scope: rootInfo.scope, kind: rootInfo.kind }); + } + } + + const activeByName = new Map(); + const shadowed = []; + for (const skill of scanned) { + if (activeByName.has(skill.name)) { + shadowed.push({ ...skill, shadowedBy: activeByName.get(skill.name).displayPath }); + } else { + activeByName.set(skill.name, skill); + } + } + + const inputPath = expandInputPath(query, projectRoot); + const matches = []; + for (const skill of scanned) { + if (skill.name === query || skill.folderName === query) { + matches.push(skill); + continue; + } + if (inputPath) { + const normalized = path.resolve(inputPath); + if (path.resolve(skill.path) === normalized || path.resolve(path.dirname(skill.path)) === normalized) { + matches.push(skill); + } + } + } + + const activeMatches = matches.filter((skill) => activeByName.get(skill.name)?.path === skill.path); + const shadowedMatches = matches.filter((skill) => activeByName.get(skill.name)?.path !== skill.path); + + process.stdout.write( + JSON.stringify( + { + query, + projectRoot, + roots, + found: matches.length > 0, + activeMatches, + shadowedMatches: shadowedMatches.map((skill) => ({ + ...skill, + shadowedBy: activeByName.get(skill.name)?.displayPath, + })), + duplicateNames: shadowed, + }, + null, + 2 + ) + ); + process.stdout.write("\n"); +} + +main(); diff --git a/packages/core/templates/skills/bundled/skill-writer/SKILL.md b/packages/core/templates/skills/bundled/skill-writer/SKILL.md new file mode 100644 index 00000000..ca2adc41 --- /dev/null +++ b/packages/core/templates/skills/bundled/skill-writer/SKILL.md @@ -0,0 +1,402 @@ +--- +name: skill-writer +description: Guide users through creating, updating, debugging, and validating Agent Skills for AI agents. Use when the user wants to create, write, author, design, troubleshoot, validate, or improve a Skill, or needs help with SKILL.md, frontmatter, or skill structure. +--- + +# Skill Writer + +This Skill helps you create well-structured Agent Skills for AI agents that follow best practices and validation requirements. + +## When to use this Skill + +Use this Skill when: + +- Creating a new Agent Skill +- Writing or updating SKILL.md files +- Designing skill structure and frontmatter +- Troubleshooting skill discovery issues +- Converting existing prompts or workflows into Skills + +## Instructions + +### Step 1: Determine Skill scope + +First, understand what the Skill should do: + +1. **Ask clarifying questions**: + - What specific capability should this Skill provide? + - When should AI agents use this Skill? + - What tools or resources does it need? + - Is this for personal use or team sharing? + +2. **Keep it focused**: One Skill = one capability + - Good: "PDF form filling", "Excel data analysis" + - Too broad: "Document processing", "Data tools" + +### Step 2: Choose Skill location + +Determine where to create the Skill: + +**Personal Skills** (`~/.agents/skills/`): + +- Individual workflows and preferences +- Experimental Skills +- Personal productivity tools + +**Project Skills** (`.agents/skills/`): + +- Team workflows and conventions +- Project-specific expertise +- Shared utilities (committed to git) + +### Step 3: Create Skill structure + +Create the directory and files: + +```bash +# Personal +mkdir -p ~/.agents/skills/skill-name + +# Project +mkdir -p .agents/skills/skill-name +``` + +For multi-file Skills: + +``` +skill-name/ +├── SKILL.md (required) +├── reference.md (optional) +├── examples.md (optional) +├── scripts/ +│ └── helper.py (optional) +└── templates/ + └── template.txt (optional) +``` + +### Step 4: Write SKILL.md frontmatter + +Create YAML frontmatter with required fields: + +```yaml +--- +name: skill-name +description: Brief description of what this does and when to use it +--- +``` + +**Field requirements**: + +- **name**: + - Lowercase letters, numbers, hyphens only + - Max 64 characters + - Must match directory name + - Good: `pdf-processor`, `git-commit-helper` + - Bad: `PDF_Processor`, `Git Commits!` + +- **description**: + - Max 1024 characters + - Include BOTH what it does AND when to use it + - Use specific trigger words users would say + - Mention file types, operations, and context + +**Optional frontmatter fields**: + +- **allowed-tools**: Restrict tool access (comma-separated list) + ```yaml + allowed-tools: read + ``` + Use for: + - Read-only Skills + - Security-sensitive workflows + - Limited-scope operations + +### Step 5: Write effective descriptions + +The description is critical for AI agents to discover your Skill. + +**Formula**: `[What it does] + [When to use it] + [Key triggers]` + +**Examples**: + +✅ **Good**: + +```yaml +description: Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when the user mentions PDFs, forms, or document extraction. +``` + +✅ **Good**: + +```yaml +description: Analyze Excel spreadsheets, create pivot tables, and generate charts. Use when working with Excel files, spreadsheets, or analyzing tabular data in .xlsx format. +``` + +❌ **Too vague**: + +```yaml +description: Helps with documents +description: For data analysis +``` + +**Tips**: + +- Include specific file extensions (.pdf, .xlsx, .json) +- Mention common user phrases ("analyze", "extract", "generate") +- List concrete operations (not generic verbs) +- Add context clues ("Use when...", "For...") + +### Step 6: Structure the Skill content + +Use clear Markdown sections: + +````markdown +# Skill Name + +Brief overview of what this Skill does. + +## Quick start + +Provide a simple example to get started immediately. + +## Instructions + +Step-by-step guidance for AI agents: + +1. First step with clear action +2. Second step with expected outcome +3. Handle edge cases + +## Examples + +Show concrete usage examples with code or commands. + +## Best practices + +- Key conventions to follow +- Common pitfalls to avoid +- When to use vs. not use + +## Requirements + +List any dependencies or prerequisites: + +```bash +pip install package-name +``` +```` + +## Advanced usage + +For complex scenarios, see [reference.md](reference.md). + +```` + +### Step 7: Add supporting files (optional) + +Create additional files for progressive disclosure: + +**reference.md**: Detailed API docs, advanced options +**examples.md**: Extended examples and use cases +**scripts/**: Helper scripts and utilities +**templates/**: File templates or boilerplate + +Reference them from SKILL.md: +```markdown +For advanced usage, see [reference.md](reference.md). + +Run the helper script: +\`\`\`bash +python scripts/helper.py input.txt +\`\`\` +```` + +### Step 8: Validate the Skill + +Check these requirements: + +✅ **File structure**: + +- [ ] SKILL.md exists in correct location +- [ ] Directory name matches frontmatter `name` + +✅ **YAML frontmatter**: + +- [ ] Opening `---` on line 1 +- [ ] Closing `---` before content +- [ ] Valid YAML (no tabs, correct indentation) +- [ ] `name` follows naming rules +- [ ] `description` is specific and < 1024 chars + +✅ **Content quality**: + +- [ ] Clear instructions for AI agents +- [ ] Concrete examples provided +- [ ] Edge cases handled +- [ ] Dependencies listed (if any) + +✅ **Testing**: + +- [ ] Description matches user questions +- [ ] Skill activates on relevant queries +- [ ] Instructions are clear and actionable + +### Step 9: Test the Skill + +1. **Restart AI agents** (if running) to load the Skill + +2. **Ask relevant questions** that match the description: + + ``` + Can you help me extract text from this PDF? + ``` + +3. **Verify activation**: AI agents should use the Skill automatically + +4. **Check behavior**: Confirm AI agents follows the instructions correctly + +### Step 10: Debug if needed + +If AI agents doesn't use the Skill: + +1. **Make description more specific**: + - Add trigger words + - Include file types + - Mention common user phrases + +2. **Check file location**: + + ```bash + ls ~/.agents/skills/skill-name/SKILL.md + ls .agents/skills/skill-name/SKILL.md + ``` + +3. **Validate YAML**: + ```bash + cat SKILL.md | head -n 10 + ``` + +## Common patterns + +### Read-only Skill + +```yaml +--- +name: code-reader +description: Read and analyze code without making changes. Use for code review, understanding codebases, or documentation. +allowed-tools: read +--- +``` + +### Script-based Skill + +```yaml +--- +name: data-processor +description: Process CSV and JSON data files with Python scripts. Use when analyzing data files or transforming datasets. +--- + +# Data Processor + +## Instructions + +1. Use the processing script: +\`\`\`bash +python scripts/process.py input.csv --output results.json +\`\`\` + +2. Validate output with: +\`\`\`bash +python scripts/validate.py results.json +\`\`\` +``` + +### Multi-file Skill with progressive disclosure + +```yaml +--- +name: api-designer +description: Design REST APIs following best practices. Use when creating API endpoints, designing routes, or planning API architecture. +--- + +# API Designer + +Quick start: See [examples.md](examples.md) + +Detailed reference: See [reference.md](reference.md) + +## Instructions + +1. Gather requirements +2. Design endpoints (see examples.md) +3. Document with OpenAPI spec +4. Review against best practices (see reference.md) +``` + +## Best practices for Skill authors + +1. **One Skill, one purpose**: Don't create mega-Skills +2. **Specific descriptions**: Include trigger words users will say +3. **Clear instructions**: Write for AI agents, not humans +4. **Concrete examples**: Show real code, not pseudocode +5. **List dependencies**: Mention required packages in description +6. **Test with teammates**: Verify activation and clarity +7. **Version your Skills**: Document changes in content +8. **Use progressive disclosure**: Put advanced details in separate files + +## Validation checklist + +Before finalizing a Skill, verify: + +- [ ] Name is lowercase, hyphens only, max 64 chars +- [ ] Description is specific and < 1024 chars +- [ ] Description includes "what" and "when" +- [ ] YAML frontmatter is valid +- [ ] Instructions are step-by-step +- [ ] Examples are concrete and realistic +- [ ] Dependencies are documented +- [ ] File paths use forward slashes +- [ ] Skill activates on relevant queries +- [ ] AI agents follows instructions correctly + +## Troubleshooting + +**Skill doesn't activate**: + +- Make description more specific with trigger words +- Include file types and operations in description +- Add "Use when..." clause with user phrases + +**Multiple Skills conflict**: + +- Make descriptions more distinct +- Use different trigger words +- Narrow the scope of each Skill + +**Skill has errors**: + +- Check YAML syntax (no tabs, proper indentation) +- Verify file paths (use forward slashes) +- Ensure scripts have execute permissions +- List all dependencies + +## Examples + +See the documentation for complete examples: + +- Simple single-file Skill (commit-helper) +- Skill with tool permissions (code-reviewer) +- Multi-file Skill (pdf-processing) + +## Output format + +When creating a Skill, I will: + +1. Ask clarifying questions about scope and requirements +2. Suggest a Skill name and location +3. Create the SKILL.md file with proper frontmatter +4. Include clear instructions and examples +5. Add supporting files if needed +6. Provide testing instructions +7. Validate against all requirements + +The result will be a complete, working Skill that follows all best practices and validation rules. diff --git a/packages/core/templates/skills/karpathy-guidelines.md b/packages/core/templates/skills/karpathy-guidelines.md new file mode 100644 index 00000000..8f5d86bf --- /dev/null +++ b/packages/core/templates/skills/karpathy-guidelines.md @@ -0,0 +1,71 @@ +--- +name: karpathy-guidelines +description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria. +--- + +# Karpathy Guidelines + +Behavioral guidelines to reduce common LLM coding mistakes. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. diff --git a/templates/tools/ask-user-question.md b/packages/core/templates/tools/ask-user-question.md similarity index 99% rename from templates/tools/ask-user-question.md rename to packages/core/templates/tools/ask-user-question.md index 7e072982..bcf4ebcc 100644 --- a/templates/tools/ask-user-question.md +++ b/packages/core/templates/tools/ask-user-question.md @@ -1,12 +1,14 @@ ## AskUserQuestion Use this tool when you need to ask the user questions during execution. This allows you to: + 1. Gather user preferences or requirements 2. Clarify ambiguous instructions 3. Get decisions on implementation choices as you work 4. Offer choices to the user about what direction to take. Usage notes: + - Users will always be able to select "Other" to provide custom text input - Use multiSelect: true to allow multiple answers to be selected for a question - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label diff --git a/packages/core/templates/tools/bash.md b/packages/core/templates/tools/bash.md new file mode 100644 index 00000000..c69d7cd0 --- /dev/null +++ b/packages/core/templates/tools/bash.md @@ -0,0 +1,103 @@ +## Bash + +Executes a given bash command. Working directory persists between commands; shell state (everything else) does not. The shell environment is initialized from the user's profile (bash or zsh). + +On Windows, Bash runs through Git Bash. Use POSIX commands and quote Windows paths carefully. + +IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. + +IMPORTANT: Before reaching for generic shell pipelines, prefer purpose-built CLI tools when they make the task more accurate, safer, faster, or easier to understand: + +- Use `ripgrep` (`rg`) when you need to search file contents by text or regex across the workspace; prefer it over slower tools like `grep`. +- Use `jq` when you need to inspect, filter, or transform JSON output; prefer it over ad-hoc parsing with `sed`, `awk`, or Python one-liners. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") + - Examples of proper quoting: + - cd "/Users/name/My Documents" (correct) + - cd /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + +- The command argument is required. +- The sideEffects argument is required. Declare the minimum permission scopes the command may need. +- You can use `run_in_background: true` to run a command in the background. Only use this if you need to perform a blocking task, like running a server for the upcoming test scripts. +- When using `run_in_background`, do NOT add `&` to the command. Output is written to a log file. +- Before your final response, stop background tasks that has not reported a completed state, unless the user explicitly asks to keep it running. +- To stop a background command, use the `stopCommand` returned in the tool result metadata. +- Use `sideEffects: []` only for commands that do not read, write, delete, query Git history, mutate Git history, or access the network, such as `date` or `node --version`. +- Use `*-out-cwd` when the command accesses paths outside the current workspace. For example, `cat /etc/hosts` requires `["read-out-cwd"]`. +- Use `query-git-log` for commands such as `git log`, `git show HEAD`, `git blame`, or history diffs. Use `mutate-git-log` for commands such as `git commit`, `git reset`, `git rebase`, `git merge`, `git cherry-pick`, or `git tag`. +- Use `["unknown"]` when you cannot classify the command safely. +- It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does. +- If the output exceeds 30000 characters, output will be truncated before being returned to you. +- Always prefer using the dedicated tools for these commands: + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < + pytest /foo/bar/tests + + + cd /foo/bar && pytest tests + + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "command": { + "description": "The command to execute", + "type": "string" + }, + "description": { + "description": "Clear, concise description of what this command does in active voice. Never use words like \"complex\" or \"risk\" in the description - just describe what it does.\n\nFor simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):\n- ls → \"List files in current directory\"\n- git status → \"Show working tree status\"\n- npm install → \"Install package dependencies\"\n\nFor commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:\n- find . -name \"*.tmp\" -exec rm {} \\; → \"Find and delete all .tmp files recursively\"\n- git reset --hard origin/main → \"Discard all local changes and match remote main\"\n- curl -s url | jq '.data[]' → \"Fetch JSON from URL and extract data array elements\"", + "type": "string" + }, + "sideEffects": { + "description": "Permission scopes required by this bash command. Use [] only for commands that do not read, write, delete, or access the network. Use [\"unknown\"] when the effects cannot be classified safely.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "unknown" + ] + }, + "uniqueItems": true + }, + "run_in_background": { + "description": "Set to true to run the command in the background. Use this only when you do not need the result immediately and can wait for a completion notification.", + "type": "boolean" + } + }, + "required": ["command", "sideEffects"], + "additionalProperties": false +} +``` diff --git a/templates/tools/edit.md b/packages/core/templates/tools/edit.md similarity index 70% rename from templates/tools/edit.md rename to packages/core/templates/tools/edit.md index 4ed48940..4f039127 100644 --- a/templates/tools/edit.md +++ b/packages/core/templates/tools/edit.md @@ -3,10 +3,10 @@ Performs scoped string replacements in files. Usage: -- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. -- If your prior Read only covered part of the file, use the returned `snippet_id` to scope the edit, or read the full file before editing without a snippet. + +- You must use `Read` tool at least once in the conversation before editing to get the required `snippet_id`. This tool will error if you attempt an edit without reading the file. +- `snippet_id` defines the search scope. Provide `file_path` only as an optional guard that the snippet belongs to the expected file. - When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. -- Prefer passing `snippet_id` from a prior Read response when you want to limit the replacement to a known range. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. - If `old_string` is not unique, the tool returns candidate matches with line ranges, previews, and snippet ids that you can reuse in a follow-up edit. @@ -18,16 +18,16 @@ Usage: "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "file_path": { - "description": "The absolute path to the file to modify (must be absolute, not relative). Optional when snippet_id is provided.", + "snippet_id": { + "description": "Required snippet_id returned by Read or a prior Edit error response.", "type": "string" }, - "snippet_id": { - "description": "Snippet id returned by Read or a prior Edit error response. Limits the search range to that snippet.", + "file_path": { + "description": "Optional absolute path guard. If provided, it must match the snippet's file.", "type": "string" }, "old_string": { - "description": "The text to replace within the file or snippet scope", + "description": "The text to replace within the snippet_id scope", "type": "string" }, "new_string": { @@ -44,10 +44,7 @@ Usage: "type": "number" } }, - "required": [ - "old_string", - "new_string" - ], + "required": ["snippet_id", "old_string", "new_string"], "additionalProperties": false } ``` diff --git a/templates/tools/read.md.ejs b/packages/core/templates/tools/read.md.ejs similarity index 95% rename from templates/tools/read.md.ejs rename to packages/core/templates/tools/read.md.ejs index a9c50e5f..9f79ac9b 100644 --- a/templates/tools/read.md.ejs +++ b/packages/core/templates/tools/read.md.ejs @@ -9,7 +9,7 @@ Usage: - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters - Any lines longer than 2000 characters will be truncated - Results are returned using cat -n format, with line numbers starting at 1 -- Text reads return a snippet id in metadata. You can pass that snippet id to the Edit tool to constrain replacements to just that read range. +- Text reads return a snippet id for Edit: full-file reads use ids like `full_file_0`; partial reads use ids like `snippet_1`. <%_ if (supportsMultimodal) { _%> - This tool allows you to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Deepseek is a multimodal LLM. <%_ } else { _%> diff --git a/templates/tools/update-plan.md b/packages/core/templates/tools/update-plan.md similarity index 97% rename from templates/tools/update-plan.md rename to packages/core/templates/tools/update-plan.md index 0c74b367..9459242e 100644 --- a/templates/tools/update-plan.md +++ b/packages/core/templates/tools/update-plan.md @@ -3,6 +3,7 @@ Updates the current task plan and progress display. Usage: + - Use this tool for non-trivial multi-step tasks when a task list helps track execution progress. - Pass the complete current task list every time. The latest call replaces the previous visible plan. - The `plan` argument is a markdown string, not an array of step objects. If the requirement is in Chinese, then use Chinese for the markdown as well. @@ -25,9 +26,7 @@ Usage: "type": "string" } }, - "required": [ - "plan" - ], + "required": ["plan"], "additionalProperties": false } ``` diff --git a/templates/tools/web-search.md b/packages/core/templates/tools/web-search.md similarity index 99% rename from templates/tools/web-search.md rename to packages/core/templates/tools/web-search.md index 92e22753..e5eabf0f 100644 --- a/templates/tools/web-search.md +++ b/packages/core/templates/tools/web-search.md @@ -19,9 +19,11 @@ JSON schema: ``` Usage: + - Do not reduce `query` to space-separated keywords. Typical use cases: + - Confirm recent SDK, framework, or API changes - Check current compatibility, deprecations, or migration notes - Look up active issue tracker discussions or recent regressions diff --git a/templates/tools/write.md b/packages/core/templates/tools/write.md similarity index 86% rename from templates/tools/write.md rename to packages/core/templates/tools/write.md index 1a969754..ce774eb3 100644 --- a/templates/tools/write.md +++ b/packages/core/templates/tools/write.md @@ -3,12 +3,13 @@ Writes a file to the local filesystem. Usage: + - This tool will overwrite the existing file if there is one at the provided path. - If this is an existing file, you MUST read the full file first. A partial read is not enough for overwriting an existing file. - `content` must be a single string. If you are writing JSON, serialize the full document to text before calling this tool. - Prefer `Edit` for updating existing files. Use `Write` for new files or intentional full-file rewrites. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. -- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User. - NEVER proactively create one-off test script. Only create one-off test script files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. @@ -26,10 +27,7 @@ Usage: "type": "string" } }, - "required": [ - "file_path", - "content" - ], + "required": ["file_path", "content"], "additionalProperties": false } ``` diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 00000000..ac00d953 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "composite": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/tests"] +} diff --git a/packages/vscode-ide-companion/LICENSE b/packages/vscode-ide-companion/LICENSE new file mode 100644 index 00000000..7fd7a206 --- /dev/null +++ b/packages/vscode-ide-companion/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 lessweb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/vscode-ide-companion/README.md b/packages/vscode-ide-companion/README.md new file mode 100644 index 00000000..ee11be0f --- /dev/null +++ b/packages/vscode-ide-companion/README.md @@ -0,0 +1,94 @@ +# Deep Code + +[Deep Code](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 是 Visual Studio Code 的 AI 编码助手扩展,专门为最新的 `deepseek-v4` 模型优化。 + +## 配置 + +创建 `~/.deepcode/settings.json` 文件,内容如下: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +## 主要功能 + +### **Skills** +Deep Code 支持 agent skills,允许您扩展助手的能力: + +- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 +- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 + +### **为 DeepSeek 优化** +- 专门为 DeepSeek 模型性能调优。 +- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 +- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 + +## 支持的模型 + +- `deepseek-v4-pro`(推荐使用) +- `deepseek-v4-flash` +- 任何其他 OpenAI 兼容模型 + +## 截图示例 + +![screenshot](resources/deepcode_screenshot.png) + +## Deep Code CLI + +```bash +npm install -g @vegamo/deepcode-cli +``` + +![intro1](https://raw.githubusercontent.com/lessweb/deepcode-cli/main/resources/intro1.png) + +> VSCode插件和CLI共享配置文件和数据,但运行时没有依赖。 + +- GitHub: https://github.com/lessweb/deepcode-cli + +## 常见问题 + +### 如何将 Deep Code 从左侧边栏移动到右侧边栏(Secondary Side Bar)? + +![faq1](resources/faq1.gif) + +### Deep Code是否支持理解图片? + +Deep Code支持多模态,但目前deepseek-v4不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的Doubao-Seed-2.0-pro模型,适配效果最好。 + +### 怎样在任务完成后自动给Slack发消息? + +编写一个调用Slack webhook的Shell通知脚本,然后在`~/.deepcode/settings.json`中将`notify`字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g + +### 是否支持Coding Plan? + +支持。只要把`~/.deepcode/settings.json`的env.BASE_URL配置为OpenAI兼容的接口地址就行。以火山方舟的Coding Plan为例,`~/.deepcode/settings.json`这样配置: + +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` + +## 获取帮助 +- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode/issues) + +## 支持我们 + +如果你觉得这个插件对你有帮助,请考虑通过以下方式支持我们: + +- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode) +- 向我们提交反馈和建议 +- 分享给你的朋友和同事 diff --git a/packages/vscode-ide-companion/README_cn.md b/packages/vscode-ide-companion/README_cn.md new file mode 100644 index 00000000..ee11be0f --- /dev/null +++ b/packages/vscode-ide-companion/README_cn.md @@ -0,0 +1,94 @@ +# Deep Code + +[Deep Code](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 是 Visual Studio Code 的 AI 编码助手扩展,专门为最新的 `deepseek-v4` 模型优化。 + +## 配置 + +创建 `~/.deepcode/settings.json` 文件,内容如下: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +## 主要功能 + +### **Skills** +Deep Code 支持 agent skills,允许您扩展助手的能力: + +- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 +- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 + +### **为 DeepSeek 优化** +- 专门为 DeepSeek 模型性能调优。 +- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 +- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 + +## 支持的模型 + +- `deepseek-v4-pro`(推荐使用) +- `deepseek-v4-flash` +- 任何其他 OpenAI 兼容模型 + +## 截图示例 + +![screenshot](resources/deepcode_screenshot.png) + +## Deep Code CLI + +```bash +npm install -g @vegamo/deepcode-cli +``` + +![intro1](https://raw.githubusercontent.com/lessweb/deepcode-cli/main/resources/intro1.png) + +> VSCode插件和CLI共享配置文件和数据,但运行时没有依赖。 + +- GitHub: https://github.com/lessweb/deepcode-cli + +## 常见问题 + +### 如何将 Deep Code 从左侧边栏移动到右侧边栏(Secondary Side Bar)? + +![faq1](resources/faq1.gif) + +### Deep Code是否支持理解图片? + +Deep Code支持多模态,但目前deepseek-v4不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的Doubao-Seed-2.0-pro模型,适配效果最好。 + +### 怎样在任务完成后自动给Slack发消息? + +编写一个调用Slack webhook的Shell通知脚本,然后在`~/.deepcode/settings.json`中将`notify`字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g + +### 是否支持Coding Plan? + +支持。只要把`~/.deepcode/settings.json`的env.BASE_URL配置为OpenAI兼容的接口地址就行。以火山方舟的Coding Plan为例,`~/.deepcode/settings.json`这样配置: + +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` + +## 获取帮助 +- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode/issues) + +## 支持我们 + +如果你觉得这个插件对你有帮助,请考虑通过以下方式支持我们: + +- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode) +- 向我们提交反馈和建议 +- 分享给你的朋友和同事 diff --git a/packages/vscode-ide-companion/README_en.md b/packages/vscode-ide-companion/README_en.md new file mode 100644 index 00000000..40b199bc --- /dev/null +++ b/packages/vscode-ide-companion/README_en.md @@ -0,0 +1,87 @@ +# Deep Code + +[Deep Code](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) is an AI coding assistant extension for Visual Studio Code, specifically optimized for the latest `deepseek-v4` model. + +## Configuration + +Create `~/.deepcode/settings.json` with: + +```json +{ + "env": { + "MODEL": "deepseek-v4-pro", + "BASE_URL": "https://api.deepseek.com", + "API_KEY": "sk-..." + }, + "thinkingEnabled": true, + "reasoningEffort": "max" +} +``` + +## Key Features + +### **Skills** +Deep Code supports agent skills that allows you to extend the assistant's capabilities: + +- **User-level Skills**: discovered and activated from `~/.agents/skills/`. +- **Project-level Skills**: loaded from `./.agents/skills/` for project-specific workflows, with legacy `./.deepcode/skills/` compatibility. + +### **Optimized for DeepSeek** +- Specifically tuned for DeepSeek model performance. +- Reduce costs by using [Context Caching](https://api-docs.deepseek.com/guides/kv_cache). +- Natively supports [Thinking Mode](https://api-docs.deepseek.com/guides/thinking_mode) and Thinking Effort Control. + +## Supported Models + +- `deepseek-v4-pro` (Recommended) +- `deepseek-v4-flash` +- `deepseek-chat` +- Any other OpenAI-compatible model + +## Screenshot + +![screenshot](resources/deepcode_screenshot.png) + +## Deep Code CLI + +```bash +npm install -g @vegamo/deepcode-cli +``` + +![intro1](https://raw.githubusercontent.com/lessweb/deepcode-cli/main/resources/intro1.png) + +> The VSCode plugin and CLI share configuration and data, but they have no dependencies at runtime. + +- GitHub: https://github.com/lessweb/deepcode-cli + +## FAQ + +### How can I move Deep Code from the left sidebar to the right (Secondary Side Bar) in VS Code? + +![faq1](resources/faq1.gif) + +### Does Deep Code support understanding images? + +Deep Code supports multimodal, but `deepseek-v4` does not support multimodal yet. Some models have multimodal capabilities but impose strict limits on multi-turn dialogue requests. For multimodal input, we recommend using the Volcano Ark `Doubao-Seed-2.0-pro` model, which has the best integration. + +### How to automatically send a Slack message after a task completes? + +Write a shell notification script that calls a Slack webhook, then set the `notify` field in `~/.deepcode/settings.json` to the full path of the script. For detailed steps, refer to: https://binfer.net/share/jby5xnc-so6g + +### Does it support Coding Plan? + +Yes. Just set `env.BASE_URL` in `~/.deepcode/settings.json` to an OpenAI-compatible API endpoint. Take Volcano Ark's Coding Plan as an example, configure `~/.deepcode/settings.json` as follows: + +```json +{ + "env": { + "MODEL": "ark-code-latest", + "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", + "API_KEY": "**************" + }, + "thinkingEnabled": true +} +``` + +## Getting Help +- Report bugs or request features on GitHub Issues (https://github.com/lessweb/deepcode/issues) diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json new file mode 100644 index 00000000..fc7f8118 --- /dev/null +++ b/packages/vscode-ide-companion/package.json @@ -0,0 +1,98 @@ +{ + "name": "deepcode-vscode", + "version": "0.1.23", + "publisher": "vegamo", + "displayName": "Deep Code", + "description": "Deep Code VSCode companion — AI-assisted development in your editor", + "license": "MIT", + "type": "commonjs", + "main": "./out/extension.js", + "preview": true, + "repository": { + "type": "git", + "url": "https://github.com/lessweb/deepcode-cli.git", + "directory": "packages/vscode-ide-companion" + }, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "AI", + "Chat" + ], + "keywords": [ + "deep-code", + "deep code", + "deep", + "code", + "cli", + "ide integration", + "ide companion" + ], + "icon": "resources/deepcoding_icon.png", + "activationEvents": [], + "files": [ + "out/extension.js", + "resources/**", + "templates/**", + "README.md", + "README_cn.md", + "README_en.md", + "LICENSE" + ], + "contributes": { + "commands": [ + { + "command": "deepcode.openView", + "title": "Open Deep Code", + "icon": { + "light": "resources/deepcoding_icon.svg", + "dark": "resources/deepcoding_icon.svg" + } + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "deepcode", + "title": "Deep Code", + "icon": "resources/deepcoding_icon.png" + } + ] + }, + "views": { + "deepcode": [ + { + "id": "deepcode.chatView", + "name": "Deep Code", + "icon": "resources/deepcoding_icon.png", + "type": "webview" + } + ] + }, + "menus": { + "editor/title": [ + { + "command": "deepcode.openView", + "group": "navigation@100" + } + ] + } + }, + "scripts": { + "typecheck": "tsc -p ./ --noEmit", + "build": "node ../../scripts/esbuild-vscode.config.js", + "prepublishOnly": "npm run build", + "package": "vsce package --no-dependencies", + "test": "node src/tests/run-tests.mjs" + }, + "dependencies": { + "@vegamo/deepcode-core": "file:../core", + "markdown-it": "^14.2.0" + }, + "devDependencies": { + "@types/markdown-it": "^14.1.1", + "@types/vscode": "^1.85.0", + "@vscode/vsce": "^3.6.0" + } +} diff --git a/packages/vscode-ide-companion/resources/deepcode_screenshot.png b/packages/vscode-ide-companion/resources/deepcode_screenshot.png new file mode 100644 index 00000000..3e1f2a9d Binary files /dev/null and b/packages/vscode-ide-companion/resources/deepcode_screenshot.png differ diff --git a/packages/vscode-ide-companion/resources/deepcoding_icon.png b/packages/vscode-ide-companion/resources/deepcoding_icon.png new file mode 100644 index 00000000..7268ce7e Binary files /dev/null and b/packages/vscode-ide-companion/resources/deepcoding_icon.png differ diff --git a/packages/vscode-ide-companion/resources/deepcoding_icon.svg b/packages/vscode-ide-companion/resources/deepcoding_icon.svg new file mode 100644 index 00000000..cabdba13 --- /dev/null +++ b/packages/vscode-ide-companion/resources/deepcoding_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/vscode-ide-companion/resources/faq1.gif b/packages/vscode-ide-companion/resources/faq1.gif new file mode 100644 index 00000000..93d69449 Binary files /dev/null and b/packages/vscode-ide-companion/resources/faq1.gif differ diff --git a/packages/vscode-ide-companion/resources/prompt-attachments.js b/packages/vscode-ide-companion/resources/prompt-attachments.js new file mode 100644 index 00000000..e6c628ac --- /dev/null +++ b/packages/vscode-ide-companion/resources/prompt-attachments.js @@ -0,0 +1,293 @@ +(function () { + const ATTACHMENT_LABEL = "粘贴的图像"; + const PREVIEW_OFFSET = 10; + + function createElement(tagName, className) { + const element = document.createElement(tagName); + if (className) { + element.className = className; + } + return element; + } + + function isImageFile(file) { + return Boolean(file && typeof file.type === "string" && file.type.startsWith("image/")); + } + + function readFileAsDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : ""); + reader.onerror = () => reject(reader.error || new Error("Failed to read file.")); + reader.readAsDataURL(file); + }); + } + + function pickImageFileFromClipboard(event) { + const items = Array.from(event.clipboardData?.items || []); + for (const item of items) { + if (item.kind !== "file") { + continue; + } + const file = item.getAsFile(); + if (isImageFile(file)) { + return file; + } + } + return null; + } + + function createPromptAttachmentManager(options) { + const promptInput = options?.promptInput; + const inputWrap = options?.inputWrap; + const toolsLine = options?.toolsLine; + const onAttachmentChange = + typeof options?.onAttachmentChange === "function" ? options.onAttachmentChange : function () {}; + + if (!promptInput || !inputWrap || !toolsLine) { + throw new Error("Prompt attachment manager requires promptInput, inputWrap, and toolsLine."); + } + + let attachments = []; + let nextAttachmentId = 0; + let previewPopup = null; + let previewImage = null; + let previewAnchor = null; + + function ensurePreviewPopup() { + if (previewPopup) { + return; + } + + previewPopup = createElement("div", "chat-attached-context-preview"); + previewImage = createElement("img", "chat-attached-context-preview-image"); + previewImage.alt = ATTACHMENT_LABEL; + previewPopup.appendChild(previewImage); + document.body.appendChild(previewPopup); + } + + function hidePreview() { + if (!previewPopup) { + return; + } + previewAnchor = null; + previewPopup.classList.remove("show"); + } + + function updatePreviewPosition(anchor) { + if (!previewPopup || !anchor) { + return; + } + + const rect = anchor.getBoundingClientRect(); + const popupRect = previewPopup.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let left = rect.left; + let top = rect.top - popupRect.height - PREVIEW_OFFSET; + + if (left + popupRect.width > viewportWidth - 12) { + left = viewportWidth - popupRect.width - 12; + } + if (left < 12) { + left = 12; + } + if (top < 12) { + top = rect.bottom + PREVIEW_OFFSET; + } + if (top + popupRect.height > viewportHeight - 12) { + top = Math.max(12, viewportHeight - popupRect.height - 12); + } + + previewPopup.style.left = left + "px"; + previewPopup.style.top = top + "px"; + } + + function showPreview(anchor, attachment) { + if (!attachment) { + return; + } + + ensurePreviewPopup(); + previewAnchor = anchor; + previewImage.src = attachment.dataUrl; + previewPopup.classList.add("show"); + updatePreviewPosition(anchor); + } + + function emitChange() { + onAttachmentChange({ + hasAttachments: attachments.length > 0, + attachments: attachments.slice(), + }); + } + + function clear() { + attachments = []; + toolsLine.innerHTML = ""; + toolsLine.classList.remove("has-attachment"); + hidePreview(); + emitChange(); + } + + function removeAttachment(id) { + const nextAttachments = attachments.filter((attachment) => attachment.id !== id); + if (nextAttachments.length === attachments.length) { + return; + } + attachments = nextAttachments; + render(); + emitChange(); + } + + function createAttachmentNode(attachment) { + const wrapper = createElement("div", "chat-attached-context-attachment show-file-icons"); + wrapper.tabIndex = 0; + wrapper.setAttribute("role", "button"); + wrapper.setAttribute("aria-label", ATTACHMENT_LABEL + " (删除)"); + wrapper.dataset.attachmentId = String(attachment.id); + wrapper.draggable = true; + + const removeButton = createElement("a", "monaco-button codicon codicon-close"); + removeButton.tabIndex = -1; + removeButton.setAttribute("role", "button"); + removeButton.setAttribute("aria-label", "从上下文中移除"); + removeButton.href = "#"; + removeButton.textContent = "×"; + removeButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + removeAttachment(attachment.id); + }); + + const iconLabel = createElement("div", "monaco-icon-label"); + const iconLabelContainer = createElement("div", "monaco-icon-label-container"); + const iconNameContainer = createElement("span", "monaco-icon-name-container"); + iconLabelContainer.appendChild(iconNameContainer); + iconLabel.appendChild(iconLabelContainer); + + const pill = createElement("div", "chat-attached-context-pill"); + const image = createElement("img", "chat-attached-context-pill-image"); + image.src = attachment.dataUrl; + image.alt = ATTACHMENT_LABEL; + pill.appendChild(image); + + const text = createElement("span", "chat-attached-context-custom-text"); + text.textContent = ATTACHMENT_LABEL; + + wrapper.appendChild(removeButton); + wrapper.appendChild(iconLabel); + wrapper.appendChild(pill); + wrapper.appendChild(text); + + const show = () => showPreview(wrapper, attachment); + wrapper.addEventListener("mouseenter", show); + wrapper.addEventListener("focus", show); + wrapper.addEventListener("mouseleave", hidePreview); + wrapper.addEventListener("blur", hidePreview); + wrapper.addEventListener("dragstart", (event) => { + event.preventDefault(); + }); + wrapper.addEventListener("keydown", (event) => { + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + removeAttachment(attachment.id); + } + }); + + return wrapper; + } + + function render() { + toolsLine.innerHTML = ""; + toolsLine.classList.toggle("has-attachment", attachments.length > 0); + if (attachments.length === 0) { + hidePreview(); + return; + } + for (const attachment of attachments) { + toolsLine.appendChild(createAttachmentNode(attachment)); + } + if (previewAnchor && !toolsLine.contains(previewAnchor)) { + hidePreview(); + } + } + + function addAttachmentData(data) { + if (!data?.dataUrl) { + return false; + } + + nextAttachmentId += 1; + attachments.push({ + id: nextAttachmentId, + name: data.name || ATTACHMENT_LABEL, + mimeType: data.mimeType || "image/png", + dataUrl: data.dataUrl, + label: ATTACHMENT_LABEL, + }); + render(); + emitChange(); + return true; + } + + async function addAttachmentFromFile(file) { + if (!isImageFile(file)) { + return false; + } + + const dataUrl = await readFileAsDataUrl(file); + return addAttachmentData({ + name: file.name || ATTACHMENT_LABEL, + mimeType: file.type || "image/png", + dataUrl, + label: ATTACHMENT_LABEL, + }); + } + + async function handlePaste(event) { + const file = pickImageFileFromClipboard(event); + if (!file) { + return; + } + + event.preventDefault(); + try { + await addAttachmentFromFile(file); + } catch (error) { + console.error("Failed to attach pasted image.", error); + } + } + + promptInput.addEventListener("paste", handlePaste); + + window.addEventListener("resize", () => { + if (previewPopup?.classList.contains("show") && previewAnchor) { + updatePreviewPosition(previewAnchor); + } + }); + + window.addEventListener( + "scroll", + () => { + if (previewPopup?.classList.contains("show") && previewAnchor) { + updatePreviewPosition(previewAnchor); + } + }, + true + ); + + return { + clear, + hasAttachments() { + return attachments.length > 0; + }, + getImageUrls() { + return attachments.map((attachment) => attachment.dataUrl); + }, + }; + } + + window.createPromptAttachmentManager = createPromptAttachmentManager; +})(); diff --git a/packages/vscode-ide-companion/resources/webview.css b/packages/vscode-ide-companion/resources/webview.css new file mode 100644 index 00000000..98ffa62e --- /dev/null +++ b/packages/vscode-ide-companion/resources/webview.css @@ -0,0 +1,1605 @@ +/* CSS Variables */ +:root { + --bg: var(--vscode-editor-background); + --panel: var(--vscode-sideBar-background); + --panel-2: var(--vscode-editor-inactiveSelectionBackground); + --muted: var(--vscode-descriptionForeground); + --accent: var(--vscode-focusBorder); + --accent-2: var(--vscode-button-background); + --danger: var(--vscode-errorForeground); + --shadow: rgba(0, 0, 0, 0.15); + --border-color: var(--vscode-panel-border); + --prompt-line-height: 18px; + --prompt-min-height: calc(var(--prompt-line-height) * 3 + 20px); + --prompt-max-height: calc(var(--prompt-line-height) * 10 + 20px); +} + +/* Global Styles */ +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + height: 100vh; + overflow-x: hidden; + background: var(--bg); + color: var(--vscode-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); +} + +/* App Container */ +.app { + height: 100vh; + display: flex; + flex-direction: column; + position: relative; + overflow-x: hidden; +} + +/* Header Container */ +.header-container { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 16px; + background: var(--vscode-sideBar-background); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; + position: relative; +} + +.header-left { + position: relative; + min-width: 0; +} + +/* Session Selector */ +.session-selector { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; + width: 100%; +} + +.session-selector:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.session-selector.open .session-selector-icon { + transform: rotate(180deg); +} + +.session-selector-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--vscode-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.session-logo { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.session-title-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-selector-icon { + width: 16px; + height: 16px; + fill: var(--vscode-foreground); + transition: transform 0.2s; + flex-shrink: 0; +} + +/* Header New Button */ +.header-new-btn { + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: background 0.2s; + background: none; + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + flex-shrink: 0; +} + +.header-new-btn:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.header-new-icon { + width: 16px; + height: 16px; + fill: var(--vscode-foreground); +} + +/* Session Dropdown */ +.session-dropdown { + position: absolute; + top: calc(100% + 8px); + left: 16px; + right: 16px; + background: var(--vscode-dropdown-background); + border: 1px solid var(--border-color); + border-radius: 4px; + max-height: 400px; + z-index: 10000; + display: none; + box-shadow: 0 4px 12px var(--shadow); + flex-direction: column; +} + +.session-dropdown.show { + display: flex; +} + +.session-search-box { + padding: 8px; + flex-shrink: 0; +} + +.session-search-input { + width: 100%; + padding: 6px 10px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 4px; + font-size: 13px; + outline: none; + font-family: var(--vscode-font-family); + outline: none; +} + +.session-search-input:focus { + border-color: var(--vscode-focusBorder); + outline: none; +} + +.session-search-input::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.session-dropdown-list { + flex: 1; + overflow-y: auto; + min-height: 0; + padding: 8px 12px 12px; +} + +.session-dropdown-group { + margin-bottom: 12px; +} + +.session-dropdown-group:last-child { + margin-bottom: 0; +} + +.session-dropdown-group-title { + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + letter-spacing: 0.5px; + padding: 6px 6px 8px; + margin-bottom: 2px; +} + +.session-dropdown-empty { + padding: 20px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 13px; +} + +.session-dropdown-item { + padding: 6px; + cursor: pointer; + transition: background 0.2s; + display: flex; + gap: 12px; + margin-bottom: 6px; + border-radius: 4px; + align-items: center; +} + +.session-dropdown-item:last-child { + margin-bottom: 0; +} + +.session-dropdown-item:hover { + background: var(--vscode-list-hoverBackground); +} + +.session-dropdown-item.active { + background: var(--vscode-list-activeSelectionBackground); +} + +.session-dropdown-item.active .session-dropdown-summary { + color: var(--vscode-list-activeSelectionForeground); +} + +.session-dropdown-item.active .session-dropdown-time { + color: var(--vscode-list-activeSelectionForeground); + opacity: 0.8; +} + +.session-dropdown-summary { + font-size: 13px; + color: var(--vscode-foreground); + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + font-weight: 500; +} + +.session-dropdown-time { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.session-dropdown-summary mark { + background-color: var(--vscode-editor-findMatchHighlightBackground); + color: var(--vscode-editor-foreground); + padding: 0 2px; + border-radius: 2px; +} + +/* Chat Container */ +.chat-container { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + overflow-x: hidden; +} + +.chat-container.hidden { + display: none; +} + +/* Messages */ +.messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px; + display: flex; + flex-direction: column; + gap: 1px; + background: var(--vscode-sideBar-background); +} + +/* Message Bubbles */ +.bubble { + padding: 16px; + font-size: 13px; + width: 100%; + border: 1px solid transparent; + color: var(--vscode-foreground); + border-radius: 6px; + position: relative; + overflow-wrap: break-word; + word-break: break-word; +} + +/* Collapsible Bubble */ +.bubble-collapsible-header { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + user-select: none; + padding: 4px 0; + position: relative; +} + +.bubble-collapsible-header:hover .bubble-title-text { + opacity: 0.8; +} + +.bubble-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--vscode-descriptionForeground); + flex-shrink: 0; + margin-top: 6px; + position: relative; + z-index: 3; +} + +.bubble-dot.success { + background-color: var(--vscode-terminal-ansiGreen); +} + +.bubble-dot.error { + background-color: var(--vscode-terminal-ansiRed); +} + +/* 连接线:只在有 connect-to-prev class 时显示向上的连接线 */ +.bubble-dot.connect-to-prev::before { + content: ""; + position: absolute; + left: 3.5px; + bottom: 8px; + width: 1px; + height: var(--line-height, 0px); + background-color: var(--vscode-panel-border); + z-index: 0; +} + +.bubble-title { + flex: 1; + font-size: 13px; + color: var(--vscode-foreground); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + min-width: 0; + overflow: visible; +} + +.bubble-title-text { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; + overflow: visible; +} + +.bubble-title-text b { + text-transform: capitalize; + flex-shrink: 0; +} + +.bubble-title .tool-params { + color: var(--vscode-descriptionForeground); + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + position: relative; + display: inline-block; +} + +/* Tooltip styles */ +.tooltip { + position: fixed; + padding: 4px 8px; + background: var(--vscode-editorHoverWidget-background); + color: var(--vscode-editorHoverWidget-foreground); + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: 4px; + font-size: 12px; + white-space: normal; + word-break: break-word; + max-width: 400px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 10000; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: + opacity 0.5s, + visibility 0.5s; +} + +.tooltip.show { + opacity: 1; + visibility: visible; +} + +.bubble-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.bubble-toggle svg { + width: 12px; + height: 12px; + fill: var(--vscode-descriptionForeground); + transition: transform 0.2s; +} + +.bubble-toggle.expanded svg { + transform: rotate(180deg); +} + +.bubble-collapsible-content { + margin-top: 8px; + margin-left: 24px; + padding: 8px 12px; + background: var(--vscode-textCodeBlock-background); + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: auto; + max-width: calc(100% - 24px); + font-family: var(--vscode-editor-font-family); + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; + max-height: 300px; +} + +.tool-result-details { + display: flex; + flex-direction: column; + gap: 10px; + font-family: var(--vscode-font-family); + white-space: normal; +} + +.tool-result-summary { + color: var(--vscode-foreground); + line-height: 1.5; +} + +.update-plan-result { + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-size: 13px; + line-height: 1.45; + padding: 10px; + white-space: normal; +} + +.update-plan-heading { + color: var(--vscode-foreground); + font-size: 13px; + font-weight: 650; + line-height: 1.35; + margin: 0 0 8px; +} + +.update-plan-heading:not(:first-child) { + margin-top: 12px; +} + +.update-plan-spacer { + height: 6px; +} + +.update-plan-paragraph { + margin: 4px 0; +} + +.update-plan-task, +.update-plan-bullet { + display: grid; + grid-template-columns: 18px 1fr; + gap: 8px; + align-items: flex-start; + margin: 5px 0; + padding-left: calc(var(--plan-indent, 0) * 18px); +} + +.update-plan-status { + width: 14px; + height: 14px; + margin-top: 2px; + border: 1px solid var(--vscode-descriptionForeground); + border-radius: 50%; + color: var(--vscode-editor-background); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + line-height: 1; +} + +.update-plan-task.status-done .update-plan-status { + background: var(--vscode-testing-iconPassed, #2ea043); + border-color: var(--vscode-testing-iconPassed, #2ea043); +} + +.update-plan-task.status-active .update-plan-status { + background: var(--vscode-charts-blue, #3794ff); + border-color: var(--vscode-charts-blue, #3794ff); +} + +.update-plan-task.status-attention .update-plan-status { + background: var(--vscode-testing-iconFailed, #f85149); + border-color: var(--vscode-testing-iconFailed, #f85149); +} + +.update-plan-task.status-todo .update-plan-status { + background: transparent; +} + +.update-plan-task.status-done .update-plan-task-text { + color: var(--vscode-descriptionForeground); + text-decoration: line-through; +} + +.update-plan-bullet-marker { + color: var(--vscode-descriptionForeground); + line-height: 1.45; + text-align: center; +} + +.update-plan-markdown code { + border-radius: 3px; + background: var(--vscode-textCodeBlock-background); + font-family: var(--vscode-editor-font-family); + font-size: 0.92em; + padding: 1px 4px; +} + +.tool-result-file-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.tool-result-label { + color: var(--vscode-descriptionForeground); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.tool-result-filepath { + border: none; + background: none; + color: var(--vscode-textLink-foreground, var(--vscode-button-background)); + cursor: pointer; + font: inherit; + padding: 0; + text-decoration: underline; + text-underline-offset: 2px; + word-break: break-all; +} + +.tool-result-filepath:hover { + color: var(--vscode-textLink-activeForeground, var(--vscode-button-hoverBackground)); +} + +.tool-result-diff { + display: flex; + flex-direction: column; + gap: 6px; +} + +.diff-preview { + border: 1px solid var(--border-color); + border-radius: 6px; + overflow: hidden; + background: var(--vscode-editor-background); +} + +.diff-line { + display: grid; + grid-template-columns: 20px 1fr; + gap: 0; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + line-height: 1.2; +} + +.diff-line-marker { + display: flex; + align-items: flex-start; + justify-content: center; + padding: 1px 0; + color: var(--vscode-descriptionForeground); + user-select: none; +} + +.diff-line-content { + display: block; + padding: 1px 10px 1px 0; + white-space: pre-wrap; + word-break: break-word; +} + +.diff-line.added { + background: color-mix( + in srgb, + var(--vscode-diffEditor-insertedLineBackground, rgba(46, 160, 67, 0.18)) 100%, + transparent + ); +} + +.diff-line.removed { + background: color-mix( + in srgb, + var(--vscode-diffEditor-removedLineBackground, rgba(248, 81, 73, 0.16)) 100%, + transparent + ); +} + +.diff-line.context { + background: var(--vscode-editor-background); +} + +/* assistant 角色的展开内容无边框 */ +.bubble.assistant .bubble-collapsible-content { + border: none; + background: transparent; + padding: 8px 0; +} + +.bubble-collapsible-content.collapsed { + display: none; +} + +/* 普通assistant气泡布局 */ +.bubble.assistant:has(.bubble-normal-content) { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.bubble.assistant:has(.bubble-normal-content) > .bubble-dot { + margin-top: 6px; +} + +.bubble-normal-content { + flex: 1; + min-width: 0; +} + +/* 复制按钮 */ +.bubble-copy-btn { + display: none; + position: absolute; + top: 8px; + right: 8px; + width: 26px; + height: 26px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--vscode-editor-background); + color: var(--vscode-descriptionForeground); + cursor: pointer; + align-items: center; + justify-content: center; + transition: + opacity 0.15s, + background 0.15s; + z-index: 5; +} + +.bubble-copy-btn:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.bubble-copy-btn.copied { + color: var(--vscode-testing-iconPassed, #2ea043); + border-color: var(--vscode-testing-iconPassed, #2ea043); +} + +.bubble-copy-btn svg { + width: 14px; + height: 14px; + fill: currentColor; + pointer-events: none; +} + +.bubble.assistant:hover .bubble-copy-btn { + display: flex; +} + +.bubble.user { + background: var(--vscode-editor-background); + border-color: var(--vscode-panel-border); + padding: 4px 6px; + margin: 12px 0 12px auto; + white-space: pre-wrap; + word-break: break-word; + width: fit-content; + max-width: 80%; +} + +.bubble.system { + background: transparent; + border-color: transparent; + padding: 4px 16px; +} + +.bubble.assistant { + background: transparent; + border-color: transparent; + padding: 4px 16px; +} + +.bubble.tool { + background: transparent; + border-color: transparent; + padding: 4px 16px; +} + +.bubble p { + margin: 8px 0; + overflow-wrap: break-word; + word-break: break-word; +} + +.bubble p:first-of-type { + margin-top: 0; +} + +.bubble p:last-of-type { + margin-bottom: 0; +} + +.bubble pre { + padding: 12px; + border-radius: 4px; + overflow-x: auto; + max-width: 100%; + color: var(--vscode-editor-foreground); + background: var(--vscode-textCodeBlock-background); + margin: 8px 0; + border: 1px solid var(--vscode-panel-border); + white-space: pre-wrap; + word-break: break-word; +} + +.bubble code { + font-family: var(--vscode-editor-font-family); + font-size: 12px; + color: var(--vscode-editor-foreground); + background-color: unset; + overflow-wrap: break-word; + word-break: break-word; +} + +.ask-user-question { + display: flex; + flex-direction: column; + gap: 12px; + font-family: var(--vscode-font-family); + font-size: 13px; + white-space: normal; +} + +.ask-user-question-intro { + color: var(--vscode-descriptionForeground); +} + +.ask-user-question-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.ask-user-question-content { + overflow: visible; + max-height: none; +} + +.ask-user-question-block { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ask-user-question-title { + padding: 0 4px; + font-weight: 600; +} + +.ask-user-question-option { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; +} + +.ask-user-question-option input { + margin: 2px 0 0; +} + +.ask-user-question-option-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ask-user-question-option-description { + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.ask-user-question-other { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ask-user-question-other-label { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.ask-user-question-other textarea { + min-height: 56px; + max-height: 120px; + resize: none; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); +} + +.ask-user-question-actions { + margin-top: 10px; +} + +.ask-user-question-error { + color: var(--vscode-errorForeground); + font-size: 12px; + display: none; + margin-bottom: 6px; +} + +.ask-user-question-error:not(:empty) { + display: block; +} + +.ask-user-question-submit { + align-self: flex-start; + border: 1px solid transparent; + border-radius: 4px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + padding: 6px 12px; + cursor: pointer; + font-size: 12px; +} + +.ask-user-question-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.ask-user-question-empty { + color: var(--vscode-descriptionForeground); +} + +.permission-prompt-host { + margin-bottom: 10px; +} + +.permission-card { + border: 1px solid var(--vscode-panel-border); + border-left: 3px solid var(--vscode-notificationsWarningIcon-foreground, #f59e0b); + border-radius: 6px; + background: var(--vscode-input-background); + box-shadow: 0 4px 14px var(--shadow); + padding: 12px; +} + +.permission-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.permission-title { + color: var(--vscode-notificationsWarningIcon-foreground, #f59e0b); + font-weight: 700; +} + +.permission-progress { + margin-top: 2px; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.permission-close { + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 18px; + line-height: 1; + padding: 0 4px; +} + +.permission-close:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.permission-tool { + margin-top: 10px; + font-weight: 700; +} + +.permission-command { + margin-top: 6px; + border-radius: 4px; + background: var(--vscode-textCodeBlock-background); + color: var(--vscode-editor-foreground); + font-family: var(--vscode-editor-font-family); + font-size: 12px; + line-height: 1.45; + padding: 8px; + white-space: pre-wrap; + word-break: break-word; +} + +.permission-description { + margin-top: 8px; + color: var(--vscode-descriptionForeground); + line-height: 1.45; +} + +.permission-scope { + display: inline-flex; + align-items: center; + margin-top: 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + padding: 3px 8px; +} + +.permission-scope.risk-low { + background: rgba(34, 197, 94, 0.14); + color: #22c55e; +} + +.permission-scope.risk-medium { + background: rgba(245, 158, 11, 0.14); + color: #f59e0b; +} + +.permission-scope.risk-high { + background: rgba(239, 68, 68, 0.14); + color: #ef4444; +} + +.permission-question { + margin-top: 10px; + font-weight: 600; +} + +.permission-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.permission-action { + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 4px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + cursor: pointer; + font-size: 12px; + padding: 6px 10px; +} + +.permission-action:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +.permission-allow, +.permission-always { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.permission-allow:hover, +.permission-always:hover { + background: var(--vscode-button-hoverBackground); +} + +.permission-deny { + border-color: var(--vscode-inputValidation-errorBorder, #ef4444); + color: var(--vscode-errorForeground, #ef4444); +} + +.permission-denied-card { + border-left-color: var(--vscode-errorForeground, #ef4444); +} + +.permission-denied-card .permission-title { + color: var(--vscode-errorForeground, #ef4444); +} + +/* Spinner dot for live thinking bubble */ +.bubble-dot.spinner-dot { + background: transparent !important; +} + +/* 旋转环用 ::after 实现,不影响 ::before 连线 */ +.bubble-dot.spinner-dot::after { + content: ""; + position: absolute; + top: -2px; + left: -2px; + width: calc(100% + 4px); + height: calc(100% + 4px); + border-radius: 50%; + border: 2px solid var(--vscode-progressBar-background); + border-top-color: var(--accent); + animation: spin 0.8s linear infinite; + box-sizing: border-box; + pointer-events: none; + z-index: 1; +} + +/* Thinking bubble: 标题和内容同行显示 */ +.bubble[data-thinking-live="true"] .thinking-status { + color: var(--vscode-descriptionForeground); + font-weight: normal; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Composer */ +.composer { + position: relative; + padding: 12px 16px 16px; + background: var(--vscode-sideBar-background); + flex-shrink: 0; +} + +.input-wrap { + position: relative; + display: flex; + flex-direction: column; + min-height: calc(var(--prompt-min-height) + 42px); + border-radius: 4px; + border: 1px solid var(--border-color); + background: var(--vscode-input-background); + transition: + border-color 0.15s ease, + outline-color 0.15s ease, + background-color 0.15s ease; +} + +.input-wrap:focus-within { + border-color: var(--vscode-focusBorder); + outline: 1px solid var(--vscode-focusBorder); +} + +.tools-line { + display: none; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-height: 0; + padding: 0 12px; +} + +.tools-line.has-attachment { + display: flex; + min-height: 34px; + padding: 10px 12px 0; +} + +.composer-footer { + display: flex; + height: 42px; + width: 100%; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; + position: relative; +} + +/* Textarea */ +textarea { + width: 100%; + min-height: var(--prompt-min-height); + max-height: var(--prompt-max-height); + resize: none; + overflow-y: hidden; + border: none; + border-radius: 4px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + padding: 10px 12px; + font-size: 13px; + line-height: var(--prompt-line-height); + outline: none; + font-family: var(--vscode-font-family); +} + +textarea:focus { + outline: none; +} + +textarea::placeholder { + color: var(--vscode-input-placeholderForeground); +} + +.chat-attached-context-attachment { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; + padding: 4px; + border: 1px solid var(--vscode-editorWidget-background, var(--vscode-input-background)); + border-radius: 6px; + color: var(--vscode-foreground); +} + +.chat-attached-context-attachment:hover { + border-color: var(--vscode-focusBorder); +} + +.chat-attached-context-attachment .monaco-button.codicon-close { + display: inline-flex; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + color: var(--vscode-descriptionForeground); + text-decoration: none; + flex-shrink: 0; +} + +.chat-attached-context-attachment .monaco-button.codicon-close:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.monaco-icon-label, +.monaco-icon-label-container, +.monaco-icon-name-container { + display: none; +} + +.chat-attached-context-pill { + width: 13px; + height: 13px; + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; + background: var(--vscode-editor-background); +} + +.chat-attached-context-pill-image { + display: block; + width: 13px; + height: 13px; + margin: 1.5px; + object-fit: cover; + border-radius: 2px; +} + +.chat-attached-context-custom-text { + font-size: 12px; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chat-attached-context-preview { + position: fixed; + display: none; + padding: 10px; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); + box-shadow: 0 8px 24px var(--shadow); + z-index: 10001; + pointer-events: none; +} + +.chat-attached-context-preview.show { + display: block; +} + +.chat-attached-context-preview-image { + display: block; + max-width: min(520px, 70vw); + max-height: min(420px, 65vh); + border-radius: 6px; + object-fit: contain; +} + +/* Send Button */ +.send-button { + width: 30px; + height: 30px; + border-radius: 50%; + border: none; + cursor: pointer; + display: grid; + place-items: center; + background: transparent; +} + +.context-meter { + position: relative; + width: 18px; + height: 18px; + display: grid; + place-items: center; + flex-shrink: 0; + z-index: 3; +} + +.context-meter-ring { + --context-percent: 0%; + width: 14px; + height: 14px; + border-radius: 50%; + background: + radial-gradient(circle at center, var(--vscode-input-background) 36%, transparent 38%), + conic-gradient( + var(--vscode-descriptionForeground) 0 var(--context-percent), + var(--vscode-editorWidget-border, var(--vscode-panel-border)) var(--context-percent) 100% + ); +} + +.context-meter:hover .context-meter-ring { + filter: brightness(1.1); +} + +.context-meter-tooltip { + position: fixed; + left: 16px; + bottom: calc(100% + 10px); + display: none; + width: min(360px, calc(100vw - 32px)); + max-height: min(420px, 70vh); + overflow: auto; + padding: 12px 14px; + border: 1px solid var(--vscode-editorHoverWidget-border, var(--border-color)); + border-radius: 12px; + background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); + color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); + box-shadow: 0 8px 24px var(--shadow); + font-size: 12px; + line-height: 1.4; + pointer-events: none; + z-index: 10002; +} + +.context-meter:hover .context-meter-tooltip { + display: block; +} + +.context-tooltip-title { + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 13px; + margin-bottom: 4px; +} + +.context-tooltip-summary { + text-align: center; + font-size: 16px; + font-weight: 600; + margin-bottom: 10px; +} +.context-tooltip-section { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--border-color); + color: var(--vscode-descriptionForeground); + font-weight: 600; +} + +.context-tooltip-row { + display: flex; + justify-content: space-between; + gap: 12px; + margin-top: 4px; +} + +.context-tooltip-label { + min-width: 0; + color: var(--vscode-descriptionForeground); + overflow-wrap: anywhere; +} + +.context-tooltip-value { + max-width: 55%; + text-align: right; + font-family: var(--vscode-editor-font-family); + overflow-wrap: anywhere; +} + +.send-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.send-icon { + width: 26px; + height: 26px; + fill: currentColor; + color: var(--vscode-foreground); + transition: opacity 0.2s; +} + +#sendIcon { + color: var(--vscode-descriptionForeground); +} + +#stopIcon { + display: none; + color: var(--vscode-foreground); +} + +.send-icon.empty { + opacity: 0.6; +} + +/* Skills Button */ +.skills-button { + height: 22px; + border-radius: 4px; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + background: transparent; + padding: 0; + transition: opacity 0.2s; + flex-shrink: 0; + color: var(--vscode-foreground); +} + +.skills-button span { + margin-top: 4px; +} + +.skills-button:hover { + opacity: 0.8; +} + +.skills-button svg { + width: 16px; + height: 16px; + fill: var(--vscode-foreground); +} + +/* Skills Popup */ +.skills-popup { + position: absolute; + bottom: calc(100% + 8px); + left: 16px; + right: 16px; + background: var(--vscode-dropdown-background); + border: 1px solid var(--border-color); + border-radius: 4px; + max-height: 300px; + overflow-y: auto; + z-index: 10000; + display: none; + box-shadow: 0 4px 12px var(--shadow); +} + +.skills-popup.show { + display: block; +} + +.skills-popup-header { + padding: 8px 12px; + font-size: 12px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid var(--border-color); +} + +.skills-popup-list { + padding: 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.skills-popup-empty { + padding: 12px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 13px; +} + +.skills-popup-item { + display: flex; + align-items: center; + padding: 6px 10px; + cursor: pointer; + border-radius: 4px; + font-size: 13px; + transition: background 0.2s; + gap: 8px; +} + +.skills-popup-item:hover { + background: var(--vscode-list-hoverBackground); +} + +.skills-popup-item.selected { + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); +} + +.skills-popup-item-name { + flex: 0 0 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skills-popup-item-path { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.skills-popup-item:hover .skills-popup-item-path, +.skills-popup-item.selected .skills-popup-item-path { + color: inherit; +} + +.skills-popup-item-loaded { + color: var(--vscode-testing-icon-success); + font-weight: bold; + font-size: 14px; + margin-left: 4px; +} + +/* Skills Bar (contains button and tags) */ +.skills-bar { + display: flex; + align-items: center; + gap: 8px; + width: calc(100% - 60px); +} + +/* Skills Tags */ +.skills-tags { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + flex: 1; + min-width: 0; +} + +.skills-tags-inner { + width: 100%; + overflow: auto; + white-space: nowrap; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.skills-tags-inner::-webkit-scrollbar { + width: 0; + height: 0; +} + +.skill-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-radius: 4px; + font-size: 12px; + margin-right: 4px; +} + +.skill-tag-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skill-tag-remove { + cursor: pointer; + font-size: 14px; + line-height: 1; + opacity: 0.7; + transition: opacity 0.2s; +} + +.skill-tag-remove:hover { + opacity: 1; +} + +/* Prevent horizontal overflow for all markdown content */ +.bubble table { + display: block; + overflow-x: auto; + max-width: 100%; +} + +.bubble img { + max-width: 100%; + height: auto; +} + +.bubble ul, +.bubble ol { + padding-left: 1.5em; + overflow-wrap: break-word; +} + +.bubble li { + overflow-wrap: break-word; + word-break: break-word; +} + +.bubble blockquote { + overflow-wrap: break-word; + word-break: break-word; +} + +.bubble a { + overflow-wrap: break-word; + word-break: break-all; +} diff --git a/packages/vscode-ide-companion/resources/webview.html b/packages/vscode-ide-companion/resources/webview.html new file mode 100644 index 00000000..1290f8d6 --- /dev/null +++ b/packages/vscode-ide-companion/resources/webview.html @@ -0,0 +1,2354 @@ + + + + + + + Deep Code + + + +
+
+
+
+
+ + Deep Code +
+ +
+
+ +
+
+ + +
+
+
+ +
+
Select Skills
+
+
+ +
+
+ + +
+
+
+
+ + + + + diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts new file mode 100644 index 00000000..44542e79 --- /dev/null +++ b/packages/vscode-ide-companion/src/extension.ts @@ -0,0 +1,562 @@ +import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import OpenAI from "openai"; +import MarkdownIt from "markdown-it"; +import type { SessionMessage } from "@vegamo/deepcode-core"; +import { + SessionManager, + getCompactPromptTokenThreshold, + type LlmStreamProgress, + type PermissionScope, + type SessionEntry, + type SkillInfo, + type UserPromptContent, + type UserToolPermission, + resolveSettingsSources, + type DeepcodingSettings, + type ReasoningEffort, + type ResolvedDeepcodingSettings, + setShellIfWindows, +} from "@vegamo/deepcode-core"; +import { getNonce } from "./utils.js"; +import { handleWebviewMessage } from "./provider.js"; + +const DEFAULT_MODEL = "deepseek-v4-pro"; +const DEFAULT_BASE_URL = "https://api.deepseek.com"; + +type ReasoningMessageParams = { + reasoning_content?: string; +}; + +export class DeepCodeViewProvider implements vscode.WebviewViewProvider { + public static readonly viewType = "deepcode.chatView"; + + private readonly context: vscode.ExtensionContext; + private webviewView: vscode.WebviewView | undefined; + private readonly md: MarkdownIt; + private readonly sessionManager: SessionManager; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + this.md = new MarkdownIt({ + html: false, + linkify: false, + breaks: true, + }); + this.sessionManager = new SessionManager({ + projectRoot: this.getWorkspaceRoot(), + createOpenAIClient: () => this.createOpenAIClient(), + getResolvedSettings: () => this.resolveCurrentSettings(), + renderMarkdown: (text) => this.md.render(text), + onAssistantMessage: (message: SessionMessage, shouldConnect: boolean) => { + if (!this.webviewView) { + return; + } + if (message.visible === false) { + return; + } + if (message.role !== "tool") { + const reasoningContent = (message.messageParams as ReasoningMessageParams | null)?.reasoning_content; + message.html = this.md.render(message.content || reasoningContent || ""); + } + this.webviewView.webview.postMessage({ type: "appendMessage", message, shouldConnect }); + }, + onSessionEntryUpdated: (entry) => { + if (!this.webviewView) { + return; + } + this.webviewView.webview.postMessage({ + type: "sessionStatus", + sessionId: entry.id, + status: entry.status, + askPermissions: entry.askPermissions, + processes: this.serializeProcesses(entry.processes), + tokenTelemetry: this.buildTokenTelemetry(entry), + }); + }, + onLlmStreamProgress: (progress: LlmStreamProgress) => { + if (!this.webviewView) { + return; + } + this.webviewView.webview.postMessage({ + type: "llmStreamProgress", + progress, + }); + }, + }); + void this.initializeMcpServers(); + } + + dispose(): void { + this.sessionManager.dispose(); + } + + resolveWebviewView(webviewView: vscode.WebviewView): void { + this.webviewView = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.context.extensionUri], + }; + + webviewView.webview.html = this.getWebviewHtml(webviewView.webview); + + webviewView.webview.onDidReceiveMessage(async (message) => { + const msg = message as Record | undefined; + + // openFile requires vscode API, handle here directly + if (msg?.type === "openFile") { + const filePath = String(msg.filePath || "").trim(); + const line = Number(msg.line || 1); + if (filePath) { + await this.openFileInEditor(filePath, line); + } + return; + } + + const handled = await handleWebviewMessage(message, { + sessionManager: this.sessionManager, + postMessage: (m) => this.webviewView?.webview.postMessage(m), + renderMarkdown: (text) => this.md.render(text), + copyToClipboard: (text) => void vscode.env.clipboard.writeText(text), + }); + + if (!handled) { + // unrecognized message type — no-op + } + }); + } + + private async loadInitialSession(): Promise { + const sessions = this.sessionManager.listSessions(); + const sessionsList = sessions.map((s) => ({ + id: s.id, + summary: s.summary || "Untitled", + createTime: s.createTime, + updateTime: s.updateTime, + status: s.status, + })); + + if (sessions.length === 0) { + // 没有历史会话,显示新对话界面 + this.sendMessage({ + type: "initializeEmpty", + sessions: sessionsList, + status: null, + tokenTelemetry: this.buildTokenTelemetry(null), + }); + return; + } + + // 显示最新的对话 + const latestSession = sessions[0]; + this.loadSession(latestSession.id); + } + + private loadSession(sessionId: string): void { + const session = this.sessionManager.getSession(sessionId); + if (!session) { + return; + } + + // 设置为活动会话 + this.sessionManager.setActiveSessionId(sessionId); + + const messages = this.sessionManager.listSessionMessages(sessionId); + + // 获取所有会话列表 + const sessions = this.sessionManager.listSessions(); + const sessionsList = sessions.map((s) => ({ + id: s.id, + summary: s.summary || "Untitled", + createTime: s.createTime, + updateTime: s.updateTime, + status: s.status, + })); + + // 发送对话信息到 webview + this.sendMessage({ + type: "loadSession", + sessionId, + summary: session.summary || "Untitled", + status: session.status, + askPermissions: session.askPermissions, + processes: this.serializeProcesses(session.processes), + tokenTelemetry: this.buildTokenTelemetry(session), + sessions: sessionsList, + messages: messages + .filter((m) => m.visible) + .map((m) => ({ + role: m.role, + content: m.content, + html: + m.role !== "tool" + ? this.md.render(m.content || (m.messageParams as ReasoningMessageParams | null)?.reasoning_content || "") + : undefined, + meta: m.meta, + })), + }); + } + + private showSessionsList(): void { + const sessions = this.sessionManager.listSessions(); + this.sendMessage({ + type: "showSessionsList", + sessions: sessions.map((s) => ({ + id: s.id, + summary: s.summary || "Untitled", + createTime: s.createTime, + updateTime: s.updateTime, + status: s.status, + })), + }); + } + + private async createNewSession(): Promise { + // 清除当前活动会话 + this.sessionManager.setActiveSessionId(null); + + // 获取所有会话列表 + const sessions = this.sessionManager.listSessions(); + const sessionsList = sessions.map((s) => ({ + id: s.id, + summary: s.summary || "Untitled", + createTime: s.createTime, + updateTime: s.updateTime, + status: s.status, + })); + + this.sendMessage({ + type: "initializeEmpty", + sessions: sessionsList, + status: null, + tokenTelemetry: this.buildTokenTelemetry(null), + }); + await this.sendSkillsList(); + } + + private sendMessage(message: unknown): void { + if (!this.webviewView) { + return; + } + this.webviewView.webview.postMessage(message); + } + + private async sendSkillsList(sessionId?: string): Promise { + if (!this.webviewView) { + return; + } + const skills = await this.sessionManager.listSkills( + sessionId ?? this.sessionManager.getActiveSessionId() ?? undefined + ); + this.sendMessage({ type: "skillsList", skills }); + } + + private async handlePrompt( + prompt: string, + skills?: SkillInfo[], + imageUrls?: string[], + options: { permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[] } = {} + ): Promise { + if (!this.webviewView) { + return; + } + + const webview = this.webviewView.webview; + const normalizedImages = Array.isArray(imageUrls) ? imageUrls.filter(Boolean) : []; + const displayPrompt = prompt || (normalizedImages.length > 0 ? "粘贴的图像" : ""); + const isPermissionContinue = + prompt === "/continue" && + normalizedImages.length === 0 && + ((options.permissions?.length ?? 0) > 0 || (options.alwaysAllows?.length ?? 0) > 0); + + // 先显示用户消息(原始文本,不做 HTML 格式化) + if (displayPrompt && !isPermissionContinue) { + webview.postMessage({ type: "userMessage", content: displayPrompt }); + } + + webview.postMessage({ type: "loading", value: true }); + + try { + const userPrompt: UserPromptContent = { + text: prompt, + skills, + imageUrls: normalizedImages, + permissions: options.permissions, + alwaysAllows: options.alwaysAllows, + }; + await this.sessionManager.handleUserPrompt(userPrompt); + await this.sendSkillsList(); + + const activeSessionId = this.sessionManager.getActiveSessionId(); + const activeSession = activeSessionId ? this.sessionManager.getSession(activeSessionId) : null; + if (activeSessionId && activeSession) { + webview.postMessage({ + type: "sessionStatus", + sessionId: activeSessionId, + status: activeSession.status, + askPermissions: activeSession.askPermissions, + processes: this.serializeProcesses(activeSession.processes), + tokenTelemetry: this.buildTokenTelemetry(activeSession), + }); + } + + // 发送更新后的会话列表(可能创建了新会话) + const sessions = this.sessionManager.listSessions(); + const sessionsList = sessions.map((s) => ({ + id: s.id, + summary: s.summary || "Untitled", + createTime: s.createTime, + updateTime: s.updateTime, + status: s.status, + })); + webview.postMessage({ + type: "showSessionsList", + sessions: sessionsList, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + webview.postMessage({ + type: "assistant", + html: this.md.render(`Request failed: ${message}`), + }); + } finally { + webview.postMessage({ type: "loading", value: false }); + } + } + + private handlePermissionDenied(sessionId: string): void { + this.sessionManager.denySessionPermission(sessionId); + const session = this.sessionManager.getSession(sessionId); + if (session) { + this.sendMessage({ + type: "sessionStatus", + sessionId, + status: session.status, + askPermissions: session.askPermissions, + processes: this.serializeProcesses(session.processes), + tokenTelemetry: this.buildTokenTelemetry(session), + }); + } + this.showSessionsList(); + } + + private createOpenAIClient(): { + client: OpenAI | null; + model: string; + baseURL: string; + thinkingEnabled: boolean; + reasoningEffort: ReasoningEffort; + debugLogEnabled: boolean; + notify?: string; + webSearchTool?: string; + env?: Record; + machineId?: string; + } { + const settings = this.resolveCurrentSettings(); + + const { apiKey, baseURL, model, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, webSearchTool, env } = + settings; + const machineId = vscode.env.machineId; + + if (!apiKey) { + return { + client: null, + model, + baseURL, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, + notify, + webSearchTool, + env, + machineId, + }; + } + + const client = new OpenAI({ + apiKey, + baseURL: baseURL || undefined, + }); + + return { + client, + model, + baseURL, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, + notify, + webSearchTool, + env, + machineId, + }; + } + + private buildTokenTelemetry(session: SessionEntry | null): { + model: string; + thinkingEnabled: boolean; + reasoningEffort: ReasoningEffort; + activeTokens: number; + compactPromptTokenThreshold: number; + usage: unknown | null; + } { + const settings = this.resolveCurrentSettings(); + return { + model: settings.model, + thinkingEnabled: settings.thinkingEnabled, + reasoningEffort: settings.reasoningEffort, + activeTokens: session?.activeTokens ?? 0, + compactPromptTokenThreshold: getCompactPromptTokenThreshold(settings.model), + usage: session?.usage ?? null, + }; + } + + private async initializeMcpServers(): Promise { + try { + await this.sessionManager.initMcpServers(this.resolveCurrentSettings().mcpServers); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(`Failed to initialize MCP servers: ${message}`); + } + } + + private resolveCurrentSettings(): ResolvedDeepcodingSettings { + return resolveSettingsSources( + this.readUserSettings(), + this.readProjectSettings(), + { + model: DEFAULT_MODEL, + baseURL: DEFAULT_BASE_URL, + }, + process.env + ); + } + + private readUserSettings(): DeepcodingSettings | null { + try { + const settingsPath = path.join(os.homedir(), ".deepcode", "settings.json"); + if (!fs.existsSync(settingsPath)) { + return null; + } + + const raw = fs.readFileSync(settingsPath, "utf8"); + return JSON.parse(raw) as DeepcodingSettings; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to read ~/.deepcode/settings.json: ${message}`); + return null; + } + } + + private readProjectSettings(): DeepcodingSettings | null { + const workspaceRoot = this.getWorkspaceRoot(); + try { + const settingsPath = path.join(workspaceRoot, ".deepcode", "settings.json"); + if (!fs.existsSync(settingsPath)) { + return null; + } + + const raw = fs.readFileSync(settingsPath, "utf8"); + return JSON.parse(raw) as DeepcodingSettings; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage( + `Failed to read ${path.join(workspaceRoot, ".deepcode", "settings.json")}: ${message}` + ); + return null; + } + } + + private getWorkspaceRoot(): string { + const workspace = vscode.workspace.workspaceFolders?.[0]; + if (workspace) { + return workspace.uri.fsPath; + } + return process.cwd(); + } + + private serializeProcesses( + processes: Map | null + ): Record | null { + if (!processes || processes.size === 0) { + return null; + } + + const serialized: Record = {}; + for (const [pid, entry] of processes.entries()) { + serialized[pid] = entry; + } + return serialized; + } + + private getWebviewHtml(webview: vscode.Webview): string { + const nonce = getNonce(); + const csp = webview.cspSource; + + // 读取 HTML 模板文件 + const htmlPath = vscode.Uri.joinPath(this.context.extensionUri, "resources", "webview.html"); + let html = fs.readFileSync(htmlPath.fsPath, "utf8"); + + // 获取 CSS 文件 URI + const cssPath = vscode.Uri.joinPath(this.context.extensionUri, "resources", "webview.css"); + const cssUri = webview.asWebviewUri(cssPath); + const attachmentsJsPath = vscode.Uri.joinPath(this.context.extensionUri, "resources", "prompt-attachments.js"); + const attachmentsJsUri = webview.asWebviewUri(attachmentsJsPath); + + // 获取 Logo 文件 URI + const iconPath = vscode.Uri.joinPath(this.context.extensionUri, "resources", "deepcoding_icon.png"); + const iconUri = webview.asWebviewUri(iconPath); + + // 替换占位符 + html = html.replace(/\{\{nonce\}\}/g, nonce); + html = html.replace(/\{\{cspSource\}\}/g, csp); + html = html.replace(/\{\{cssUri\}\}/g, cssUri.toString()); + html = html.replace(/\{\{attachmentsJsUri\}\}/g, attachmentsJsUri.toString()); + html = html.replace(/\{\{iconUri\}\}/g, iconUri.toString()); + html = html.replace(/\{\{workspaceRoot\}\}/g, JSON.stringify(this.getWorkspaceRoot())); + + return html; + } + + private async openFileInEditor(filePath: string, line: number): Promise { + const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath)); + const editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + }); + + const targetLine = Number.isFinite(line) && line > 0 ? Math.floor(line) - 1 : 0; + const safeLine = Math.min(Math.max(0, targetLine), Math.max(0, document.lineCount - 1)); + const position = new vscode.Position(safeLine, 0); + const selection = new vscode.Selection(position, position); + editor.selection = selection; + editor.revealRange(new vscode.Range(position, position), vscode.TextEditorRevealType.InCenter); + } +} + +export function activate(context: vscode.ExtensionContext): void { + process.env.NoDefaultCurrentDirectoryInExePath = "1"; + try { + setShellIfWindows(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + void vscode.window.showErrorMessage(message); + } + + const provider = new DeepCodeViewProvider(context); + context.subscriptions.push(provider); + context.subscriptions.push(vscode.window.registerWebviewViewProvider(DeepCodeViewProvider.viewType, provider)); + context.subscriptions.push( + vscode.commands.registerCommand("deepcode.openView", async () => { + await vscode.commands.executeCommand("workbench.view.extension.deepcode"); + await vscode.commands.executeCommand("deepcode.chatView.focus"); + }) + ); +} + +export function deactivate(): void { + // no-op +} diff --git a/packages/vscode-ide-companion/src/provider.ts b/packages/vscode-ide-companion/src/provider.ts new file mode 100644 index 00000000..2e3b4ac8 --- /dev/null +++ b/packages/vscode-ide-companion/src/provider.ts @@ -0,0 +1,323 @@ +/** + * Message handling logic for the Deepcoding webview provider. + * Extracted from extension.ts for testability — no direct vscode dependency. + */ +import type { SessionManager } from "@vegamo/deepcode-core"; +import type { PermissionScope, SkillInfo, UserToolPermission } from "@vegamo/deepcode-core"; +import { parseUserToolPermissions, parsePermissionScopes } from "./utils.js"; + +export interface PostMessageFn { + (message: unknown): void; +} + +export interface ProviderDeps { + sessionManager: Pick< + SessionManager, + | "listSessions" + | "getSession" + | "getActiveSessionId" + | "setActiveSessionId" + | "listSessionMessages" + | "handleUserPrompt" + | "interruptActiveSession" + | "denySessionPermission" + | "listSkills" + >; + postMessage: PostMessageFn; + renderMarkdown: (text: string) => string; + copyToClipboard: (text: string) => void; +} + +export interface SessionSummary { + id: string; + summary: string; + createTime: string; + updateTime: string; + status: string; +} + +function toSessionList( + sessions: Array<{ id: string; summary?: string | null; createTime: string; updateTime: string; status: string }> +): SessionSummary[] { + return sessions.map((s) => ({ + id: s.id, + summary: s.summary || "Untitled", + createTime: s.createTime, + updateTime: s.updateTime, + status: s.status, + })); +} + +/** + * Routes incoming webview messages to the appropriate handler. + * Returns true if the message was handled. + */ +export async function handleWebviewMessage(message: unknown, deps: ProviderDeps): Promise { + const { sessionManager, postMessage, renderMarkdown, copyToClipboard } = deps; + + if (!message || typeof message !== "object") { + return false; + } + + const msg = message as Record; + + if (msg.type === "ready") { + loadInitialSession(sessionManager, postMessage, renderMarkdown); + await sendSkillsList(sessionManager, postMessage); + return true; + } + + if (msg.type === "requestSkills") { + await sendSkillsList(sessionManager, postMessage); + return true; + } + + if (msg.type === "userPrompt") { + const prompt = String(msg.prompt || "").trim(); + const images = Array.isArray(msg.images) + ? (msg.images as unknown[]).filter((image): image is string => typeof image === "string" && image.length > 0) + : []; + const permissions = parseUserToolPermissions(msg.permissions); + const alwaysAllows = parsePermissionScopes(msg.alwaysAllows); + if (!prompt && images.length === 0 && permissions.length === 0 && alwaysAllows.length === 0) { + return true; + } + const skills = (msg.skills as SkillInfo[]) || []; + await handlePrompt(prompt, skills, images, sessionManager, postMessage, renderMarkdown, { + permissions: permissions.length > 0 ? permissions : undefined, + alwaysAllows: alwaysAllows.length > 0 ? alwaysAllows : undefined, + }); + return true; + } + + if (msg.type === "interrupt") { + sessionManager.interruptActiveSession(); + return true; + } + + if (msg.type === "denyPermission") { + const sessionId = String(msg.sessionId || sessionManager.getActiveSessionId() || "").trim(); + if (sessionId) { + handlePermissionDenied(sessionId, sessionManager, postMessage); + } + return true; + } + + if (msg.type === "createNewSession") { + await createNewSession(sessionManager, postMessage); + return true; + } + + if (msg.type === "selectSession") { + const sessionId = String(msg.sessionId || "").trim(); + if (sessionId) { + loadSession(sessionId, sessionManager, postMessage, renderMarkdown); + await sendSkillsList(sessionManager, postMessage, sessionId); + } + return true; + } + + if (msg.type === "backToList") { + showSessionsList(sessionManager, postMessage); + return true; + } + + if (msg.type === "openFile") { + // openFile requires vscode API — handled by the caller + return false; + } + + if (msg.type === "copyText") { + const text = String(msg.text || ""); + if (text) { + copyToClipboard(text); + } + return true; + } + + return false; +} + +function loadInitialSession( + sessionManager: ProviderDeps["sessionManager"], + postMessage: PostMessageFn, + renderMarkdown: (text: string) => string +): void { + const sessions = sessionManager.listSessions(); + const sessionsList = toSessionList(sessions); + + if (sessions.length === 0) { + postMessage({ + type: "initializeEmpty", + sessions: sessionsList, + status: null, + }); + return; + } + + const latestSession = sessions[0]; + loadSession(latestSession.id, sessionManager, postMessage, renderMarkdown); +} + +export function loadSession( + sessionId: string, + sessionManager: ProviderDeps["sessionManager"], + postMessage: PostMessageFn, + renderMarkdown: (text: string) => string +): void { + const session = sessionManager.getSession(sessionId); + if (!session) { + return; + } + + sessionManager.setActiveSessionId(sessionId); + const messages = sessionManager.listSessionMessages(sessionId); + const sessions = sessionManager.listSessions(); + + postMessage({ + type: "loadSession", + sessionId, + summary: session.summary || "Untitled", + status: session.status, + askPermissions: session.askPermissions, + processes: serializeProcesses(session.processes), + sessions: toSessionList(sessions), + messages: messages + .filter((m) => m.visible) + .map((m) => ({ + role: m.role, + content: m.content, + html: + m.role !== "tool" + ? renderMarkdown( + m.content || (m.messageParams as { reasoning_content?: string } | null)?.reasoning_content || "" + ) + : undefined, + meta: m.meta, + })), + }); +} + +function showSessionsList(sessionManager: ProviderDeps["sessionManager"], postMessage: PostMessageFn): void { + const sessions = sessionManager.listSessions(); + postMessage({ + type: "showSessionsList", + sessions: toSessionList(sessions), + }); +} + +async function createNewSession( + sessionManager: ProviderDeps["sessionManager"], + postMessage: PostMessageFn +): Promise { + sessionManager.setActiveSessionId(null); + const sessions = sessionManager.listSessions(); + + postMessage({ + type: "initializeEmpty", + sessions: toSessionList(sessions), + status: null, + }); + await sendSkillsList(sessionManager, postMessage); +} + +async function sendSkillsList( + sessionManager: ProviderDeps["sessionManager"], + postMessage: PostMessageFn, + sessionId?: string +): Promise { + const skills = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); + postMessage({ type: "skillsList", skills }); +} + +async function handlePrompt( + prompt: string, + skills: SkillInfo[], + imageUrls: string[], + sessionManager: ProviderDeps["sessionManager"], + postMessage: PostMessageFn, + renderMarkdown: (text: string) => string, + options: { permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[] } = {} +): Promise { + const normalizedImages = imageUrls.filter(Boolean); + const displayPrompt = prompt || (normalizedImages.length > 0 ? "粘贴的图像" : ""); + const isPermissionContinue = + prompt === "/continue" && + normalizedImages.length === 0 && + ((options.permissions?.length ?? 0) > 0 || (options.alwaysAllows?.length ?? 0) > 0); + + if (displayPrompt && !isPermissionContinue) { + postMessage({ type: "userMessage", content: displayPrompt }); + } + + postMessage({ type: "loading", value: true }); + + try { + await sessionManager.handleUserPrompt({ + text: prompt, + skills, + imageUrls: normalizedImages, + permissions: options.permissions, + alwaysAllows: options.alwaysAllows, + }); + await sendSkillsList(sessionManager, postMessage); + + const activeSessionId = sessionManager.getActiveSessionId(); + const activeSession = activeSessionId ? sessionManager.getSession(activeSessionId) : null; + if (activeSessionId && activeSession) { + postMessage({ + type: "sessionStatus", + sessionId: activeSessionId, + status: activeSession.status, + askPermissions: activeSession.askPermissions, + processes: serializeProcesses(activeSession.processes), + }); + } + + const sessions = sessionManager.listSessions(); + postMessage({ + type: "showSessionsList", + sessions: toSessionList(sessions), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + postMessage({ + type: "assistant", + html: renderMarkdown(`Request failed: ${message}`), + }); + } finally { + postMessage({ type: "loading", value: false }); + } +} + +function handlePermissionDenied( + sessionId: string, + sessionManager: ProviderDeps["sessionManager"], + postMessage: PostMessageFn +): void { + sessionManager.denySessionPermission(sessionId); + const session = sessionManager.getSession(sessionId); + if (session) { + postMessage({ + type: "sessionStatus", + sessionId, + status: session.status, + askPermissions: session.askPermissions, + processes: serializeProcesses(session.processes), + }); + } + showSessionsList(sessionManager, postMessage); +} + +function serializeProcesses( + processes: Map | null +): Record | null { + if (!processes || processes.size === 0) { + return null; + } + const serialized: Record = {}; + for (const [pid, entry] of processes.entries()) { + serialized[pid] = entry; + } + return serialized; +} diff --git a/packages/vscode-ide-companion/src/tests/extension-utils.test.ts b/packages/vscode-ide-companion/src/tests/extension-utils.test.ts new file mode 100644 index 00000000..8dea36de --- /dev/null +++ b/packages/vscode-ide-companion/src/tests/extension-utils.test.ts @@ -0,0 +1,132 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { VALID_PERMISSION_SCOPES, parseUserToolPermissions, parsePermissionScopes, getNonce } from "../utils.js"; + +// --- VALID_PERMISSION_SCOPES --- + +test("VALID_PERMISSION_SCOPES contains all expected scopes", () => { + const expected = [ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", + ]; + assert.equal(VALID_PERMISSION_SCOPES.size, expected.length); + for (const scope of expected) { + assert.ok(VALID_PERMISSION_SCOPES.has(scope as any), `missing scope: ${scope}`); + } +}); + +// --- parseUserToolPermissions --- + +test("parseUserToolPermissions returns empty array for non-array input", () => { + assert.deepEqual(parseUserToolPermissions(undefined), []); + assert.deepEqual(parseUserToolPermissions(null), []); + assert.deepEqual(parseUserToolPermissions("string"), []); + assert.deepEqual(parseUserToolPermissions(123), []); + assert.deepEqual(parseUserToolPermissions({}), []); +}); + +test("parseUserToolPermissions returns empty array for empty array", () => { + assert.deepEqual(parseUserToolPermissions([]), []); +}); + +test("parseUserToolPermissions parses valid permissions", () => { + const input = [ + { toolCallId: "call-1", permission: "allow" }, + { toolCallId: "call-2", permission: "deny" }, + ]; + const result = parseUserToolPermissions(input); + assert.equal(result.length, 2); + assert.deepEqual(result[0], { toolCallId: "call-1", permission: "allow" }); + assert.deepEqual(result[1], { toolCallId: "call-2", permission: "deny" }); +}); + +test("parseUserToolPermissions filters out invalid items", () => { + const input = [ + null, + 123, + "string", + {}, + { toolCallId: "", permission: "allow" }, // empty toolCallId + { toolCallId: " ", permission: "allow" }, // whitespace-only toolCallId + { toolCallId: "call-1" }, // missing permission + { toolCallId: "call-2", permission: "invalid" }, // invalid permission value + { permission: "allow" }, // missing toolCallId + { toolCallId: "call-3", permission: "allow" }, // valid + ]; + const result = parseUserToolPermissions(input); + assert.equal(result.length, 1); + assert.deepEqual(result[0], { toolCallId: "call-3", permission: "allow" }); +}); + +test("parseUserToolPermissions preserves toolCallId with leading/trailing spaces", () => { + const input = [{ toolCallId: " call-1 ", permission: "allow" }]; + const result = parseUserToolPermissions(input); + // trimmed toolCallId " " fails the .trim() check, so this item is filtered + // Wait, " call-1 ".trim() = "call-1" which is truthy, so it passes + assert.equal(result.length, 1); + assert.equal(result[0].toolCallId, " call-1 "); +}); + +// --- parsePermissionScopes --- + +test("parsePermissionScopes returns empty array for non-array input", () => { + assert.deepEqual(parsePermissionScopes(undefined), []); + assert.deepEqual(parsePermissionScopes(null), []); + assert.deepEqual(parsePermissionScopes("string"), []); + assert.deepEqual(parsePermissionScopes(123), []); + assert.deepEqual(parsePermissionScopes({}), []); +}); + +test("parsePermissionScopes returns empty array for empty array", () => { + assert.deepEqual(parsePermissionScopes([]), []); +}); + +test("parsePermissionScopes parses valid scopes", () => { + const input = ["read-in-cwd", "write-in-cwd", "network"]; + const result = parsePermissionScopes(input); + assert.equal(result.length, 3); + assert.deepEqual(result, ["read-in-cwd", "write-in-cwd", "network"]); +}); + +test("parsePermissionScopes filters out invalid values", () => { + const input = ["read-in-cwd", "invalid-scope", 123, null, undefined, {}, "mcp"]; + const result = parsePermissionScopes(input); + assert.equal(result.length, 2); + assert.deepEqual(result, ["read-in-cwd", "mcp"]); +}); + +test("parsePermissionScopes deduplicates scopes", () => { + const input = ["read-in-cwd", "write-in-cwd", "read-in-cwd", "network", "network"]; + const result = parsePermissionScopes(input); + assert.equal(result.length, 3); + assert.deepEqual(result, ["read-in-cwd", "write-in-cwd", "network"]); +}); + +// --- getNonce --- + +test("getNonce returns a 32-character string", () => { + const nonce = getNonce(); + assert.equal(nonce.length, 32); +}); + +test("getNonce only contains alphanumeric characters", () => { + const nonce = getNonce(); + assert.ok(/^[A-Za-z0-9]+$/.test(nonce), `nonce contains non-alphanumeric chars: ${nonce}`); +}); + +test("getNonce returns different values on successive calls", () => { + const nonces = new Set(); + for (let i = 0; i < 100; i++) { + nonces.add(getNonce()); + } + // With 62^32 possible values, 100 calls should almost certainly be unique + assert.ok(nonces.size > 90, `Expected mostly unique nonces, got ${nonces.size} unique out of 100`); +}); diff --git a/packages/vscode-ide-companion/src/tests/extension.test.ts b/packages/vscode-ide-companion/src/tests/extension.test.ts new file mode 100644 index 00000000..9ea39656 --- /dev/null +++ b/packages/vscode-ide-companion/src/tests/extension.test.ts @@ -0,0 +1,477 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { handleWebviewMessage, loadSession, type ProviderDeps } from "../provider.js"; + +// --- Helpers --- + +function createMockSessionManager(options?: { sessions?: any[]; messages?: any[]; skills?: any[] }) { + const sessions = options?.sessions ?? [ + { + id: "session-1", + summary: "Test Session", + status: "idle", + askPermissions: null, + processes: null, + activeTokens: 100, + usage: null, + createTime: "2025-01-01T00:00:00Z", + updateTime: "2025-01-01T00:00:00Z", + }, + ]; + const messages = options?.messages ?? []; + const skills = options?.skills ?? []; + let activeSessionId: string | null = sessions[0]?.id ?? null; + + return { + dispose: () => {}, + listSessions: () => sessions, + getSession: (id: string) => sessions.find((s: any) => s.id === id) ?? null, + getActiveSessionId: () => activeSessionId, + setActiveSessionId: (id: string | null) => { + activeSessionId = id; + }, + listSessionMessages: (_sessionId: string) => messages, + handleUserPrompt: () => Promise.resolve(), + interruptActiveSession: () => {}, + denySessionPermission: (_sessionId: string) => {}, + listSkills: () => Promise.resolve(skills), + initMcpServers: () => Promise.resolve(), + }; +} + +function createDeps(options?: Parameters[0]): ProviderDeps & { messages: unknown[] } { + const messages: unknown[] = []; + return { + sessionManager: createMockSessionManager(options), + postMessage: (msg: unknown) => { + messages.push(msg); + }, + renderMarkdown: (text: string) => `

${text}

`, + copyToClipboard: () => {}, + messages, + }; +} + +// --- handleWebviewMessage routing --- + +test("handleWebviewMessage returns false for null message", async () => { + const deps = createDeps(); + const result = await handleWebviewMessage(null, deps); + assert.equal(result, false); +}); + +test("handleWebviewMessage returns false for non-object message", async () => { + const deps = createDeps(); + assert.equal(await handleWebviewMessage("string", deps), false); + assert.equal(await handleWebviewMessage(123, deps), false); +}); + +test("handleWebviewMessage returns false for unknown message type", async () => { + const deps = createDeps(); + assert.equal(await handleWebviewMessage({ type: "unknownType" }, deps), false); +}); + +test("ready message triggers loadInitialSession and sendSkillsList", async () => { + const deps = createDeps(); + const handled = await handleWebviewMessage({ type: "ready" }, deps); + + assert.equal(handled, true); + const types = deps.messages.map((m: any) => m.type); + // With sessions present, should send loadSession + skillsList + assert.ok(types.includes("loadSession"), `Expected loadSession, got: ${types.join(", ")}`); + assert.ok(types.includes("skillsList"), `Expected skillsList, got: ${types.join(", ")}`); +}); + +test("ready message renders markdown for initial session messages", async () => { + const deps = createDeps({ + messages: [{ role: "assistant", content: "**bold**", visible: true }], + }); + + await handleWebviewMessage({ type: "ready" }, deps); + + const loadMsg = deps.messages.find((m: any) => m.type === "loadSession") as any; + assert.ok(loadMsg, "Should send loadSession"); + assert.equal(loadMsg.messages[0].html, "

**bold**

"); +}); + +test("ready with no sessions sends initializeEmpty", async () => { + const deps = createDeps({ sessions: [] }); + await handleWebviewMessage({ type: "ready" }, deps); + + const types = deps.messages.map((m: any) => m.type); + assert.ok(types.includes("initializeEmpty"), `Expected initializeEmpty, got: ${types.join(", ")}`); +}); + +test("requestSkills sends skillsList", async () => { + const deps = createDeps({ skills: [{ name: "test-skill" }] }); + await handleWebviewMessage({ type: "requestSkills" }, deps); + + const skillsMsg = deps.messages.find((m: any) => m.type === "skillsList"); + assert.ok(skillsMsg, "Should send skillsList"); + assert.deepEqual((skillsMsg as any).skills, [{ name: "test-skill" }]); +}); + +test("interrupt calls interruptActiveSession", async () => { + const deps = createDeps(); + let interrupted = false; + (deps.sessionManager as any).interruptActiveSession = () => { + interrupted = true; + }; + + const handled = await handleWebviewMessage({ type: "interrupt" }, deps); + assert.equal(handled, true); + assert.ok(interrupted, "interruptActiveSession should be called"); +}); + +test("createNewSession clears active session and sends initializeEmpty", async () => { + const deps = createDeps(); + let cleared = false; + (deps.sessionManager as any).setActiveSessionId = (id: string | null) => { + if (id === null) cleared = true; + }; + + await handleWebviewMessage({ type: "createNewSession" }, deps); + + assert.ok(cleared, "setActiveSessionId(null) should be called"); + const types = deps.messages.map((m: any) => m.type); + assert.ok(types.includes("initializeEmpty"), `Expected initializeEmpty, got: ${types.join(", ")}`); + assert.ok(types.includes("skillsList"), `Expected skillsList, got: ${types.join(", ")}`); +}); + +test("selectSession loads session and sends skillsList", async () => { + const deps = createDeps(); + let loadedId: string | null = null; + (deps.sessionManager as any).setActiveSessionId = (id: string) => { + loadedId = id; + }; + + await handleWebviewMessage({ type: "selectSession", sessionId: "session-1" }, deps); + + assert.equal(loadedId, "session-1"); + const types = deps.messages.map((m: any) => m.type); + assert.ok(types.includes("loadSession"), `Expected loadSession, got: ${types.join(", ")}`); + assert.ok(types.includes("skillsList"), `Expected skillsList, got: ${types.join(", ")}`); +}); + +test("selectSession with empty sessionId does nothing", async () => { + const deps = createDeps(); + await handleWebviewMessage({ type: "selectSession", sessionId: "" }, deps); + assert.equal(deps.messages.length, 0, "No messages for empty sessionId"); +}); + +test("selectSession with non-existent session does not send loadSession", async () => { + const deps = createDeps(); + (deps.sessionManager as any).getSession = () => null; + + await handleWebviewMessage({ type: "selectSession", sessionId: "non-existent" }, deps); + + const types = deps.messages.map((m: any) => m.type); + assert.ok(!types.includes("loadSession"), "Should not send loadSession for non-existent session"); +}); + +test("backToList sends showSessionsList", async () => { + const deps = createDeps(); + await handleWebviewMessage({ type: "backToList" }, deps); + + const msg = deps.messages.find((m: any) => m.type === "showSessionsList"); + assert.ok(msg, "Should send showSessionsList"); + assert.ok(Array.isArray((msg as any).sessions), "sessions should be an array"); +}); + +test("denyPermission calls denySessionPermission and sends sessionStatus", async () => { + const deps = createDeps(); + let deniedId: string | null = null; + (deps.sessionManager as any).denySessionPermission = (id: string) => { + deniedId = id; + }; + + await handleWebviewMessage({ type: "denyPermission", sessionId: "session-1" }, deps); + + assert.equal(deniedId, "session-1"); + const types = deps.messages.map((m: any) => m.type); + assert.ok(types.includes("sessionStatus"), `Expected sessionStatus, got: ${types.join(", ")}`); + assert.ok(types.includes("showSessionsList"), `Expected showSessionsList, got: ${types.join(", ")}`); +}); + +test("denyPermission with empty sessionId does nothing", async () => { + const deps = createDeps(); + (deps.sessionManager as any).getActiveSessionId = () => null; + + await handleWebviewMessage({ type: "denyPermission", sessionId: "" }, deps); + + // No sessionStatus should be sent + const types = deps.messages.map((m: any) => m.type); + assert.ok(!types.includes("sessionStatus"), "Should not send sessionStatus for empty sessionId"); +}); + +test("copyText calls copyToClipboard", async () => { + const deps = createDeps(); + let copiedText: string | null = null; + deps.copyToClipboard = (text: string) => { + copiedText = text; + }; + + const handled = await handleWebviewMessage({ type: "copyText", text: "hello" }, deps); + assert.equal(handled, true); + assert.equal(copiedText, "hello"); +}); + +test("copyText with empty text does not call copyToClipboard", async () => { + const deps = createDeps(); + let copied = false; + deps.copyToClipboard = () => { + copied = true; + }; + + await handleWebviewMessage({ type: "copyText", text: "" }, deps); + assert.ok(!copied, "Should not copy empty text"); +}); + +test("openFile returns false (handled by caller)", async () => { + const deps = createDeps(); + const result = await handleWebviewMessage({ type: "openFile", filePath: "/some/file.ts" }, deps); + assert.equal(result, false); +}); + +// --- userPrompt --- + +test("userPrompt with empty prompt and no images/permissions is handled without messages", async () => { + const deps = createDeps(); + const handled = await handleWebviewMessage( + { type: "userPrompt", prompt: "", images: [], permissions: [], alwaysAllows: [] }, + deps + ); + assert.equal(handled, true); + assert.equal(deps.messages.length, 0, "No messages for empty prompt"); +}); + +test("userPrompt with text sends userMessage and loading states", async () => { + const deps = createDeps(); + await handleWebviewMessage({ type: "userPrompt", prompt: "hello" }, deps); + + const types = deps.messages.map((m: any) => m.type); + assert.ok(types.includes("userMessage"), `Expected userMessage, got: ${types.join(", ")}`); + assert.ok(types.includes("loading"), `Expected loading, got: ${types.join(", ")}`); + + // Should end with loading: false + const lastLoading = [...deps.messages].reverse().find((m: any) => m.type === "loading"); + assert.deepEqual(lastLoading, { type: "loading", value: false }); +}); + +test("userPrompt with images sends userMessage with image placeholder", async () => { + const deps = createDeps(); + await handleWebviewMessage({ type: "userPrompt", prompt: "", images: ["data:image/png;base64,abc"] }, deps); + + const userMsg = deps.messages.find((m: any) => m.type === "userMessage"); + assert.ok(userMsg, "Should send userMessage for images"); + assert.equal((userMsg as any).content, "粘贴的图像"); +}); + +test("userPrompt passes multiple image urls to the session manager", async () => { + const deps = createDeps(); + let submittedPrompt: any = null; + (deps.sessionManager as any).handleUserPrompt = (prompt: any) => { + submittedPrompt = prompt; + return Promise.resolve(); + }; + + await handleWebviewMessage( + { + type: "userPrompt", + prompt: "", + images: ["data:image/png;base64,abc", "data:image/jpeg;base64,def"], + }, + deps + ); + + assert.deepEqual(submittedPrompt?.imageUrls, ["data:image/png;base64,abc", "data:image/jpeg;base64,def"]); +}); + +test("userPrompt with permissions (continue) does not send userMessage", async () => { + const deps = createDeps(); + await handleWebviewMessage( + { + type: "userPrompt", + prompt: "/continue", + images: [], + permissions: [{ toolCallId: "call-1", permission: "allow" }], + }, + deps + ); + + const userMsg = deps.messages.find((m: any) => m.type === "userMessage"); + assert.ok(!userMsg, "Should not send userMessage for /continue with permissions"); +}); + +test("userPrompt sends sessionStatus after handling", async () => { + const deps = createDeps(); + await handleWebviewMessage({ type: "userPrompt", prompt: "hello" }, deps); + + const types = deps.messages.map((m: any) => m.type); + assert.ok(types.includes("sessionStatus"), `Expected sessionStatus, got: ${types.join(", ")}`); +}); + +test("userPrompt sends showSessionsList after handling", async () => { + const deps = createDeps(); + await handleWebviewMessage({ type: "userPrompt", prompt: "hello" }, deps); + + const sessionsMsg = deps.messages.find((m: any) => m.type === "showSessionsList"); + assert.ok(sessionsMsg, "Should send showSessionsList"); + assert.ok(Array.isArray((sessionsMsg as any).sessions), "sessions should be an array"); +}); + +test("userPrompt on error sends assistant error message", async () => { + const deps = createDeps(); + (deps.sessionManager as any).handleUserPrompt = () => Promise.reject(new Error("API failed")); + + await handleWebviewMessage({ type: "userPrompt", prompt: "hello" }, deps); + + const assistantMsg = deps.messages.find((m: any) => m.type === "assistant"); + assert.ok(assistantMsg, "Should send assistant error message"); + assert.ok((assistantMsg as any).html.includes("API failed"), "Error message should contain the error text"); +}); + +test("userPrompt always sends loading: false even on error", async () => { + const deps = createDeps(); + (deps.sessionManager as any).handleUserPrompt = () => Promise.reject(new Error("fail")); + + await handleWebviewMessage({ type: "userPrompt", prompt: "hello" }, deps); + + const lastLoading = [...deps.messages].reverse().find((m: any) => m.type === "loading"); + assert.deepEqual(lastLoading, { type: "loading", value: false }); +}); + +// --- loadSession --- + +test("loadSession sends loadSession with correct fields", () => { + const sessionManager = createMockSessionManager(); + const messages: unknown[] = []; + const postMessage = (msg: unknown) => { + messages.push(msg); + }; + + loadSession("session-1", sessionManager, postMessage, (t) => t); + + const msg = messages.find((m: any) => m.type === "loadSession") as any; + assert.ok(msg, "Should send loadSession"); + assert.equal(msg.sessionId, "session-1"); + assert.equal(msg.summary, "Test Session"); + assert.equal(msg.status, "idle"); + assert.ok(Array.isArray(msg.sessions), "sessions should be an array"); + assert.ok(Array.isArray(msg.messages), "messages should be an array"); +}); + +test("loadSession with non-existent session does nothing", () => { + const sessionManager = createMockSessionManager(); + const messages: unknown[] = []; + const postMessage = (msg: unknown) => { + messages.push(msg); + }; + + (sessionManager as any).getSession = () => null; + loadSession("non-existent", sessionManager, postMessage, (t) => t); + + assert.equal(messages.length, 0, "No messages for non-existent session"); +}); + +test("loadSession sets active session id", () => { + const sessionManager = createMockSessionManager(); + const messages: unknown[] = []; + let setTo: string | null = null; + (sessionManager as any).setActiveSessionId = (id: string) => { + setTo = id; + }; + + loadSession( + "session-1", + sessionManager, + (msg) => messages.push(msg), + (t) => t + ); + + assert.equal(setTo, "session-1"); +}); + +test("loadSession filters out invisible messages", () => { + const sessionManager = createMockSessionManager({ + messages: [ + { role: "user", content: "visible", visible: true }, + { role: "assistant", content: "hidden", visible: false }, + { role: "user", content: "also visible", visible: true }, + ], + }); + const messages: unknown[] = []; + loadSession( + "session-1", + sessionManager, + (msg) => messages.push(msg), + (t) => t + ); + + const loadMsg = messages.find((m: any) => m.type === "loadSession") as any; + assert.equal(loadMsg.messages.length, 2, "Should filter out invisible messages"); +}); + +// --- serializeProcesses --- + +test("loadSession serializes processes map to object", () => { + const sessionManager = createMockSessionManager({ + sessions: [ + { + id: "session-1", + summary: "Test", + status: "idle", + askPermissions: null, + processes: new Map([ + ["123", { startTime: "2025-01-01", command: "ls" }], + ["456", { startTime: "2025-01-02", command: "cat" }], + ]), + activeTokens: 0, + usage: null, + createTime: "2025-01-01", + updateTime: "2025-01-01", + }, + ], + }); + const messages: unknown[] = []; + loadSession( + "session-1", + sessionManager, + (msg) => messages.push(msg), + (t) => t + ); + + const loadMsg = messages.find((m: any) => m.type === "loadSession") as any; + assert.deepEqual(loadMsg.processes, { + "123": { startTime: "2025-01-01", command: "ls" }, + "456": { startTime: "2025-01-02", command: "cat" }, + }); +}); + +test("loadSession returns null for empty processes", () => { + const sessionManager = createMockSessionManager({ + sessions: [ + { + id: "session-1", + summary: "Test", + status: "idle", + askPermissions: null, + processes: null, + activeTokens: 0, + usage: null, + createTime: "2025-01-01", + updateTime: "2025-01-01", + }, + ], + }); + const messages: unknown[] = []; + loadSession( + "session-1", + sessionManager, + (msg) => messages.push(msg), + (t) => t + ); + + const loadMsg = messages.find((m: any) => m.type === "loadSession") as any; + assert.equal(loadMsg.processes, null); +}); diff --git a/packages/vscode-ide-companion/src/tests/prompt-attachments.test.ts b/packages/vscode-ide-companion/src/tests/prompt-attachments.test.ts new file mode 100644 index 00000000..314826e3 --- /dev/null +++ b/packages/vscode-ide-companion/src/tests/prompt-attachments.test.ts @@ -0,0 +1,240 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +type EventHandler = (event: any) => unknown; + +class FakeClassList { + private readonly classes = new Set(); + + constructor(className = "") { + for (const classPart of className.split(/\s+/)) { + if (classPart) { + this.classes.add(classPart); + } + } + } + + add(className: string): void { + this.classes.add(className); + } + + remove(className: string): void { + this.classes.delete(className); + } + + contains(className: string): boolean { + return this.classes.has(className); + } + + toggle(className: string, force?: boolean): boolean { + const shouldAdd = force ?? !this.classes.has(className); + if (shouldAdd) { + this.classes.add(className); + } else { + this.classes.delete(className); + } + return shouldAdd; + } +} + +class FakeElement { + readonly tagName: string; + className = ""; + classList = new FakeClassList(); + children: FakeElement[] = []; + parent: FakeElement | null = null; + dataset: Record = {}; + style: Record = {}; + textContent = ""; + tabIndex = 0; + draggable = false; + href = ""; + src = ""; + alt = ""; + private readonly attributes = new Map(); + private readonly listeners = new Map(); + + constructor(tagName: string) { + this.tagName = tagName; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + appendChild(child: FakeElement): FakeElement { + child.parent = this; + this.children.push(child); + return child; + } + + set innerHTML(_value: string) { + for (const child of this.children) { + child.parent = null; + } + this.children = []; + } + + get innerHTML(): string { + return ""; + } + + addEventListener(type: string, handler: EventHandler): void { + const listeners = this.listeners.get(type) ?? []; + listeners.push(handler); + this.listeners.set(type, listeners); + } + + async dispatchEvent(event: any): Promise { + event.type ??= ""; + for (const handler of this.listeners.get(event.type) ?? []) { + await handler(event); + } + } + + contains(candidate: FakeElement | null): boolean { + if (!candidate) { + return false; + } + if (candidate === this) { + return true; + } + return this.children.some((child) => child.contains(candidate)); + } + + querySelector(selector: string): FakeElement | null { + if (!selector.startsWith(".")) { + return null; + } + const className = selector.slice(1); + for (const child of this.children) { + if (child.className.split(/\s+/).includes(className)) { + return child; + } + const match = child.querySelector(selector); + if (match) { + return match; + } + } + return null; + } + + getBoundingClientRect(): { left: number; top: number; bottom: number; width: number; height: number } { + return { left: 20, top: 80, bottom: 100, width: 160, height: 40 }; + } +} + +class FakeDocument { + readonly body = new FakeElement("body"); + + createElement(tagName: string): FakeElement { + return new FakeElement(tagName); + } +} + +class FakeFileReader { + result: string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + error: Error | null = null; + + readAsDataURL(file: { dataUrl?: string }): void { + this.result = file.dataUrl ?? ""; + this.onload?.(); + } +} + +function loadAttachmentManager(): { + manager: { clear: () => void; hasAttachments: () => boolean; getImageUrls: () => string[] }; + promptInput: FakeElement; + toolsLine: FakeElement; +} { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const scriptPath = path.resolve(__dirname, "../../resources/prompt-attachments.js"); + const script = fs.readFileSync(scriptPath, "utf8"); + + const document = new FakeDocument(); + const window = { + innerWidth: 1024, + innerHeight: 768, + addEventListener: () => {}, + createPromptAttachmentManager: undefined as + | undefined + | ((options: Record) => { + clear: () => void; + hasAttachments: () => boolean; + getImageUrls: () => string[]; + }), + }; + + vm.runInNewContext(script, { console, document, window, FileReader: FakeFileReader }); + + const createPromptAttachmentManager = window.createPromptAttachmentManager; + if (typeof createPromptAttachmentManager !== "function") { + throw new Error("Prompt attachment manager was not registered."); + } + const promptInput = new FakeElement("textarea"); + const inputWrap = new FakeElement("div"); + const toolsLine = new FakeElement("div"); + const manager = createPromptAttachmentManager({ promptInput, inputWrap, toolsLine }); + + return { manager, promptInput, toolsLine }; +} + +async function pasteImage(promptInput: FakeElement, dataUrl: string): Promise { + let defaultPrevented = false; + await promptInput.dispatchEvent({ + type: "paste", + clipboardData: { + items: [ + { + kind: "file", + getAsFile: () => ({ type: "image/png", name: "image.png", dataUrl }), + }, + ], + }, + preventDefault: () => { + defaultPrevented = true; + }, + }); + assert.equal(defaultPrevented, true); +} + +test("prompt attachment manager appends pasted images instead of replacing the previous image", async () => { + const { manager, promptInput, toolsLine } = loadAttachmentManager(); + + await pasteImage(promptInput, "data:image/png;base64,first"); + await pasteImage(promptInput, "data:image/png;base64,second"); + + assert.equal(manager.hasAttachments(), true); + assert.deepEqual(Array.from(manager.getImageUrls()), ["data:image/png;base64,first", "data:image/png;base64,second"]); + assert.equal(toolsLine.children.length, 2); + assert.equal(toolsLine.classList.contains("has-attachment"), true); +}); + +test("prompt attachment manager removes one pasted image without clearing the rest", async () => { + const { manager, promptInput, toolsLine } = loadAttachmentManager(); + + await pasteImage(promptInput, "data:image/png;base64,first"); + await pasteImage(promptInput, "data:image/png;base64,second"); + + const firstAttachment = toolsLine.children[0]; + const removeButton = firstAttachment.children[0]; + await removeButton.dispatchEvent({ + type: "click", + preventDefault: () => {}, + stopPropagation: () => {}, + }); + + assert.deepEqual(Array.from(manager.getImageUrls()), ["data:image/png;base64,second"]); + assert.equal(toolsLine.children.length, 1); + + manager.clear(); + assert.equal(manager.hasAttachments(), false); + assert.deepEqual(Array.from(manager.getImageUrls()), []); + assert.equal(toolsLine.children.length, 0); +}); diff --git a/packages/vscode-ide-companion/src/tests/run-tests.mjs b/packages/vscode-ide-companion/src/tests/run-tests.mjs new file mode 100644 index 00000000..73034d8a --- /dev/null +++ b/packages/vscode-ide-companion/src/tests/run-tests.mjs @@ -0,0 +1,15 @@ +// Test runner for @vegamo/deepcode-vscode +import { globSync } from "glob"; +import { spawnSync } from "child_process"; +import { fileURLToPath } from "url"; +import * as path from "path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const testFiles = globSync("*.test.ts", { cwd: __dirname }); + +const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { + stdio: "inherit", + cwd: __dirname, +}); + +process.exit(result.status ?? 1); diff --git a/packages/vscode-ide-companion/src/utils.ts b/packages/vscode-ide-companion/src/utils.ts new file mode 100644 index 00000000..7fc126e8 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils.ts @@ -0,0 +1,61 @@ +import type { PermissionScope, UserToolPermission } from "@vegamo/deepcode-core"; + +export const VALID_PERMISSION_SCOPES = new Set([ + "read-in-cwd", + "read-out-cwd", + "write-in-cwd", + "write-out-cwd", + "delete-in-cwd", + "delete-out-cwd", + "query-git-log", + "mutate-git-log", + "network", + "mcp", +]); + +export function parseUserToolPermissions(value: unknown): UserToolPermission[] { + if (!Array.isArray(value)) { + return []; + } + const result: UserToolPermission[] = []; + for (const item of value) { + if (!item || typeof item !== "object") { + continue; + } + const record = item as { toolCallId?: unknown; permission?: unknown }; + if (typeof record.toolCallId !== "string" || !record.toolCallId.trim()) { + continue; + } + if (record.permission !== "allow" && record.permission !== "deny") { + continue; + } + result.push({ toolCallId: record.toolCallId, permission: record.permission }); + } + return result; +} + +export function parsePermissionScopes(value: unknown): PermissionScope[] { + if (!Array.isArray(value)) { + return []; + } + const result: PermissionScope[] = []; + for (const item of value) { + if (typeof item !== "string" || !VALID_PERMISSION_SCOPES.has(item as PermissionScope)) { + continue; + } + const scope = item as PermissionScope; + if (!result.includes(scope)) { + result.push(scope); + } + } + return result; +} + +export function getNonce(): string { + let text = ""; + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i += 1) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} diff --git a/packages/vscode-ide-companion/tsconfig.build.json b/packages/vscode-ide-companion/tsconfig.build.json new file mode 100644 index 00000000..8601700a --- /dev/null +++ b/packages/vscode-ide-companion/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "ignoreDeprecations": "6.0", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "composite": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node", "vscode"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json new file mode 100644 index 00000000..c0d84433 --- /dev/null +++ b/packages/vscode-ide-companion/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "ignoreDeprecations": "6.0", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["node", "vscode"], + "baseUrl": ".", + "paths": { + "@vegamo/deepcode-core": ["../core/src/index.ts"], + "@vegamo/deepcode-core/*": ["../core/src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", ".vscode-test", "out"] +} diff --git a/scripts/build-vscode-companion.js b/scripts/build-vscode-companion.js new file mode 100644 index 00000000..7fbb1c2a --- /dev/null +++ b/scripts/build-vscode-companion.js @@ -0,0 +1,38 @@ +import { spawnSync } from "node:child_process"; +import { cpSync, existsSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function run(command, args, label) { + console.log(`\n[${label}] ${command} ${args.join(" ")}`); + const result = spawnSync(command, args, { stdio: "inherit", cwd: root, shell: true }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +console.log("========================================="); +console.log(" Deep Code — Build VSCode Companion"); +console.log("========================================="); + +run("npm", ["run", "build", "--workspace=@vegamo/deepcode-core"], "1/4 Build core"); +run("node", ["scripts/esbuild-vscode.config.js"], "2/4 Bundle extension"); + +// Copy templates from core so the extension can read them at runtime via fs +const templatesSrc = join(root, "packages", "core", "templates"); +const templatesDest = join(root, "packages", "vscode-ide-companion", "templates"); + +if (!existsSync(templatesSrc)) { + console.error(`\n❌ Templates not found at ${templatesSrc}`); + process.exit(1); +} + +rmSync(templatesDest, { recursive: true, force: true }); +cpSync(templatesSrc, templatesDest, { recursive: true, dereference: true }); +console.log("\n[3/4] Copied templates from core → vscode-ide-companion/templates/"); + +run("npm", ["run", "package", "--workspace=deepcode-vscode"], "4/4 Package .vsix"); + +console.log("\n✅ VSCode companion build complete.\n\n"); diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 00000000..7710968b --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,24 @@ +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +function run(command, args, label) { + process.stdout.write(`\n[${label}] ${command} ${args.join(" ")}\n`); + const result = spawnSync(command, args, { stdio: "inherit", cwd: root, shell: true }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +console.log("========================================="); +console.log(" Deep Code CLI — Build"); +console.log("========================================="); + +run("npm", ["run", "build", "--workspace=@vegamo/deepcode-core"], "1/3"); +run("node", ["scripts/rewrite-esm-imports.js"], "2/3"); +run("npm", ["run", "bundle"], "3/3"); + +console.log("\n✅ Build complete.\n\n"); diff --git a/scripts/clean.js b/scripts/clean.js new file mode 100644 index 00000000..4fd4c7e6 --- /dev/null +++ b/scripts/clean.js @@ -0,0 +1,47 @@ +import { rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); +const RMRF = { recursive: true, force: true }; + +console.log("Cleaning build artifacts...\n"); + +// Root node_modules +rmSync(join(root, "node_modules"), RMRF); +console.log(" rm node_modules/"); + +// Per-package node_modules, dist, generated, tsbuildinfo +const packageDirs = globSync("packages/*", { cwd: root, absolute: true }); +for (const pkgDir of packageDirs) { + const short = pkgDir.replace(root + "/", ""); + + rmSync(join(pkgDir, "node_modules"), RMRF); + console.log(` rm ${short}/node_modules/`); + + rmSync(join(pkgDir, "dist"), RMRF); + console.log(` rm ${short}/dist/`); + + rmSync(join(pkgDir, "src", "generated"), RMRF); + console.log(` rm ${short}/src/generated/`); + + rmSync(join(pkgDir, "tsconfig.tsbuildinfo"), { force: true }); +} + +// VSCode companion specific artifacts +const vscodeDir = join(root, "packages", "vscode-ide-companion"); +rmSync(join(vscodeDir, "out"), RMRF); +console.log(" rm packages/vscode-ide-companion/out/"); + +rmSync(join(vscodeDir, "templates"), RMRF); +console.log(" rm packages/vscode-ide-companion/templates/"); + +const vsixFiles = globSync("*.vsix", { cwd: vscodeDir }); +for (const vsixFile of vsixFiles) { + rmSync(join(vscodeDir, vsixFile), RMRF); + console.log(` rm packages/vscode-ide-companion/${vsixFile}`); +} + +console.log("\n✅ Clean complete.\n\n"); diff --git a/scripts/copy-bundle-assets.js b/scripts/copy-bundle-assets.js new file mode 100644 index 00000000..c1905c79 --- /dev/null +++ b/scripts/copy-bundle-assets.js @@ -0,0 +1,50 @@ +import { cpSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); +const cliRoot = join(root, "packages", "cli"); +const distDir = join(cliRoot, "dist"); + +if (!existsSync(distDir)) { + mkdirSync(distDir, { recursive: true }); +} + +const templatesSrc = join(root, "packages", "core", "templates"); +const templatesDest = join(distDir, "templates"); + +if (!existsSync(templatesSrc)) { + console.error(`Templates directory not found at ${templatesSrc}`); + process.exit(1); +} + +// 1. Copy core/templates/ → dist/templates/, excluding skills/bundled/. +// Bundled skills are copied separately to dist/bundled/ (see step 2) and +// getBundledSkillsRoot() resolves them from there at runtime. +rmSync(templatesDest, { recursive: true, force: true }); +cpSync(templatesSrc, templatesDest, { + recursive: true, + dereference: true, + filter: (src) => { + const rel = relative(templatesSrc, src); + // Exclude skills/bundled and everything under it + return !(rel === join("skills", "bundled") || rel.startsWith(join("skills", "bundled") + "/")); + }, +}); +console.log("\n✅ Copied core/templates/ → dist/templates/ (excluding skills/bundled/)"); + +// 2. Copy bundled skills to dist/bundled/ +const bundledSkillsSrc = join(templatesSrc, "skills", "bundled"); +const bundledSkillsDest = join(distDir, "bundled"); + +if (existsSync(bundledSkillsSrc)) { + rmSync(bundledSkillsDest, { recursive: true, force: true }); + cpSync(bundledSkillsSrc, bundledSkillsDest, { + recursive: true, + dereference: true, + }); + console.log("✅ Copied bundled skills → dist/bundled/"); +} + +console.log("\n✅ All bundle assets copied.\n"); diff --git a/scripts/empty-shim.js b/scripts/empty-shim.js new file mode 100644 index 00000000..76f9cbf5 --- /dev/null +++ b/scripts/empty-shim.js @@ -0,0 +1,2 @@ +// Empty shim for react-devtools-core (browser-only, not needed in CLI bundle) +export default {}; diff --git a/scripts/esbuild-shims.js b/scripts/esbuild-shims.js new file mode 100644 index 00000000..f38ee0c1 --- /dev/null +++ b/scripts/esbuild-shims.js @@ -0,0 +1,22 @@ +/** + * Shims for esbuild ESM bundles. + * + * When esbuild bundles CJS modules into ESM output, it replaces require() + * calls with a __require shim that throws for non-bundled modules. This + * file provides a real require() via createRequire() so Node.js built-in + * modules (assert, events, zlib, etc.) resolve correctly at runtime. + */ + +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const _require = createRequire(import.meta.url); + +if (typeof globalThis.require === "undefined") { + globalThis.require = _require; +} + +export const require = _require; +export const __filename = fileURLToPath(import.meta.url); +export const __dirname = dirname(__filename); diff --git a/scripts/esbuild-vscode.config.js b/scripts/esbuild-vscode.config.js new file mode 100644 index 00000000..25cd2bc2 --- /dev/null +++ b/scripts/esbuild-vscode.config.js @@ -0,0 +1,29 @@ +import { build } from "esbuild"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +const vscodeRoot = join(root, "packages", "vscode-ide-companion"); +const entry = join(vscodeRoot, "src", "extension.ts"); +const outfile = join(vscodeRoot, "out", "extension.js"); + +await build({ + entryPoints: [entry], + bundle: true, + platform: "node", + format: "cjs", + target: "node18", + outfile, + external: ["vscode"], + sourcemap: true, + footer: { + js: "module.exports = { activate, deactivate };", + }, + logOverride: { + "empty-import-meta": "silent", + }, +}); + +console.log(`\n✅ ${outfile} built successfully\n\n`); diff --git a/scripts/esbuild.config.js b/scripts/esbuild.config.js new file mode 100644 index 00000000..3236c5ee --- /dev/null +++ b/scripts/esbuild.config.js @@ -0,0 +1,43 @@ +import { build } from "esbuild"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +const cliRoot = join(root, "packages", "cli"); +const entry = join(cliRoot, "src", "cli.tsx"); + +await build({ + entryPoints: [entry], + bundle: true, + outdir: join(cliRoot, "dist"), + entryNames: "[name]", + chunkNames: "chunks/[name]-[hash]", + splitting: true, + platform: "node", + format: "esm", + target: "node22", + banner: { js: "#!/usr/bin/env node" }, + jsx: "automatic", + jsxImportSource: "react", + packages: "bundle", + inject: [join(__dirname, "esbuild-shims.js")], + alias: { + // react-devtools-core is a browser-only package pulled in by ink's + // devtools support. It cannot run in a Node.js CLI, so we replace it + // with an empty shim so esbuild doesn't bundle the real (broken) code. + "react-devtools-core": join(__dirname, "empty-shim.js"), + }, + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + logOverride: { + "empty-import-meta": "silent", + }, + metafile: true, + write: true, + keepNames: true, +}); + +console.log(`\n✅ ${join(cliRoot, "dist", "cli.js")} built successfully\n\n`); diff --git a/scripts/generate-git-commit-info.js b/scripts/generate-git-commit-info.js new file mode 100644 index 00000000..ac029533 --- /dev/null +++ b/scripts/generate-git-commit-info.js @@ -0,0 +1,46 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); +const scriptPath = relative(root, fileURLToPath(import.meta.url)); + +const generatedCliDir = join(root, "packages", "cli", "src", "generated"); +const cliGitCommitFile = join(generatedCliDir, "git-commit.ts"); + +let gitCommitInfo = "N/A"; +let cliVersion = "UNKNOWN"; + +if (!existsSync(generatedCliDir)) { + mkdirSync(generatedCliDir, { recursive: true }); +} + +try { + const gitHash = execSync("git rev-parse --short HEAD", { encoding: "utf-8" }).trim(); + if (gitHash) { + gitCommitInfo = gitHash; + } + + const pkg = JSON.parse(readFileSync(join(root, "packages", "cli", "package.json"), "utf-8")); + cliVersion = pkg.version ?? "UNKNOWN"; +} catch { + // ignore +} + +const fileContent = [ + "/**", + " * @license", + ` * Copyright ${new Date().getFullYear()} @vegamo deepcode`, + " */", + "", + `// Auto-generated by ${scriptPath}. Do not edit.`, + `export const GIT_COMMIT_INFO = "${gitCommitInfo}";`, + `export const CLI_VERSION = "${cliVersion}";`, + "", +].join("\n"); + +writeFileSync(cliGitCommitFile, fileContent); + +console.log(`Generated version info: ${cliVersion} (${gitCommitInfo})`); diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js new file mode 100644 index 00000000..eb12dfea --- /dev/null +++ b/scripts/prepare-package.js @@ -0,0 +1,427 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function log(msg) { + console.log(msg); +} + +function step(n, total, msg) { + console.log(`\n[${n}/${total}] ${msg}`); +} + +function fail(msg) { + console.error(`\n❌ ${msg}`); + process.exit(1); +} + +function ok(msg) { + console.log(`✅ ${msg}`); +} + +function run(cmd, args, opts = {}) { + const label = opts.label ?? `${cmd} ${args.join(" ")}`; + if (opts.dryRun && !opts.runInDryRun) { + log(` (dry-run) ${label}`); + return { status: 0, stdout: "" }; + } + const result = spawnSync(cmd, args, { + stdio: opts.stdio ?? "inherit", + cwd: opts.cwd ?? root, + shell: false, + encoding: opts.encoding, + env: { ...process.env, ...opts.env }, + }); + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + fail(`Command failed: ${label}${output ? `\n${output}` : ""}`); + } + return result; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf-8")); +} + +function writeJson(filePath, data) { + writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8"); +} + +function isValidSemver(v) { + return /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(v); +} + +function isValidNpmTag(v) { + return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v); +} + +function hasPackFile(files, expectedPath) { + return files.includes(expectedPath); +} + +function hasPackPrefix(files, expectedPrefix) { + return files.some((file) => file.startsWith(expectedPrefix)); +} + +function validatePacklist(cwd, checks, opts = {}) { + const label = opts.label ?? `npm pack --dry-run --json --ignore-scripts`; + const result = run("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd, + label, + stdio: "pipe", + encoding: "utf-8", + runInDryRun: true, + }); + const output = result.stdout.trim(); + const packs = JSON.parse(output); + const pack = Array.isArray(packs) ? packs[0] : packs; + const files = (pack?.files ?? []).map((file) => file.path); + const missing = []; + + for (const check of checks) { + const found = check.type === "prefix" ? hasPackPrefix(files, check.value) : hasPackFile(files, check.value); + if (!found) { + missing.push(check.label ?? check.value); + } + } + + if (missing.length > 0) { + fail(`Package tarball is missing required files:\n - ${missing.join("\n - ")}`); + } + + ok(`Validated package tarball (${files.length} files)`); +} + +function hasGitChanges(paths) { + const result = spawnSync("git", ["diff", "--quiet", "--", ...paths], { + cwd: root, + shell: false, + }); + if (result.status === 0) { + return false; + } + if (result.status === 1) { + return true; + } + fail("Unable to check release file changes."); +} + +function gitTagExists(tagName) { + const result = spawnSync("git", ["rev-parse", "-q", "--verify", `refs/tags/${tagName}`], { + cwd: root, + shell: false, + stdio: "ignore", + }); + return result.status === 0; +} + +// ── Parse args ─────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +let version = null; +let tag = "latest"; +let dryRun = false; +let force = false; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--dry-run") { + dryRun = true; + } else if (arg === "--force") { + force = true; + } else if (arg === "--tag") { + tag = args[++i]; + if (!tag) fail("--tag requires a value"); + } else if (!version) { + version = arg; + } else { + fail(`Unknown argument: ${arg}`); + } +} + +if (!version) { + log(` +Usage: node scripts/prepare-package.js [options] + +Arguments: + Semver version to publish (e.g. 0.1.32, 0.2.0-beta.1) + +Options: + --tag npm dist-tag (default: "latest") + --dry-run Preview all steps without executing + --force Skip branch check (publish from non-main branch) + +Examples: + node scripts/prepare-package.js 0.1.32 + node scripts/prepare-package.js 0.1.32-beta.1 --tag beta + node scripts/prepare-package.js 0.1.32 --dry-run +`); + process.exit(1); +} + +if (!isValidSemver(version)) { + fail(`Invalid semver version: ${version}`); +} + +if (!isValidNpmTag(tag)) { + fail(`Invalid npm dist-tag: ${tag}`); +} + +const TOTAL_STEPS = 8; + +// ── Banner ─────────────────────────────────────────────────────────────────── + +log("========================================="); +log(` Deep Code CLI — Publish v${version}`); +log(` tag=${tag} dryRun=${dryRun} force=${force}`); +log("========================================="); + +// ── 1. Git checks ──────────────────────────────────────────────────────────── + +step(1, TOTAL_STEPS, "Checking git state..."); + +const gitStatus = spawnSync("git", ["status", "--porcelain"], { + cwd: root, + encoding: "utf-8", + shell: false, +}); +if (gitStatus.stdout.trim()) { + fail("Working tree is not clean. Commit or stash changes first."); +} +ok("Working tree is clean"); + +if (!force) { + const gitBranch = spawnSync("git", ["branch", "--show-current"], { + cwd: root, + encoding: "utf-8", + shell: false, + }); + const branch = gitBranch.stdout.trim(); + if (branch !== "main") { + fail(`Not on main branch (current: ${branch}). Use --force to publish from another branch.`); + } + ok("On main branch"); +} + +// ── 2. npm auth ────────────────────────────────────────────────────────────── + +step(2, TOTAL_STEPS, "Checking npm authentication..."); + +if (!dryRun) { + const whoami = spawnSync("npm", ["whoami"], { + cwd: root, + encoding: "utf-8", + shell: false, + }); + if (whoami.status !== 0) { + fail("Not logged in to npm. Run `npm login` first."); + } + ok(`Logged in as: ${whoami.stdout.trim()}`); +} else { + log(" (dry-run) skipping npm whoami"); +} + +// ── 3. Version bump ────────────────────────────────────────────────────────── + +step(3, TOTAL_STEPS, "Updating package versions..."); + +const corePkgPath = join(root, "packages", "core", "package.json"); +const cliPkgPath = join(root, "packages", "cli", "package.json"); + +const corePkg = readJson(corePkgPath); +const cliPkg = readJson(cliPkgPath); + +const oldVersion = corePkg.version; + +corePkg.version = version; +cliPkg.version = version; + +if (!dryRun) { + writeJson(corePkgPath, corePkg); + writeJson(cliPkgPath, cliPkg); + ok(`Updated packages/core: ${oldVersion} → ${version}`); + ok(`Updated packages/cli: ${oldVersion} → ${version}`); +} else { + log(` (dry-run) packages/core: ${oldVersion} → ${version}`); + log(` (dry-run) packages/cli: ${oldVersion} → ${version}`); +} + +run("npm", ["install", "--package-lock-only", "--ignore-scripts"], { + dryRun, + label: "npm install --package-lock-only --ignore-scripts", +}); +ok("package-lock.json is up to date"); + +// ── 4. Quality checks ──────────────────────────────────────────────────────── + +step(4, TOTAL_STEPS, "Running quality checks (typecheck + lint + format)..."); + +run("npm", ["run", "check"], { dryRun }); +ok("All checks passed"); + +step(5, TOTAL_STEPS, "Running tests..."); + +run("npm", ["run", "test", "--workspaces"], { dryRun }); +ok("All tests passed"); + +// ── 6. Build ───────────────────────────────────────────────────────────────── + +step(6, TOTAL_STEPS, "Building and bundling packages..."); + +run("npm", ["run", "build"], { dryRun }); +ok("Build and bundle complete"); + +// ── 7. Prepare dist/ for publishing ────────────────────────────────────────── + +step(7, TOTAL_STEPS, "Preparing dist/ for publishing..."); + +const cliRoot = join(root, "packages", "cli"); +const distDir = join(cliRoot, "dist"); +const distCliJs = join(distDir, "cli.js"); +const distChunks = join(distDir, "chunks"); +const distTemplates = join(distDir, "templates"); +const distBundled = join(distDir, "bundled"); + +if (!existsSync(distCliJs)) { + fail(`Bundle artifact not found: ${distCliJs}. Run "npm run build" first.`); +} +if (!existsSync(distChunks)) { + fail(`Chunks directory not found: ${distChunks}. Run "npm run build" first.`); +} +if (!existsSync(distTemplates)) { + fail(`Templates directory not found: ${distTemplates}. Run "npm run build" first.`); +} +if (!existsSync(distBundled)) { + fail(`Bundled assets not found: ${distBundled}. Run "npm run build" first.`); +} + +validatePacklist( + cliRoot, + [ + { type: "file", value: "dist/cli.js" }, + { type: "prefix", value: "dist/chunks/", label: "dist/chunks/*.js" }, + { type: "prefix", value: "dist/templates/", label: "dist/templates/**" }, + { type: "prefix", value: "dist/bundled/", label: "dist/bundled/**" }, + ], + { label: "cd packages/cli && npm pack --dry-run --json --ignore-scripts" } +); + +// Copy README.md and LICENSE into dist/ +for (const file of ["README.md", "LICENSE"]) { + const src = join(root, file); + const dest = join(distDir, file); + if (existsSync(src)) { + if (!dryRun) { + copyFileSync(src, dest); + } + log(` Copied ${file} → dist/`); + } else { + log(` Warning: ${file} not found at ${src}`); + } +} + +// Write a new package.json for publishing with empty dependencies. +// All runtime code (including @vegamo/deepcode-core and its deps) is already +// bundled into dist/cli.js by esbuild with packages: "bundle". +const distPackageJson = { + name: cliPkg.name, + version: version, + description: cliPkg.description, + license: cliPkg.license, + type: "module", + main: "cli.js", + bin: { + deepcode: "cli.js", + }, + files: ["cli.js", "chunks/**", "templates/**", "bundled/**", "README.md", "LICENSE"], + engines: cliPkg.engines, + dependencies: {}, +}; + +if (!dryRun) { + writeJson(join(distDir, "package.json"), distPackageJson); +} +log(" Written dist/package.json with dependencies: {}"); + +if (!dryRun) { + validatePacklist( + distDir, + [ + { type: "file", value: "cli.js" }, + { type: "prefix", value: "chunks/", label: "chunks/*.js" }, + { type: "prefix", value: "templates/", label: "templates/**" }, + { type: "prefix", value: "bundled/", label: "bundled/**" }, + ], + { label: "cd dist && npm pack --dry-run --json --ignore-scripts" } + ); +} else { + log(" (dry-run) skipped dist/package.json tarball validation because dist/package.json was not written"); +} + +ok("dist/ prepared for publishing"); + +// ── Git commit + tag ───────────────────────────────────────────────────────── + +const releaseFiles = ["packages/core/package.json", "packages/cli/package.json", "package-lock.json"]; +const tagName = `v${version}`; + +if (!dryRun) { + log("\nCreating git commit and tag..."); + if (hasGitChanges(releaseFiles)) { + run("git", ["add", ...releaseFiles], { + label: "git add packages/*/package.json package-lock.json", + }); + run("git", ["commit", "-m", `chore(release): v${version}`], { + label: `git commit -m "chore(release): v${version}"`, + }); + } else { + log(" No release file changes to commit; tagging current HEAD"); + } + + if (gitTagExists(tagName)) { + fail(`Git tag already exists: ${tagName}`); + } + run("git", ["tag", tagName], { + label: `git tag ${tagName}`, + }); + ok(`Created tag ${tagName}`); +} else { + log("\n (dry-run) git add + commit + tag"); +} + +// ── 8. Publish from dist/ ──────────────────────────────────────────────────── + +step(8, TOTAL_STEPS, "Publishing @vegamo/deepcode-cli from dist/..."); + +const publishArgs = ["publish", "--access", "public", "--tag", tag, "--registry", "https://registry.npmjs.org"]; +if (dryRun) publishArgs.push("--dry-run"); + +run("npm", publishArgs, { + dryRun, + cwd: distDir, + label: `cd dist && npm ${publishArgs.join(" ")}`, +}); +ok(`Published @vegamo/deepcode-cli@${version}`); + +// ── Done ───────────────────────────────────────────────────────────────────── + +console.log("\n========================================="); +console.log(` 🎉 Published v${version} successfully!`); +console.log("========================================="); +console.log(` + Package published: + • @vegamo/deepcode-cli@${version} (core bundled, zero runtime dependencies) + + Verify: + npm view @vegamo/deepcode-cli version + npx @vegamo/deepcode-cli --version + + Push to remote: + git push && git push --tags +`); diff --git a/scripts/prepare-vscode.js b/scripts/prepare-vscode.js new file mode 100644 index 00000000..a9e0cb83 --- /dev/null +++ b/scripts/prepare-vscode.js @@ -0,0 +1,260 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +// Load .env file if VSCE_PAT is not already set +if (!process.env.VSCE_PAT) { + const envPath = join(root, ".env"); + if (existsSync(envPath)) { + const lines = readFileSync(envPath, "utf-8").split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const value = trimmed.slice(eqIdx + 1).trim(); + if (key && !process.env[key]) { + process.env[key] = value; + } + } + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function log(msg) { + console.log(msg); +} + +function step(n, total, msg) { + console.log(`\n[${n}/${total}] ${msg}`); +} + +function fail(msg) { + console.error(`\n❌ ${msg}`); + process.exit(1); +} + +function ok(msg) { + console.log(`✅ ${msg}`); +} + +function run(cmd, args, opts = {}) { + const label = opts.label ?? `${cmd} ${args.join(" ")}`; + if (opts.dryRun) { + log(` (dry-run) ${label}`); + return { status: 0, stdout: "" }; + } + const result = spawnSync(cmd, args, { + stdio: opts.stdio ?? "inherit", + cwd: opts.cwd ?? root, + shell: true, + env: { ...process.env, ...opts.env }, + }); + if (result.status !== 0) { + fail(`Command failed: ${label}`); + } + return result; +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf-8")); +} + +function writeJson(filePath, data) { + writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8"); +} + +function isValidSemver(v) { + return /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(v); +} + +// ── Parse args ─────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +let version = null; +let dryRun = false; +let force = false; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--dry-run") { + dryRun = true; + } else if (arg === "--force") { + force = true; + } else if (!version) { + version = arg; + } else { + fail(`Unknown argument: ${arg}`); + } +} + +if (!version) { + log(` +Usage: node scripts/prepare-vscode.js [options] + +Arguments: + Semver version to publish (e.g. 0.1.32, 0.2.0-beta.1) + +Options: + --dry-run Preview all steps without executing + --force Skip branch check (publish from non-main branch) + +Environment: + VSCE_PAT Required. Azure DevOps Personal Access Token for marketplace auth. + Generate at: https://dev.azure.com/vegamo/_usersSettings/tokens + Can also be set in .env file (auto-loaded). + +Examples: + VSCE_PAT=xxx node scripts/prepare-vscode.js 0.1.32 + node scripts/prepare-vscode.js 0.1.32-beta.1 + node scripts/prepare-vscode.js 0.1.32 --dry-run +`); + process.exit(1); +} + +if (!isValidSemver(version)) { + fail(`Invalid semver version: ${version}`); +} + +const TOTAL_STEPS = 7; + +// ── Banner ─────────────────────────────────────────────────────────────────── + +log("========================================="); +log(` Deep Code VSCode — Publish v${version}`); +log(` dryRun=${dryRun} force=${force}`); +log("========================================="); + +// ── 1. Git checks ──────────────────────────────────────────────────────────── + +step(1, TOTAL_STEPS, "Checking git state..."); + +const gitStatus = spawnSync("git", ["status", "--porcelain"], { + cwd: root, + encoding: "utf-8", + shell: true, +}); +if (gitStatus.stdout.trim()) { + fail("Working tree is not clean. Commit or stash changes first."); +} +ok("Working tree is clean"); + +if (!force) { + const gitBranch = spawnSync("git", ["branch", "--show-current"], { + cwd: root, + encoding: "utf-8", + shell: true, + }); + const branch = gitBranch.stdout.trim(); + if (branch !== "main") { + fail(`Not on main branch (current: ${branch}). Use --force to publish from another branch.`); + } + ok("On main branch"); +} + +// ── 2. VSCE_PAT check ──────────────────────────────────────────────────────── + +step(2, TOTAL_STEPS, "Checking VSCE_PAT..."); + +if (!dryRun) { + if (!process.env.VSCE_PAT) { + fail( + "VSCE_PAT environment variable is not set.\n Generate a Personal Access Token at:\n https://dev.azure.com/vegamo/_usersSettings/tokens\n Then: VSCE_PAT= node scripts/prepare-vscode.js " + ); + } + ok("VSCE_PAT is set"); +} else { + log(" (dry-run) skipping VSCE_PAT check"); +} + +// ── 3. Version bump ────────────────────────────────────────────────────────── + +step(3, TOTAL_STEPS, "Updating package version..."); + +const vscodePkgPath = join(root, "packages", "vscode-ide-companion", "package.json"); + +const vscodePkg = readJson(vscodePkgPath); + +const oldVersion = vscodePkg.version; + +vscodePkg.version = version; + +if (!dryRun) { + writeJson(vscodePkgPath, vscodePkg); + ok(`Updated packages/vscode-ide-companion: ${oldVersion} → ${version}`); +} else { + log(` (dry-run) packages/vscode-ide-companion: ${oldVersion} → ${version}`); +} + +// ── 4. Quality checks ──────────────────────────────────────────────────────── + +step(4, TOTAL_STEPS, "Running quality checks (typecheck + lint + format)..."); + +run("npm", ["run", "check"], { dryRun }); +ok("All checks passed"); + +// ── 5. Tests ────────────────────────────────────────────────────────────────── + +step(5, TOTAL_STEPS, "Running tests..."); + +run("npm", ["run", "test", "--workspaces"], { dryRun }); +ok("All tests passed"); + +// ── 6. Build ────────────────────────────────────────────────────────────────── + +step(6, TOTAL_STEPS, "Building VSCode extension..."); + +run("npm", ["run", "build:vscode"], { dryRun }); +ok("VSCode extension built"); + +// ── 7. Publish to marketplace ───────────────────────────────────────────────── + +step(7, TOTAL_STEPS, "Publishing deepcode-vscode to marketplace..."); + +const vscodeRoot = join(root, "packages", "vscode-ide-companion"); +const vsceArgs = ["vsce", "publish", version, "--no-dependencies"]; +if (dryRun) vsceArgs.splice(2, 0, "--dry-run"); + +run("npx", vsceArgs, { + cwd: vscodeRoot, + env: { VSCE_PAT: process.env.VSCE_PAT }, + label: `npx ${vsceArgs.join(" ")}`, +}); +ok(`Published deepcode-vscode@${version} to marketplace`); + +// ── Git commit + tag ───────────────────────────────────────────────────────── + +if (!dryRun) { + log("\nCreating git commit and tag..."); + run("git", ["add", "packages/vscode-ide-companion/package.json"], { + label: "git add packages/vscode-ide-companion/package.json", + }); + run("git", ["commit", "-m", `chore(release): vscode v${version}`], { + label: `git commit -m "chore(release): vscode v${version}"`, + }); + run("git", ["tag", `vscode-v${version}`], { + label: `git tag vscode-v${version}`, + }); + ok(`Created commit and tag vscode-v${version}`); +} else { + log("\n (dry-run) git add + commit + tag"); +} + +// ── Done ───────────────────────────────────────────────────────────────────── + +console.log("\n========================================="); +console.log(` 🎉 Published deepcode-vscode@${version} successfully!`); +console.log("========================================="); +console.log(` + Verify: + https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode + + Push to remote: + git push && git push --tags +`); diff --git a/scripts/rewrite-esm-imports.js b/scripts/rewrite-esm-imports.js new file mode 100644 index 00000000..715e5c3b --- /dev/null +++ b/scripts/rewrite-esm-imports.js @@ -0,0 +1,40 @@ +/** + * Post-build script: rewrites extensionless relative imports in the core + * package's dist/ output to include explicit ".js" extensions. + * + * tsc with moduleResolution:"bundler" emits `from "./foo"` (no extension). + * Node.js ESM requires `from "./foo.js"`. This script bridges the gap. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); +const distDir = join(root, "packages", "core", "dist"); + +const files = globSync("**/*.js", { cwd: distDir, absolute: true }); + +// Match: from "./anything" or from "../anything" +// Negative lookahead: skip if already ends with .js, .json, .node, or is a bare specifier +const IMPORT_RE = /(from\s+["'])(\.\.?\/[^"']+?)(? { + rewrites++; + return `${prefix}${specifier}.js${quote}`; + }); + + if (rewrites > 0) { + writeFileSync(filePath, updated, "utf8"); + totalRewrites += rewrites; + } +} + +console.log(`\n✅ Rewrote ${totalRewrites} imports across ${files.length} files in core/dist/\n`); diff --git a/scripts/start.js b/scripts/start.js new file mode 100644 index 00000000..b3d83be1 --- /dev/null +++ b/scripts/start.js @@ -0,0 +1,22 @@ +import { existsSync } from "node:fs"; +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); +const cliDist = join(root, "packages", "cli", "dist", "cli.js"); + +if (!existsSync(cliDist)) { + console.error(`Error: ${cliDist} not found. Run 'npm run build' first.`); + process.exit(1); +} + +console.log("Starting Deep Code CLI...\n"); + +const child = spawn("node", [cliDist, ...process.argv.slice(2)], { + stdio: "inherit", + cwd: root, +}); + +child.on("exit", (code) => process.exit(code ?? 1)); diff --git a/scripts/version.js b/scripts/version.js new file mode 100644 index 00000000..5d2fa4f1 --- /dev/null +++ b/scripts/version.js @@ -0,0 +1,296 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, ".."); + +const BUMP_TYPES = ["major", "minor", "patch", "premajor", "preminor", "prepatch", "prerelease", "from-git"]; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function log(msg) { + console.log(msg); +} + +function fail(msg) { + console.error(`\n❌ ${msg}`); + process.exit(1); +} + +function ok(msg) { + console.log(`✅ ${msg}`); +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf-8")); +} + +function writeJson(filePath, data) { + writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf-8"); +} + +function isValidSemver(v) { + return /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/.test(v); +} + +function isBumpType(v) { + return BUMP_TYPES.includes(v); +} + +function run(cmd, args, opts = {}) { + const result = spawnSync(cmd, args, { + stdio: opts.stdio ?? "inherit", + cwd: opts.cwd ?? root, + shell: true, + }); + if (result.status !== 0) { + fail(`Command failed: ${cmd} ${args.join(" ")}`); + } + return result; +} + +function runSilent(cmd, args) { + const result = spawnSync(cmd, args, { + cwd: root, + encoding: "utf-8", + shell: true, + }); + if (result.status !== 0) { + return null; + } + return result.stdout.trim(); +} + +// ── Version bump logic ─────────────────────────────────────────────────────── + +function parseVersion(v) { + const match = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/); + if (!match) fail(`Cannot parse version: ${v}`); + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + }; +} + +function formatVersion({ major, minor, patch, prerelease }) { + let v = `${major}.${minor}.${patch}`; + if (prerelease) v += `-${prerelease}`; + return v; +} + +function bumpVersion(current, type, preid) { + const v = parseVersion(current); + + switch (type) { + case "major": + return formatVersion({ major: v.major + 1, minor: 0, patch: 0, prerelease: null }); + + case "minor": + return formatVersion({ major: v.major, minor: v.minor + 1, patch: 0, prerelease: null }); + + case "patch": + if (v.prerelease) { + // 0.1.32-beta.1 → 0.1.32 (drop prerelease) + return formatVersion({ ...v, prerelease: null }); + } + return formatVersion({ ...v, patch: v.patch + 1 }); + + case "premajor": + return formatVersion({ + major: v.major + 1, + minor: 0, + patch: 0, + prerelease: `${preid}.0`, + }); + + case "preminor": + return formatVersion({ + major: v.major, + minor: v.minor + 1, + patch: 0, + prerelease: `${preid}.0`, + }); + + case "prepatch": + if (v.prerelease) { + // Already a prerelease — increment the prerelease number + const num = Number(v.prerelease.split(".").pop()); + const base = v.prerelease.split(".").slice(0, -1).join("."); + if (!isNaN(num)) { + return formatVersion({ ...v, prerelease: `${base}.${num + 1}` }); + } + } + return formatVersion({ + ...v, + patch: v.patch + 1, + prerelease: `${preid}.0`, + }); + + case "prerelease": + if (v.prerelease) { + // 0.1.32-beta.0 → 0.1.32-beta.1 + const num = Number(v.prerelease.split(".").pop()); + const base = v.prerelease.split(".").slice(0, -1).join("."); + if (!isNaN(num)) { + const newPre = base ? `${base}.${num + 1}` : `${num + 1}`; + return formatVersion({ ...v, prerelease: newPre }); + } + // Can't parse number, append .0 + return formatVersion({ ...v, prerelease: `${v.prerelease}.0` }); + } + // No prerelease yet — go to next patch prerelease + return formatVersion({ + ...v, + patch: v.patch + 1, + prerelease: `${preid}.0`, + }); + + default: + fail(`Unknown bump type: ${type}`); + } +} + +function resolveVersionFromGit() { + // Get latest tag matching v* + const tag = runSilent("git", ["describe", "--tags", "--abbrev=0"]); + if (!tag) { + fail("No git tags found. Cannot use 'from-git'."); + } + const v = tag.replace(/^v/, ""); + if (!isValidSemver(v)) { + fail(`Latest git tag is not a valid semver: ${tag}`); + } + return v; +} + +// ── Parse args ─────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +let bumpArg = null; +let preid = "0"; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help" || arg === "-h") { + log(` +Usage: npm run release:version -- [--preid ] + +Bumps all workspace package.json files and regenerates package-lock.json. +Works like npm version but for the entire monorepo. + +Bump types: + major 0.1.31 → 1.0.0 + minor 0.1.31 → 0.2.0 + patch 0.1.31 → 0.1.32 + premajor 0.1.31 → 1.0.0-0 + preminor 0.1.31 → 0.2.0-0 + prepatch 0.1.31 → 0.1.32-0 + prerelease 0.1.31 → 0.1.32-0 0.1.32-0 → 0.1.32-1 + from-git Use version from latest git tag + +Options: + --preid Prerelease identifier (default: "0", e.g. "beta", "alpha") + +Examples: + npm run release:version -- patch + npm run release:version -- minor + npm run release:version -- 0.2.0 + npm run release:version -- prerelease --preid beta + npm run release:version -- from-git +`); + process.exit(0); + } else if (arg === "--preid") { + preid = args[++i]; + if (!preid) fail("--preid requires a value"); + } else if (!bumpArg) { + bumpArg = arg; + } else { + fail(`Unknown argument: ${arg}`); + } +} + +if (!bumpArg) { + log(` +Usage: npm run release:version -- [--preid ] + Run with --help for details. +`); + process.exit(1); +} + +// ── Resolve target version ─────────────────────────────────────────────────── + +const corePkgPath = join(root, "packages", "core", "package.json"); +const currentVersion = readJson(corePkgPath).version; + +let version; + +if (bumpArg === "from-git") { + version = resolveVersionFromGit(); + log(`Resolved from git tag: v${version}`); +} else if (isBumpType(bumpArg)) { + version = bumpVersion(currentVersion, bumpArg, preid); +} else if (isValidSemver(bumpArg)) { + version = bumpArg; +} else { + fail(`Invalid argument: "${bumpArg}". Expected a bump type (${BUMP_TYPES.join(", ")}) or a semver version.`); +} + +// ── Banner ─────────────────────────────────────────────────────────────────── + +log("========================================="); +log(` Deep Code — Bump Version`); +log(` ${currentVersion} → ${version}`); +log("=========================================\n"); + +// ── Find all workspace package.json ────────────────────────────────────────── + +const pkgPaths = globSync("packages/*/package.json", { cwd: root, absolute: true }); + +if (pkgPaths.length === 0) { + fail("No workspace packages found under packages/"); +} + +// ── Update versions ────────────────────────────────────────────────────────── + +log("Updating package.json files:\n"); + +for (const pkgPath of pkgPaths) { + const pkg = readJson(pkgPath); + const oldVersion = pkg.version; + pkg.version = version; + writeJson(pkgPath, pkg); + const short = pkgPath.replace(root + "/", ""); + log(` ${short}: ${oldVersion} → ${version}`); +} + +// ── Regenerate lockfile ────────────────────────────────────────────────────── + +log("\nRegenerating package-lock.json...\n"); + +const lockPath = join(root, "package-lock.json"); +try { + unlinkSync(lockPath); + log(" Removed old package-lock.json"); +} catch { + // lockfile may not exist, that's fine +} + +run("npm", ["install", "--package-lock-only"]); +ok("package-lock.json regenerated"); + +// ── Done ───────────────────────────────────────────────────────────────────── + +console.log("\n========================================="); +log(` 🎉 Version bumped to v${version}`); +console.log("========================================="); +console.log(` + Updated ${pkgPaths.length} packages. Next steps: + git add -A && git commit -m "chore(release): v${version}" + git tag v${version} + git push && git push --tags +`); diff --git a/src/cli.tsx b/src/cli.tsx deleted file mode 100644 index 435499a9..00000000 --- a/src/cli.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import React from "react"; -import { render } from "ink"; -import { App } from "./ui"; -import { setShellIfWindows } from "./common/shell-utils"; -import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck"; - -const args = process.argv.slice(2); -const packageInfo = readPackageInfo(); - -if (args.includes("--version") || args.includes("-v")) { - process.stdout.write(`${packageInfo.version || "unknown"}\n`); - process.exit(0); -} - -if (args.includes("--help") || args.includes("-h")) { - process.stdout.write( - [ - "deepcode - Deep Code CLI", - "", - "Usage:", - " deepcode Launch the interactive TUI in the current directory", - " deepcode -p Launch with a pre-filled prompt", - " deepcode --prompt Same as -p", - " deepcode --version Print the version", - " deepcode --help Show this help", - "", - "Configuration:", - " ~/.deepcode/settings.json User-level API key, model, base URL", - " ./.deepcode/settings.json Project-level settings", - " ~/.agents/skills/*/SKILL.md User-level skills", - " ./.agents/skills/*/SKILL.md Project-level skills", - " ./.deepcode/skills/*/SKILL.md Legacy project-level skills", - "", - "Inside the TUI:", - " enter Send the prompt", - " shift+enter Insert a newline", - " home/end Move within the current line", - " alt+left/right Move by word", - " ctrl+w Delete the previous word", - " ctrl+v Paste an image from the clipboard", - " ctrl+x Clear pasted images", - " esc Interrupt the current model turn", - " / Open the skills/commands menu", - " /new Start a fresh conversation", - " /init Initialize an AGENTS.md file with instructions for LLM", - " /resume Pick a previous conversation to continue", - " /continue Continue the active conversation, or resume one if empty", - " /exit Quit", - " ctrl+d twice Quit", - ].join("\n") + "\n" - ); - process.exit(0); -} - -function extractInitialPrompt(args: string[]): string | undefined { - const promptIndex = args.findIndex((arg) => arg === "-p" || arg === "--prompt"); - if (promptIndex !== -1 && promptIndex + 1 < args.length) { - return args[promptIndex + 1]; - } - return undefined; -} - -let initialPrompt = extractInitialPrompt(args); -const projectRoot = process.cwd(); -configureWindowsShell(); - -if (!process.stdin.isTTY) { - process.stderr.write("deepcode requires an interactive terminal (TTY). " + "Re-run from a real terminal session.\n"); - process.exit(1); -} - -void main(); - -async function main(): Promise { - const updatePromptResult = await promptForPendingUpdate(packageInfo); - - const restartRef: { current: (() => void) | null } = { current: null }; - - function startApp(): void { - let restarting = false; - const appInitialPrompt = initialPrompt; - initialPrompt = undefined; - const inkInstance = render( - restartRef.current?.()} - />, - { exitOnCtrlC: false } - ); - - restartRef.current = () => { - restarting = true; - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); - inkInstance.unmount(); - startApp(); - }; - - inkInstance.waitUntilExit().then(() => { - if (!restarting) { - restartRef.current = null; - process.exit(0); - } - }); - } - - if (!updatePromptResult.installed) { - void checkForNpmUpdate(packageInfo); - } - - startApp(); -} - -function configureWindowsShell(): void { - process.env.NoDefaultCurrentDirectoryInExePath = "1"; - try { - setShellIfWindows(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`deepcode: ${message}\n`); - process.exit(1); - } -} - -function readPackageInfo(): PackageInfo { - try { - const pkg = require("../package.json") as { name?: unknown; version?: unknown }; - return { - name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli", - version: typeof pkg.version === "string" ? pkg.version : "", - }; - } catch { - return { name: "@vegamo/deepcode-cli", version: "" }; - } -} diff --git a/src/common/state.ts b/src/common/state.ts deleted file mode 100644 index add27f35..00000000 --- a/src/common/state.ts +++ /dev/null @@ -1,185 +0,0 @@ -import * as path from "path"; -import { posixPathToWindowsPath } from "./shell-utils"; - -export type FileLineEnding = "LF" | "CRLF"; - -export type FileState = { - filePath: string; - content: string; - timestamp: number; - version?: number; - offset?: number; - limit?: number; - isPartialView?: boolean; - encoding?: BufferEncoding; - lineEndings?: FileLineEnding; -}; - -export type FileSnippet = { - id: string; - filePath: string; - startLine: number; - endLine: number; - preview: string; - fileVersion: number; -}; - -const fileStatesBySession = new Map>(); -const snippetsBySession = new Map>(); -const snippetCountersBySession = new Map(); -const fileVersionsBySession = new Map>(); - -export function normalizeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string { - const nativePath = normalizeNativeFilePath(filePath, platform); - return platform === "win32" ? path.win32.normalize(nativePath) : path.normalize(nativePath); -} - -export function normalizeNativeFilePath(filePath: string, platform: NodeJS.Platform = process.platform): string { - if (platform !== "win32") { - return filePath; - } - - if (isGitBashAbsolutePath(filePath)) { - return posixPathToWindowsPath(filePath); - } - - return filePath; -} - -export function isAbsoluteFilePath(filePath: string, platform: NodeJS.Platform = process.platform): boolean { - const nativePath = normalizeNativeFilePath(filePath, platform); - if (platform !== "win32") { - return path.isAbsolute(nativePath); - } - - const normalized = path.win32.normalize(nativePath); - return path.win32.isAbsolute(normalized) && (/^[A-Za-z]:[\\/]/.test(normalized) || /^\\\\/.test(normalized)); -} - -function isGitBashAbsolutePath(filePath: string): boolean { - return /^\/[A-Za-z](?:\/|$)/.test(filePath) || /^\/cygdrive\/[A-Za-z](?:\/|$)/.test(filePath); -} - -export function recordFileState( - sessionId: string, - state: FileState, - options: { incrementVersion?: boolean } = {} -): void { - if (!sessionId || !state.filePath) { - return; - } - - let sessionState = fileStatesBySession.get(sessionId); - if (!sessionState) { - sessionState = new Map(); - fileStatesBySession.set(sessionId, sessionState); - } - - const normalizedPath = normalizeFilePath(state.filePath); - const currentVersion = getFileVersion(sessionId, normalizedPath); - const nextVersion = options.incrementVersion ? currentVersion + 1 : currentVersion; - setFileVersion(sessionId, normalizedPath, nextVersion); - sessionState.set(normalizedPath, { - ...state, - filePath: normalizedPath, - version: nextVersion, - }); -} - -export function markFileRead( - sessionId: string, - filePath: string, - state: Omit | null = null -): void { - if (!sessionId || !filePath) { - return; - } - - recordFileState(sessionId, { - filePath, - content: state?.content ?? "", - timestamp: state?.timestamp ?? 0, - offset: state?.offset, - limit: state?.limit, - isPartialView: state?.isPartialView, - encoding: state?.encoding, - lineEndings: state?.lineEndings, - }); -} - -export function getFileState(sessionId: string, filePath: string): FileState | null { - if (!sessionId || !filePath) { - return null; - } - - return fileStatesBySession.get(sessionId)?.get(normalizeFilePath(filePath)) ?? null; -} - -export function wasFileRead(sessionId: string, filePath: string): boolean { - return getFileState(sessionId, filePath) !== null; -} - -export function getFileVersion(sessionId: string, filePath: string): number { - if (!sessionId || !filePath) { - return 0; - } - return fileVersionsBySession.get(sessionId)?.get(normalizeFilePath(filePath)) ?? 0; -} - -function setFileVersion(sessionId: string, filePath: string, version: number): void { - let sessionVersions = fileVersionsBySession.get(sessionId); - if (!sessionVersions) { - sessionVersions = new Map(); - fileVersionsBySession.set(sessionId, sessionVersions); - } - sessionVersions.set(normalizeFilePath(filePath), version); -} - -export function isFullFileView(state: FileState | null): boolean { - return Boolean( - state && !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined" - ); -} - -export function createSnippet( - sessionId: string, - filePath: string, - startLine: number, - endLine: number, - preview: string -): FileSnippet | null { - if (!sessionId || !filePath || startLine < 1 || endLine < startLine) { - return null; - } - - const nextCounter = (snippetCountersBySession.get(sessionId) ?? 0) + 1; - snippetCountersBySession.set(sessionId, nextCounter); - - const snippet: FileSnippet = { - id: `snippet_${nextCounter}`, - filePath: normalizeFilePath(filePath), - startLine, - endLine, - preview, - fileVersion: getFileVersion(sessionId, filePath), - }; - - let snippets = snippetsBySession.get(sessionId); - if (!snippets) { - snippets = new Map(); - snippetsBySession.set(sessionId, snippets); - } - snippets.set(snippet.id, snippet); - return snippet; -} - -export function getSnippet(sessionId: string, snippetId: string): FileSnippet | null { - if (!sessionId || !snippetId) { - return null; - } - return snippetsBySession.get(sessionId)?.get(snippetId) ?? null; -} - -export function hasSnippetOutdatedFileVersion(sessionId: string, snippet: FileSnippet): boolean { - return getFileVersion(sessionId, snippet.filePath) > snippet.fileVersion; -} diff --git a/src/settings.ts b/src/settings.ts deleted file mode 100644 index b5bb869e..00000000 --- a/src/settings.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { defaultsToThinkingMode } from "./common/model-capabilities"; - -export type DeepcodingEnv = Record & { - MODEL?: string; - BASE_URL?: string; - API_KEY?: string; - THINKING_ENABLED?: string; - REASONING_EFFORT?: string; - DEBUG_LOG_ENABLED?: string; -}; - -export type ReasoningEffort = "high" | "max"; - -export type McpServerConfig = { - command: string; - args?: string[]; - env?: Record; -}; - -export type DeepcodingSettings = { - env?: DeepcodingEnv; - model?: string; - thinkingEnabled?: boolean; - reasoningEffort?: ReasoningEffort; - debugLogEnabled?: boolean; - notify?: string; - webSearchTool?: string; - mcpServers?: Record; -}; - -export type ResolvedDeepcodingSettings = { - env: Record; - apiKey?: string; - baseURL: string; - model: string; - thinkingEnabled: boolean; - reasoningEffort: ReasoningEffort; - debugLogEnabled: boolean; - notify?: string; - webSearchTool?: string; - mcpServers?: Record; -}; - -export type ModelConfigSelection = { - model: string; - thinkingEnabled: boolean; - reasoningEffort: ReasoningEffort; -}; - -export type SettingsProcessEnv = Record; - -function resolveReasoningEffort(value: unknown): ReasoningEffort | undefined { - return value === "high" || value === "max" ? value : undefined; -} - -function parseBoolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") { - return value; - } - if (typeof value !== "string") { - return undefined; - } - - const normalized = value.trim().toLowerCase(); - if (["1", "true", "enabled", "yes", "on"].includes(normalized)) { - return true; - } - if (["0", "false", "disabled", "no", "off"].includes(normalized)) { - return false; - } - return undefined; -} - -function trimString(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function normalizeEnv(env: DeepcodingSettings["env"]): Record { - const result: Record = {}; - if (!env) { - return result; - } - - for (const [key, value] of Object.entries(env)) { - if (typeof value === "string") { - result[key] = value; - } - } - return result; -} - -export function collectDeepcodeEnv(processEnv: SettingsProcessEnv = process.env): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(processEnv)) { - if (!key.startsWith("DEEPCODE_") || typeof value !== "string") { - continue; - } - const strippedKey = key.slice("DEEPCODE_".length); - if (strippedKey) { - result[strippedKey] = value; - } - } - return result; -} - -function extractMcpEnv(env: Record): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(env)) { - if (!key.startsWith("MCP_")) { - continue; - } - const strippedKey = key.slice("MCP_".length); - if (strippedKey) { - result[strippedKey] = value; - } - } - return result; -} - -function mergeMcpServers( - userSettings: DeepcodingSettings | null | undefined, - projectSettings: DeepcodingSettings | null | undefined, - userEnv: Record, - projectEnv: Record, - systemEnv: Record -): Record | undefined { - const userServers = userSettings?.mcpServers ?? {}; - const projectServers = projectSettings?.mcpServers ?? {}; - const serverNames = new Set([...Object.keys(userServers), ...Object.keys(projectServers)]); - if (serverNames.size === 0) { - return undefined; - } - - const userMcpEnv = extractMcpEnv(userEnv); - const projectMcpEnv = extractMcpEnv(projectEnv); - const systemMcpEnv = extractMcpEnv(systemEnv); - const merged: Record = {}; - - for (const name of serverNames) { - const userConfig = userServers[name]; - const projectConfig = projectServers[name]; - const command = projectConfig?.command ?? userConfig?.command; - if (!command) { - continue; - } - - const env = { - ...userEnv, - ...(userConfig?.env ?? {}), - ...userMcpEnv, - ...projectEnv, - ...(projectConfig?.env ?? {}), - ...projectMcpEnv, - ...systemEnv, - ...systemMcpEnv, - }; - const config: McpServerConfig = { - command, - args: projectConfig?.args ?? userConfig?.args, - }; - if (Object.keys(env).length > 0) { - config.env = env; - } - merged[name] = config; - } - - return Object.keys(merged).length > 0 ? merged : undefined; -} - -export function resolveSettingsSources( - userSettings: DeepcodingSettings | null | undefined, - projectSettings: DeepcodingSettings | null | undefined, - defaults: { model: string; baseURL: string }, - processEnv: SettingsProcessEnv = process.env -): ResolvedDeepcodingSettings { - const userEnv = normalizeEnv(userSettings?.env); - const projectEnv = normalizeEnv(projectSettings?.env); - const systemEnv = collectDeepcodeEnv(processEnv); - const env = { - ...userEnv, - ...projectEnv, - ...systemEnv, - }; - - const model = - trimString(systemEnv.MODEL) || - trimString(projectSettings?.model) || - trimString(projectEnv.MODEL) || - trimString(userSettings?.model) || - trimString(userEnv.MODEL) || - defaults.model; - - const thinkingEnabled = - parseBoolean(systemEnv.THINKING_ENABLED) ?? - parseBoolean(projectSettings?.thinkingEnabled) ?? - parseBoolean(projectEnv.THINKING_ENABLED) ?? - parseBoolean(userSettings?.thinkingEnabled) ?? - parseBoolean(userEnv.THINKING_ENABLED) ?? - defaultsToThinkingMode(model); - - const reasoningEffort = - resolveReasoningEffort(systemEnv.REASONING_EFFORT) ?? - resolveReasoningEffort(projectSettings?.reasoningEffort) ?? - resolveReasoningEffort(projectEnv.REASONING_EFFORT) ?? - resolveReasoningEffort(userSettings?.reasoningEffort) ?? - resolveReasoningEffort(userEnv.REASONING_EFFORT) ?? - "max"; - - const debugLogEnabled = - parseBoolean(systemEnv.DEBUG_LOG_ENABLED) ?? - parseBoolean(projectSettings?.debugLogEnabled) ?? - parseBoolean(projectEnv.DEBUG_LOG_ENABLED) ?? - parseBoolean(userSettings?.debugLogEnabled) ?? - parseBoolean(userEnv.DEBUG_LOG_ENABLED) ?? - false; - - const notify = - trimString(systemEnv.NOTIFY) || trimString(projectSettings?.notify) || trimString(userSettings?.notify) || ""; - const webSearchTool = - trimString(systemEnv.WEB_SEARCH_TOOL) || - trimString(projectSettings?.webSearchTool) || - trimString(userSettings?.webSearchTool) || - ""; - - return { - env, - apiKey: trimString(env.API_KEY) || undefined, - baseURL: trimString(env.BASE_URL) || defaults.baseURL, - model, - thinkingEnabled, - reasoningEffort, - debugLogEnabled, - notify: notify || undefined, - webSearchTool: webSearchTool || undefined, - mcpServers: mergeMcpServers(userSettings, projectSettings, userEnv, projectEnv, systemEnv), - }; -} - -export function resolveSettings( - settings: DeepcodingSettings | null | undefined, - defaults: { model: string; baseURL: string }, - processEnv: SettingsProcessEnv = process.env -): ResolvedDeepcodingSettings { - return resolveSettingsSources(settings, null, defaults, processEnv); -} - -export function modelConfigKey(config: Pick): string { - return config.thinkingEnabled ? `thinking:${config.reasoningEffort}` : "thinking:none"; -} - -export function applyModelConfigSelection( - settings: DeepcodingSettings | null | undefined, - current: ModelConfigSelection, - selected: ModelConfigSelection -): { settings: DeepcodingSettings; changed: boolean } { - const changed = selected.model !== current.model || modelConfigKey(selected) !== modelConfigKey(current); - const next: DeepcodingSettings = { ...(settings ?? {}) }; - - if (!changed) { - return { settings: next, changed: false }; - } - - if (selected.model !== current.model || Object.prototype.hasOwnProperty.call(next, "model")) { - next.model = selected.model; - } else { - delete next.model; - } - - next.thinkingEnabled = selected.thinkingEnabled; - if (selected.thinkingEnabled) { - next.reasoningEffort = selected.reasoningEffort; - } - - return { settings: next, changed: true }; -} diff --git a/src/tests/markdown.test.ts b/src/tests/markdown.test.ts deleted file mode 100644 index a0127fcb..00000000 --- a/src/tests/markdown.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { renderMarkdown } from "../ui"; - -function stripAnsi(text: string): string { - return text.replace(/\[[0-9;]*m/g, ""); -} - -test("renderMarkdown returns empty string for empty input", () => { - assert.equal(renderMarkdown(""), ""); -}); - -test("renderMarkdown preserves heading text", () => { - const result = stripAnsi(renderMarkdown("# Title")); - assert.equal(result.includes("Title"), true); - assert.equal(result.includes("#"), true); -}); - -test("renderMarkdown preserves code fences with language tag", () => { - const result = stripAnsi(renderMarkdown("```js\nconsole.log(1);\n```")); - assert.equal(result.includes("[js]"), true); - assert.equal(result.includes("console.log(1);"), true); -}); - -test("renderMarkdown styles inline code without removing it", () => { - const result = stripAnsi(renderMarkdown("Use `npm install` first.")); - assert.equal(result.includes("npm install"), true); -}); - -test("renderMarkdown keeps bullet markers", () => { - const result = stripAnsi(renderMarkdown("- item one\n- item two")); - assert.equal(result.includes("- item one"), true); - assert.equal(result.includes("- item two"), true); -}); - -test("renderMarkdown handles plain text unchanged in stripped form", () => { - const text = "hello world\nthis is a sentence"; - const result = stripAnsi(renderMarkdown(text)); - assert.equal(result, text); -}); diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts deleted file mode 100644 index fef4bc3d..00000000 --- a/src/tests/messageView.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { MessageView, parseDiffPreview } from "../ui"; -import type { SessionMessage } from "../session"; - -test("parseDiffPreview removes headers and classifies lines", () => { - const lines = parseDiffPreview( - ["--- a/file.txt", "+++ b/file.txt", "@@ -1,1 +1,1 @@", " context", "-old", "+new"].join("\n") - ); - - assert.deepEqual(lines, [ - { marker: " ", content: "context", kind: "context" }, - { marker: "-", content: "old", kind: "removed" }, - { marker: "+", content: "new", kind: "added" }, - ]); -}); - -test("parseDiffPreview keeps nonstandard context lines", () => { - const lines = parseDiffPreview("...\n+added"); - assert.deepEqual(lines, [ - { marker: " ", content: "...", kind: "context" }, - { marker: "+", content: "added", kind: "added" }, - ]); -}); - -test("MessageView summarizes thinking content across lines", () => { - assert.equal( - getThinkingParams({ - content: "Plan:\n\nInspect the code and update tests", - }), - "Plan: Inspect the code and update tests" - ); -}); - -test("MessageView removes a trailing colon from thinking summaries", () => { - assert.equal(getThinkingParams({ content: "Planning:" }), "Planning"); -}); - -test("MessageView falls back to a reasoning placeholder for hidden reasoning content", () => { - assert.equal( - getThinkingParams({ - content: "", - messageParams: { reasoning_content: "hidden chain of thought" }, - }), - "(reasoning...)" - ); -}); - -function getThinkingParams(overrides: Partial): string { - const view = MessageView({ message: buildAssistantMessage(overrides) }) as any; - return view.props.children.props.params; -} - -function buildAssistantMessage(overrides: Partial): SessionMessage { - return { - id: "message-1", - sessionId: "session-1", - role: "assistant", - content: "", - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - meta: { asThinking: true }, - ...overrides, - }; -} diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts deleted file mode 100644 index cc86712d..00000000 --- a/src/tests/prompt.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import * as fs from "fs"; -import * as path from "path"; -import { fileURLToPath } from "url"; -import { getDefaultSkillPrompt, getRuntimeContext, getSystemPrompt, getTools } from "../prompt"; - -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); - -test("getTools always includes WebSearch", () => { - const names = getTools().map((tool) => tool.function.name); - assert.equal(names.includes("WebSearch"), true); -}); - -test("getTools includes UpdatePlan with string plan schema", () => { - const tool = getTools().find((candidate) => candidate.function.name === "UpdatePlan"); - assert.ok(tool); - assert.deepEqual(tool.function.parameters.required, ["plan"]); - assert.equal((tool.function.parameters.properties.plan as { type?: unknown }).type, "string"); -}); - -test("getSystemPrompt always includes WebSearch docs", () => { - const prompt = getSystemPrompt("/tmp/project"); - assert.equal(prompt.includes("## WebSearch"), true); -}); - -test("getSystemPrompt includes UpdatePlan docs", () => { - const prompt = getSystemPrompt("/tmp/project"); - assert.equal(prompt.includes("## UpdatePlan"), true); - assert.equal(prompt.includes("The `plan` argument is a markdown string, not an array of step objects."), true); -}); - -test("getSystemPrompt does not include runtime context", () => { - const prompt = getSystemPrompt("/tmp/project"); - assert.equal(prompt.includes("# Local Workspace Environment"), false); - assert.equal(prompt.includes('"root path": "/tmp/project"'), false); -}); - -test("getDefaultSkillPrompt loads default skill templates in order", () => { - const prompt = getDefaultSkillPrompt(); - const agentDriftIndex = prompt.indexOf(""); - const planIndex = prompt.indexOf(""); - - assert.notEqual(agentDriftIndex, -1); - assert.notEqual(planIndex, -1); - assert.equal(agentDriftIndex < planIndex, true); - assert.equal(prompt.includes("Use the skill documents below to assist the user:"), true); - assert.equal(prompt.includes('path="templates/skills/'), false); -}); - -test("getSystemPrompt does not include current date guidance", () => { - const now = new Date(); - const expected = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; - const prompt = getSystemPrompt("/tmp/project"); - assert.equal(prompt.includes(expected), false); -}); - -test("getRuntimeContext includes current date and model guidance", () => { - const now = new Date(); - const expectedDate = `今天是${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日。随着对话的进行,时间在流逝。`; - const prompt = getRuntimeContext("/tmp/project", "deepseek-v4-pro"); - assert.equal(prompt.includes(expectedDate), true); - assert.equal(prompt.includes("当前LLM模型为deepseek-v4-pro,对话中可通过/model命令切换模型。"), true); - assert.equal(prompt.includes("# Local Workspace Environment"), true); - assert.equal(prompt.includes('"root path": "/tmp/project"'), true); -}); - -test("getSystemPrompt renders Read docs for non-multimodal models", () => { - const prompt = getSystemPrompt("/tmp/project", { model: "deepseek-chat" }); - assert.equal(prompt.includes("the current model is not multimodal"), true); - assert.equal(prompt.includes("the contents are presented visually"), false); -}); - -test("runtime prompt assets live under templates", () => { - assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "web-search.md")), true); - assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md.ejs")), true); - assert.equal(fs.existsSync(path.join(repoRoot, "templates", "prompts", "init_command.md.ejs")), true); - assert.equal(fs.existsSync(path.join(repoRoot, "templates", "skills", "agent-drift-guard.md")), true); - assert.equal(fs.existsSync(path.join(repoRoot, "templates", "skills", "plan-and-execute.md")), true); - assert.equal(fs.existsSync(path.join(repoRoot, "templates", "tools", "read.md")), false); - assert.equal(fs.existsSync(path.join(repoRoot, "docs", "tools")), false); - assert.equal(fs.existsSync(path.join(repoRoot, "docs", "prompts")), false); -}); diff --git a/src/tests/run-tests.mjs b/src/tests/run-tests.mjs deleted file mode 100644 index 4d09f5b5..00000000 --- a/src/tests/run-tests.mjs +++ /dev/null @@ -1,13 +0,0 @@ -// Cross-platform test runner: finds all *.test.ts files and runs them via tsx. -// Uses the glob package for reliable cross-platform pattern expansion (Node 20+). -/* eslint-disable */ - -import { globSync } from "glob"; -import { spawnSync } from "child_process"; - -const cwd = new URL("../..", import.meta.url); -const testFiles = globSync("src/tests/*.test.ts", { cwd }); - -const result = spawnSync(process.execPath, ["--import", "tsx", "--test", ...testFiles], { stdio: "inherit", cwd }); - -process.exit(result.status ?? 1); diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts deleted file mode 100644 index b7eadaeb..00000000 --- a/src/tests/session.test.ts +++ /dev/null @@ -1,1749 +0,0 @@ -import { afterEach, test } from "node:test"; -import assert from "node:assert/strict"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import { SessionManager, type SessionMessage } from "../session"; - -const originalFetch = globalThis.fetch; -const originalConsoleWarn = console.warn; -const originalHome = process.env.HOME; -const originalUserProfile = process.env.USERPROFILE; -const tempDirs: string[] = []; - -/** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */ -function setHomeDir(dir: string): void { - process.env.HOME = dir; - if (process.platform === "win32") { - process.env.USERPROFILE = dir; - } -} - -afterEach(() => { - globalThis.fetch = originalFetch; - console.warn = originalConsoleWarn; - if (originalHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = originalHome; - } - if (originalUserProfile === undefined) { - delete process.env.USERPROFILE; - } else { - process.env.USERPROFILE = originalUserProfile; - } - - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - fs.rmSync(dir, { recursive: true, force: true }); - } - } -}); - -test("SessionManager preserves structured system content when building OpenAI messages", () => { - const manager = new SessionManager({ - projectRoot: process.cwd(), - createOpenAIClient: () => ({ - client: null, - model: "test-model", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); - - const messages: SessionMessage[] = [ - { - id: "system-image", - sessionId: "session-1", - role: "system", - content: "The read tool has loaded `pixel.png`.", - contentParams: [ - { - type: "image_url", - image_url: { url: "data:image/png;base64,abc123" }, - }, - ], - messageParams: null, - compacted: false, - visible: false, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }, - ]; - - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ - role: string; - content: unknown; - }>; - - assert.equal(openAIMessages.length, 1); - assert.equal(openAIMessages[0]?.role, "system"); - assert.deepEqual(openAIMessages[0]?.content, [ - { type: "text", text: "The read tool has loaded `pixel.png`." }, - { - type: "image_url", - image_url: { url: "data:image/png;base64,abc123" }, - }, - ]); -}); - -test("SessionManager filters image content for non-multimodal models", () => { - const manager = new SessionManager({ - projectRoot: process.cwd(), - createOpenAIClient: () => ({ - client: null, - model: "deepseek-chat", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "deepseek-chat" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); - - const messages: SessionMessage[] = [ - { - id: "system-image", - sessionId: "session-1", - role: "system", - content: "The read tool has loaded `pixel.png`.", - contentParams: [ - { - type: "image_url", - image_url: { url: "data:image/png;base64,abc123" }, - }, - ], - messageParams: null, - compacted: false, - visible: false, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }, - ]; - - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "deepseek-chat") as Array<{ - role: string; - content: unknown; - }>; - - assert.equal(openAIMessages.length, 1); - assert.deepEqual(openAIMessages[0]?.content, [{ type: "text", text: "The read tool has loaded `pixel.png`." }]); -}); - -test("SessionManager preserves empty reasoning content on assistant tool calls", () => { - const manager = new SessionManager({ - projectRoot: process.cwd(), - createOpenAIClient: () => ({ - client: null, - model: "test-model", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); - - const message = (manager as any).buildAssistantMessage( - "session-1", - "", - [ - { - id: "call-1", - type: "function", - function: { name: "read", arguments: "{}" }, - }, - ], - "" - ) as SessionMessage; - - assert.deepEqual(message.messageParams, { - tool_calls: [ - { - id: "call-1", - type: "function", - function: { name: "read", arguments: "{}" }, - }, - ], - reasoning_content: "", - }); - - const openAIMessages = (manager as any).buildOpenAIMessages([message], true, "test-model") as Array<{ - reasoning_content?: string; - }>; - - assert.equal(openAIMessages[0]?.reasoning_content, ""); -}); - -test("SessionManager repairs legacy thinking tool calls missing reasoning content", () => { - const manager = new SessionManager({ - projectRoot: process.cwd(), - createOpenAIClient: () => ({ - client: null, - model: "test-model", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); - - const messages: SessionMessage[] = [ - { - id: "assistant-tool", - sessionId: "session-1", - role: "assistant", - content: "", - contentParams: null, - messageParams: { - tool_calls: [ - { - id: "call-1", - type: "function", - function: { name: "read", arguments: "{}" }, - }, - ], - }, - compacted: false, - visible: false, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }, - ]; - - const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true, "test-model") as Array<{ - reasoning_content?: string; - }>; - const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ - reasoning_content?: string; - }>; - - assert.equal(thinkingMessages[0]?.reasoning_content, ""); - assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); -}); - -test("SessionManager replays normal assistant messages with reasoning content in thinking mode", () => { - const manager = new SessionManager({ - projectRoot: process.cwd(), - createOpenAIClient: () => ({ - client: null, - model: "test-model", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); - - const messages: SessionMessage[] = [ - { - id: "assistant-final", - sessionId: "session-1", - role: "assistant", - content: "Final answer", - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }, - ]; - - const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true, "test-model") as Array<{ - reasoning_content?: string; - }>; - const nonThinkingMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ - reasoning_content?: string; - }>; - - assert.equal(thinkingMessages[0]?.reasoning_content, ""); - assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); -}); - -test("SessionManager normalizes legacy sessions without activeTokens to zero", () => { - const workspace = createTempDir("deepcode-legacy-active-tokens-workspace-"); - const home = createTempDir("deepcode-legacy-active-tokens-home-"); - setHomeDir(home); - - const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); - const projectDir = path.join(home, ".deepcode", "projects", projectCode); - fs.mkdirSync(projectDir, { recursive: true }); - fs.writeFileSync( - path.join(projectDir, "sessions-index.json"), - JSON.stringify({ - version: 1, - originalPath: workspace, - entries: [ - { - id: "legacy-session", - status: "completed", - usage: { total_tokens: 123 }, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }, - ], - }), - "utf8" - ); - - const manager = createSessionManager(workspace, "machine-id-legacy"); - - assert.equal(manager.getSession("legacy-session")?.activeTokens, 0); - assert.equal(manager.getSession("legacy-session")?.usagePerModel, null); -}); - -test("SessionManager keeps usagePerModel null until response usage is available", async () => { - const workspace = createTempDir("deepcode-null-usage-per-model-workspace-"); - const home = createTempDir("deepcode-null-usage-per-model-home-"); - setHomeDir(home); - - const manager = createMockedClientSessionManager(workspace, [{ choices: [{ message: { content: "no usage" } }] }]); - - const sessionId = await manager.createSession({ text: "" }); - - assert.equal(manager.getSession(sessionId)?.usage, null); - assert.equal(manager.getSession(sessionId)?.usagePerModel, null); -}); - -test("SessionManager marks skills loaded from existing session messages", async () => { - const workspace = createTempDir("deepcode-loaded-skills-workspace-"); - const home = createTempDir("deepcode-loaded-skills-home-"); - setHomeDir(home); - - const skillDir = path.join(home, ".agents", "skills", "lessweb-starter"); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync( - path.join(skillDir, "SKILL.md"), - "---\nname: lessweb-starter\ndescription: Create Lessweb projects\n---\n# Lessweb Starter\n", - "utf8" - ); - - const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); - const projectDir = path.join(home, ".deepcode", "projects", projectCode); - fs.mkdirSync(projectDir, { recursive: true }); - fs.writeFileSync( - path.join(projectDir, "loaded-session.jsonl"), - `${JSON.stringify({ - id: "skill-message", - sessionId: "loaded-session", - role: "system", - content: "Use the skill document below", - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - meta: { - skill: { - name: "lessweb-starter", - path: "~/.agents/skills/lessweb-starter/SKILL.md", - description: "Create Lessweb projects", - isLoaded: true, - }, - }, - })}\n`, - "utf8" - ); - - const manager = createSessionManager(workspace, "machine-id-loaded-skills"); - const loadedSkill = (await manager.listSkills("loaded-session")).find((skill) => skill.name === "lessweb-starter"); - - assert.equal(loadedSkill?.isLoaded, true); -}); - -test("SessionManager lists project skills from .agents with legacy .deepcode compatibility", async () => { - const workspace = createTempDir("deepcode-project-skills-workspace-"); - const home = createTempDir("deepcode-project-skills-home-"); - setHomeDir(home); - - const userSkillDir = path.join(home, ".agents", "skills", "shared"); - fs.mkdirSync(userSkillDir, { recursive: true }); - fs.writeFileSync( - path.join(userSkillDir, "SKILL.md"), - "---\nname: shared\ndescription: User-level skill\n---\n# Shared\n", - "utf8" - ); - - const legacyProjectSkillDir = path.join(workspace, ".deepcode", "skills", "legacy"); - fs.mkdirSync(legacyProjectSkillDir, { recursive: true }); - fs.writeFileSync( - path.join(legacyProjectSkillDir, "SKILL.md"), - "---\nname: legacy\ndescription: Legacy project skill\n---\n# Legacy\n", - "utf8" - ); - - const projectAgentsSkillDir = path.join(workspace, ".agents", "skills", "shared"); - fs.mkdirSync(projectAgentsSkillDir, { recursive: true }); - fs.writeFileSync( - path.join(projectAgentsSkillDir, "SKILL.md"), - "---\nname: shared\ndescription: Project .agents skill\n---\n# Shared\n", - "utf8" - ); - - const manager = createSessionManager(workspace, "machine-id-project-skills"); - const skills = await manager.listSkills(); - const legacySkill = skills.find((skill) => skill.name === "legacy"); - const sharedSkill = skills.find((skill) => skill.name === "shared"); - - assert.equal(legacySkill?.path, "./.deepcode/skills/legacy/SKILL.md"); - assert.equal(legacySkill?.description, "Legacy project skill"); - assert.equal(sharedSkill?.path, "./.agents/skills/shared/SKILL.md"); - assert.equal(sharedSkill?.description, "Project .agents skill"); -}); - -test("SessionManager dispose disconnects MCP servers", async () => { - const workspace = createTempDir("deepcode-mcp-dispose-workspace-"); - const serverPath = path.join(workspace, "mcp-server.cjs"); - fs.writeFileSync( - serverPath, - ` -const readline = require("readline"); -const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); -function send(message) { - process.stdout.write(JSON.stringify(message) + "\\n"); -} -rl.on("line", (line) => { - const request = JSON.parse(line); - if (!("id" in request)) { - return; - } - if (request.method === "initialize") { - send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); - return; - } - if (request.method === "tools/list") { - if (request.params && request.params.cursor === "page-2") { - send({ jsonrpc: "2.0", id: request.id, result: { tools: [ - { name: "count", inputSchema: { type: "object", properties: {} } } - ] } }); - return; - } - send({ jsonrpc: "2.0", id: request.id, result: { tools: [ - { name: "echo", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } } - ], nextCursor: "page-2" } }); - return; - } - if (request.method === "tools/call") { - send({ jsonrpc: "2.0", id: request.id, result: { content: [{ type: "text", text: request.params.name + ":" + (request.params.arguments.text || "") }] } }); - return; - } - send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); -}); -`, - "utf8" - ); - - const manager = createSessionManager(workspace, "machine-id-mcp-dispose"); - const initPromise = manager.initMcpServers({ smoke: { command: process.execPath, args: [serverPath] } }); - - assert.deepEqual(manager.getMcpStatus(), [ - { - name: "smoke", - status: "starting", - connected: false, - toolCount: 0, - tools: [], - promptCount: 0, - prompts: [], - resourceCount: 0, - resources: [], - }, - ]); - - await initPromise; - - assert.deepEqual(manager.getMcpStatus(), [ - { - name: "smoke", - status: "ready", - connected: true, - toolCount: 2, - tools: ["mcp__smoke__echo", "mcp__smoke__count"], - promptCount: 0, - prompts: [], - resourceCount: 0, - resources: [], - }, - ]); - const mcpManager = (manager as any).mcpManager; - assert.equal(mcpManager.getMcpToolDefinitions()[0].function.name, "mcp__smoke__echo"); - assert.deepEqual(await mcpManager.executeMcpTool("mcp__smoke__echo", { text: "ok" }), { - ok: true, - name: "mcp__smoke__echo", - output: "echo:ok", - }); - - manager.dispose(); - - assert.deepEqual(manager.getMcpStatus(), []); -}); - -test("SessionManager reports configured MCP servers as starting before initialization", () => { - const workspace = createTempDir("deepcode-mcp-configured-workspace-"); - const manager = new SessionManager({ - projectRoot: workspace, - createOpenAIClient: () => ({ - client: null, - model: "test-model", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ - model: "test-model", - mcpServers: { - playwright: { command: "npx", args: ["@playwright/mcp@latest"] }, - }, - }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); - - assert.deepEqual(manager.getMcpStatus(), [ - { - name: "playwright", - status: "starting", - connected: false, - toolCount: 0, - tools: [], - promptCount: 0, - prompts: [], - resourceCount: 0, - resources: [], - }, - ]); -}); - -test("SessionManager reports MCP startup stderr on failure", async () => { - const workspace = createTempDir("deepcode-mcp-failure-workspace-"); - const serverPath = path.join(workspace, "mcp-server-fail.cjs"); - fs.writeFileSync(serverPath, 'process.stderr.write("mcp startup boom"); process.exit(7);', "utf8"); - - const manager = createSessionManager(workspace, "machine-id-mcp-failure"); - await manager.initMcpServers({ broken: { command: process.execPath, args: [serverPath] } }); - - const [status] = manager.getMcpStatus(); - assert.equal(status?.name, "broken"); - assert.equal(status?.status, "failed"); - assert.equal(status?.connected, false); - assert.match(status?.error ?? "", /mcp startup boom/); -}); - -test( - "SessionManager adds -y when launching MCP servers through npx", - { skip: process.platform === "win32" }, - async () => { - const workspace = createTempDir("deepcode-mcp-npx-workspace-"); - const argsPath = path.join(workspace, "args.json"); - const fakeNpxPath = path.join(workspace, "npx"); - fs.writeFileSync( - fakeNpxPath, - `#!/usr/bin/env node -const fs = require("fs"); -const readline = require("readline"); -fs.writeFileSync(process.env.ARGS_PATH, JSON.stringify(process.argv.slice(2))); -const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); -function send(message) { - process.stdout.write(JSON.stringify(message) + "\\n"); -} -rl.on("line", (line) => { - const request = JSON.parse(line); - if (!("id" in request)) { - return; - } - if (request.method === "initialize") { - send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} } } }); - return; - } - if (request.method === "tools/list") { - send({ jsonrpc: "2.0", id: request.id, result: { tools: [] } }); - return; - } - send({ jsonrpc: "2.0", id: request.id, result: { content: [] } }); -}); -`, - "utf8" - ); - fs.chmodSync(fakeNpxPath, 0o755); - - const manager = createSessionManager(workspace, "machine-id-mcp-npx"); - await manager.initMcpServers({ - npxed: { command: fakeNpxPath, args: ["@playwright/mcp@latest"], env: { ARGS_PATH: argsPath } }, - }); - - assert.deepEqual(JSON.parse(fs.readFileSync(argsPath, "utf8")) as string[], ["-y", "@playwright/mcp@latest"]); - manager.dispose(); - } -); - -test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { - const workspace = createTempDir("deepcode-init-deepcode-workspace-"); - const home = createTempDir("deepcode-init-deepcode-home-"); - setHomeDir(home); - globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; - - fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); - fs.writeFileSync(path.join(workspace, ".deepcode", "AGENTS.md"), "deepcode project instructions", "utf8"); - fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); - - const manager = createSessionManager(workspace, "machine-id-init-deepcode"); - (manager as any).activateSession = async () => {}; - - const sessionId = await manager.createSession({ text: "/init" }); - const messages = manager.listSessionMessages(sessionId); - const userMessage = messages.find((message) => message.role === "user"); - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ - role: string; - content: string; - }>; - const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); - const systemContents = messages - .filter((message) => message.role === "system") - .map((message) => message.content ?? ""); - - assert.equal(userMessage?.content, "/init"); - assert.match(openAIUserMessage?.content ?? "", /Update \.\/\.deepcode\/AGENTS\.md/); - assert.doesNotMatch(openAIUserMessage?.content ?? "", /Update \.\/AGENTS\.md/); - assert.ok(systemContents.includes("deepcode project instructions")); - assert.ok(!systemContents.includes("root project instructions")); -}); - -test("createSession appends default system prompts in prefix-cache-friendly order", async () => { - const workspace = createTempDir("deepcode-system-order-workspace-"); - const home = createTempDir("deepcode-system-order-home-"); - setHomeDir(home); - globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; - - fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); - - const manager = createSessionManager(workspace, "machine-id-system-order"); - (manager as any).activateSession = async () => {}; - - const sessionId = await manager.createSession({ text: "hello" }); - const systemContents = manager - .listSessionMessages(sessionId) - .filter((message) => message.role === "system") - .map((message) => message.content ?? ""); - - assert.equal(systemContents.length >= 4, true); - assert.match(systemContents[0] ?? "", /# Available Tools/); - assert.doesNotMatch(systemContents[0] ?? "", /# Local Workspace Environment/); - assert.doesNotMatch(systemContents[0] ?? "", /当前LLM模型为test-model/); - assert.match(systemContents[1] ?? "", //); - assert.match(systemContents[1] ?? "", //); - assert.doesNotMatch(systemContents[1] ?? "", /path="templates\/skills\//); - assert.doesNotMatch(systemContents[1] ?? "", /当前LLM模型为test-model/); - assert.match(systemContents[2] ?? "", /# Local Workspace Environment/); - assert.match(systemContents[2] ?? "", /当前LLM模型为test-model/); - const environmentJsonMatch = (systemContents[2] ?? "").match(/```json\n([\s\S]+?)\n```/); - assert.ok(environmentJsonMatch); - const environmentInfo = JSON.parse(environmentJsonMatch[1] ?? "{}") as { "root path"?: string }; - assert.equal(environmentInfo["root path"], workspace); - assert.equal(systemContents[3], "root project instructions"); -}); - -test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { - const workspace = createTempDir("deepcode-init-root-workspace-"); - const home = createTempDir("deepcode-init-root-home-"); - setHomeDir(home); - globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; - - fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); - - const manager = createSessionManager(workspace, "machine-id-init-root"); - (manager as any).activateSession = async () => {}; - - const sessionId = await manager.createSession({ text: "first prompt" }); - await manager.replySession(sessionId, { text: "/init" }); - const messages = manager.listSessionMessages(sessionId); - const userMessages = messages.filter((message) => message.role === "user"); - const replyMessage = userMessages[userMessages.length - 1]; - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ - role: string; - content: string; - }>; - const openAIUserMessages = openAIMessages.filter((message) => message.role === "user"); - const openAIReplyMessage = openAIUserMessages[openAIUserMessages.length - 1]; - - assert.equal(replyMessage?.content, "/init"); - assert.match(openAIReplyMessage?.content ?? "", /Update \.\/AGENTS\.md/); -}); - -test("createSession stores /init and sends generate prompt when no project AGENTS file is effective", async () => { - const workspace = createTempDir("deepcode-init-generate-workspace-"); - const home = createTempDir("deepcode-init-generate-home-"); - setHomeDir(home); - globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; - - fs.mkdirSync(path.join(home, ".deepcode"), { recursive: true }); - fs.writeFileSync(path.join(home, ".deepcode", "AGENTS.md"), "user instructions", "utf8"); - - const manager = createSessionManager(workspace, "machine-id-init-generate"); - (manager as any).activateSession = async () => {}; - - const sessionId = await manager.createSession({ text: "/init" }); - const messages = manager.listSessionMessages(sessionId); - const userMessage = messages.find((message) => message.role === "user"); - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false, "test-model") as Array<{ - role: string; - content: string; - }>; - const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); - - assert.equal(userMessage?.content, "/init"); - assert.match(openAIUserMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); - assert.doesNotMatch(openAIUserMessage?.content ?? "", /Update \.\/AGENTS\.md/); -}); - -test("createSession reports a new prompt with the machineId token", async () => { - const workspace = createTempDir("deepcode-session-workspace-"); - const home = createTempDir("deepcode-session-home-"); - setHomeDir(home); - - const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; - globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { - fetchCalls.push({ input, init }); - return { - ok: true, - text: async () => "", - } as Response; - }) as typeof fetch; - - const manager = createSessionManager(workspace, "machine-id-123"); - const activatedSessionIds: string[] = []; - (manager as any).activateSession = async (sessionId: string) => { - activatedSessionIds.push(sessionId); - }; - - const sessionId = await manager.createSession({ text: "hello world" }); - await flushPromises(); - - assert.equal(activatedSessionIds.length, 1); - assert.equal(activatedSessionIds[0], sessionId); - assert.equal(fetchCalls.length, 1); - assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/new"); - assert.equal(fetchCalls[0].init?.method, "POST"); - assert.ok(fetchCalls[0].init?.signal instanceof AbortSignal); - assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); - assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-123"); -}); - -test("replySession reports a new prompt with the machineId token", async () => { - const workspace = createTempDir("deepcode-reply-workspace-"); - const home = createTempDir("deepcode-reply-home-"); - setHomeDir(home); - - const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; - globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { - fetchCalls.push({ input, init }); - return { - ok: true, - text: async () => "", - } as Response; - }) as typeof fetch; - - const manager = createSessionManager(workspace, "machine-id-456"); - (manager as any).activateSession = async () => {}; - - const sessionId = await manager.createSession({ text: "first prompt" }); - await flushPromises(); - fetchCalls.length = 0; - - await manager.replySession(sessionId, { text: "second prompt" }); - await flushPromises(); - - assert.equal(fetchCalls.length, 1); - assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/new"); - assert.equal(fetchCalls[0].init?.method, "POST"); - assert.ok(fetchCalls[0].init?.signal instanceof AbortSignal); - assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); - assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-456"); -}); - -test("reporting a new prompt does not warn when the background request fails", async () => { - const workspace = createTempDir("deepcode-report-failure-workspace-"); - const home = createTempDir("deepcode-report-failure-home-"); - setHomeDir(home); - - const warnings: unknown[][] = []; - console.warn = (...args: unknown[]) => { - warnings.push(args); - }; - globalThis.fetch = (async () => { - throw new Error("fetch failed"); - }) as typeof fetch; - - const manager = createSessionManager(workspace, "machine-id-failure"); - (manager as any).activateSession = async () => {}; - - await manager.createSession({ text: "hello world" }); - await flushPromises(); - - assert.deepEqual(warnings, []); -}); - -test("replySession continues without appending /continue as a user message", async () => { - const workspace = createTempDir("deepcode-continue-workspace-"); - const home = createTempDir("deepcode-continue-home-"); - setHomeDir(home); - - const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; - globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { - fetchCalls.push({ input, init }); - return { - ok: true, - text: async () => "", - } as Response; - }) as typeof fetch; - - const manager = createSessionManager(workspace, "machine-id-continue"); - const activatedSessionIds: string[] = []; - (manager as any).activateSession = async (sessionId: string) => { - activatedSessionIds.push(sessionId); - }; - - const sessionId = await manager.createSession({ text: "first prompt" }); - await flushPromises(); - const messagesBefore = manager.listSessionMessages(sessionId); - fetchCalls.length = 0; - activatedSessionIds.length = 0; - - await manager.replySession(sessionId, { text: "/continue" }); - await flushPromises(); - - const messagesAfter = manager.listSessionMessages(sessionId); - const userMessages = messagesAfter.filter((message) => message.role === "user"); - - assert.equal(activatedSessionIds.length, 1); - assert.equal(activatedSessionIds[0], sessionId); - assert.equal(messagesAfter.length, messagesBefore.length); - assert.equal( - userMessages.some((message) => message.content === "/continue"), - false - ); - assert.equal(fetchCalls.length, 0); -}); - -test("replySession /continue runs trailing pending tool calls before requesting another response", async () => { - const workspace = createTempDir("deepcode-continue-tool-workspace-"); - const home = createTempDir("deepcode-continue-tool-home-"); - setHomeDir(home); - - const responses = [ - createChatResponse("continued after tool", { - prompt_tokens: 9, - completion_tokens: 2, - total_tokens: 11, - }), - ]; - const manager = createMockedClientSessionManager(workspace, responses); - const originalActivateSession = manager.activateSession.bind(manager); - (manager as any).activateSession = async () => {}; - - const sessionId = await manager.createSession({ text: "first prompt" }); - const pendingAssistant = (manager as any).buildAssistantMessage( - sessionId, - "Need to read a file", - [ - { - id: "call-pending-read", - type: "function", - function: { name: "read", arguments: JSON.stringify({ file_path: path.join(workspace, "note.txt") }) }, - }, - ], - null - ) as SessionMessage; - fs.writeFileSync(path.join(workspace, "note.txt"), "hello from pending tool\n", "utf8"); - (manager as any).appendSessionMessage(sessionId, pendingAssistant); - (manager as any).activateSession = originalActivateSession; - - await manager.replySession(sessionId, { text: "/continue" }); - - const messages = manager.listSessionMessages(sessionId); - const toolMessage = messages.find((message) => { - const params = message.messageParams as { tool_call_id?: string } | null; - return message.role === "tool" && params?.tool_call_id === "call-pending-read"; - }); - const assistantMessages = messages.filter((message) => message.role === "assistant"); - const userMessages = messages.filter((message) => message.role === "user"); - - assert.ok(toolMessage); - assert.match(toolMessage.content ?? "", /hello from pending tool/); - assert.equal(assistantMessages[assistantMessages.length - 1]?.content, "continued after tool"); - assert.equal( - userMessages.some((message) => message.content === "/continue"), - false - ); -}); - -test("replySession preserves raw session messages when a previous tool call is pending", async () => { - const workspace = createTempDir("deepcode-pending-tool-workspace-"); - const home = createTempDir("deepcode-pending-tool-home-"); - setHomeDir(home); - - globalThis.fetch = (async () => - ({ - ok: true, - text: async () => "", - }) as Response) as typeof fetch; - - const manager = createSessionManager(workspace, "machine-id-pending-tool"); - (manager as any).activateSession = async () => {}; - - const sessionId = await manager.createSession({ text: "first prompt" }); - const assistantMessage = (manager as any).buildAssistantMessage( - sessionId, - "I will run a tool.", - [ - { - id: "call-1", - type: "function", - function: { name: "bash", arguments: '{"command":"sleep 100"}' }, - }, - ], - "" - ) as SessionMessage; - (manager as any).appendSessionMessage(sessionId, assistantMessage); - - await manager.replySession(sessionId, { text: "second prompt" }); - - const messages = manager.listSessionMessages(sessionId); - const assistantIndex = messages.findIndex((message) => message.id === assistantMessage.id); - assert.notEqual(assistantIndex, -1); - assert.equal(messages[assistantIndex + 1]?.role, "user"); - assert.equal(messages[assistantIndex + 1]?.content, "second prompt"); - assert.equal( - messages.some((message) => String(message.content).includes("Previous tool call did not complete.")), - false - ); -}); - -test("buildOpenAIMessages inserts interrupted results for missing tool messages", () => { - const manager = createSessionManager(process.cwd(), "machine-id-missing-tool"); - const assistantMessage = (manager as any).buildAssistantMessage( - "session-1", - "I will run a tool.", - [ - { - id: "call-1", - type: "function", - function: { name: "bash", arguments: '{"command":"sleep 100"}' }, - }, - ], - "" - ) as SessionMessage; - const userMessage = buildTestMessage("user-after-tool-call", "session-1", "user", "continue"); - - const openAIMessages = (manager as any).buildOpenAIMessages( - [assistantMessage, userMessage], - false, - "test-model" - ) as Array<{ - role: string; - content: string; - tool_call_id?: string; - }>; - - assert.equal(openAIMessages.length, 3); - assert.equal(openAIMessages[0]?.role, "assistant"); - assert.equal(openAIMessages[1]?.role, "tool"); - assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); - assert.match(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); - assert.equal(openAIMessages[2]?.role, "user"); -}); - -test("buildOpenAIMessages keeps only the first non-interrupted tool result for a tool call", () => { - const manager = createSessionManager(process.cwd(), "machine-id-duplicate-tool"); - const assistantMessage = (manager as any).buildAssistantMessage( - "session-1", - "", - [ - { - id: "call-1", - type: "function", - function: { name: "bash", arguments: '{"command":"date"}' }, - }, - ], - "" - ) as SessionMessage; - const successToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-1", - JSON.stringify({ ok: true, name: "bash", output: "2026-05-07 星期四\n" }), - { name: "bash", arguments: '{"command":"date"}' } - ) as SessionMessage; - const interruptedToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-1", - JSON.stringify({ - ok: false, - name: "bash", - error: "Previous tool call did not complete.", - metadata: { interrupted: true }, - }), - { name: "bash", arguments: '{"command":"date"}' } - ) as SessionMessage; - - const openAIMessages = (manager as any).buildOpenAIMessages( - [assistantMessage, successToolMessage, interruptedToolMessage], - false, - "test-model" - ) as Array<{ role: string; content: string; tool_call_id?: string }>; - const toolMessages = openAIMessages.filter((message) => message.role === "tool"); - - assert.equal(toolMessages.length, 1); - assert.equal(toolMessages[0]?.tool_call_id, "call-1"); - assert.match(toolMessages[0]?.content ?? "", /2026-05-07/); - assert.doesNotMatch(toolMessages[0]?.content ?? "", /Previous tool call did not complete/); -}); - -test("buildOpenAIMessages prefers a later real tool result over an earlier interrupted placeholder", () => { - const manager = createSessionManager(process.cwd(), "machine-id-prefer-real-tool"); - const assistantMessage = (manager as any).buildAssistantMessage( - "session-1", - "", - [ - { - id: "call-1", - type: "function", - function: { name: "bash", arguments: '{"command":"date"}' }, - }, - ], - "" - ) as SessionMessage; - const interruptedToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-1", - JSON.stringify({ - ok: false, - name: "bash", - error: "Previous tool call did not complete.", - metadata: { interrupted: true }, - }), - { name: "bash", arguments: '{"command":"date"}' } - ) as SessionMessage; - const successToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-1", - JSON.stringify({ ok: true, name: "bash", output: "real result" }), - { name: "bash", arguments: '{"command":"date"}' } - ) as SessionMessage; - - const openAIMessages = (manager as any).buildOpenAIMessages( - [assistantMessage, interruptedToolMessage, successToolMessage], - false, - "test-model" - ) as Array<{ role: string; content: string; tool_call_id?: string }>; - const toolMessages = openAIMessages.filter((message) => message.role === "tool"); - - assert.equal(toolMessages.length, 1); - assert.equal(toolMessages[0]?.tool_call_id, "call-1"); - assert.match(toolMessages[0]?.content ?? "", /real result/); -}); - -test("buildOpenAIMessages ignores orphan tool messages", () => { - const manager = createSessionManager(process.cwd(), "machine-id-orphan-tool"); - const userMessage = buildTestMessage("user-1", "session-1", "user", "hello"); - const orphanToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-orphan", - JSON.stringify({ ok: true, name: "bash", output: "orphan" }), - { name: "bash", arguments: '{"command":"echo orphan"}' } - ) as SessionMessage; - - const openAIMessages = (manager as any).buildOpenAIMessages( - [userMessage, orphanToolMessage], - false, - "test-model" - ) as Array<{ - role: string; - }>; - - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["user"] - ); -}); - -test("buildOpenAIMessages moves a later paired tool message behind its assistant", () => { - const manager = createSessionManager(process.cwd(), "machine-id-later-tool"); - const assistantMessage = (manager as any).buildAssistantMessage( - "session-1", - "", - [ - { - id: "call-1", - type: "function", - function: { name: "bash", arguments: '{"command":"date"}' }, - }, - ], - "" - ) as SessionMessage; - const userMessage = buildTestMessage("user-between", "session-1", "user", "continue"); - const toolMessage = (manager as any).buildToolMessage( - "session-1", - "call-1", - JSON.stringify({ ok: true, name: "bash", output: "paired later" }), - { name: "bash", arguments: '{"command":"date"}' } - ) as SessionMessage; - - const openAIMessages = (manager as any).buildOpenAIMessages( - [assistantMessage, userMessage, toolMessage], - false, - "test-model" - ) as Array<{ role: string; content: string }>; - - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool", "user"] - ); - assert.match(openAIMessages[1]?.content ?? "", /paired later/); -}); - -test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { - const manager = createSessionManager(process.cwd(), "machine-id-multi-tool-happy"); - const assistantMessage = (manager as any).buildAssistantMessage( - "session-1", - "", - [ - { - id: "call-1", - type: "function", - function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' }, - }, - { - id: "call-2", - type: "function", - function: { name: "bash", arguments: '{"command":"pwd"}' }, - }, - ], - "" - ) as SessionMessage; - const firstToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-1", - JSON.stringify({ ok: true, name: "read", content: "file content" }), - { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } - ) as SessionMessage; - const secondToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-2", - JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), - { name: "bash", arguments: '{"command":"pwd"}' } - ) as SessionMessage; - const userMessage = buildTestMessage("user-after-complete-tools", "session-1", "user", "thanks"); - - const openAIMessages = (manager as any).buildOpenAIMessages( - [assistantMessage, firstToolMessage, secondToolMessage, userMessage], - false, - "test-model" - ) as Array<{ role: string; content: string; tool_call_id?: string }>; - - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool", "tool", "user"] - ); - assert.deepEqual( - openAIMessages.filter((message) => message.role === "tool").map((message) => message.tool_call_id), - ["call-1", "call-2"] - ); - assert.equal( - openAIMessages.some((message) => message.content.includes("Previous tool call did not complete.")), - false - ); -}); - -test("buildOpenAIMessages preserves a real failed tool result", () => { - const manager = createSessionManager(process.cwd(), "machine-id-real-failed-tool"); - const assistantMessage = (manager as any).buildAssistantMessage( - "session-1", - "", - [ - { - id: "call-1", - type: "function", - function: { name: "bash", arguments: '{"command":"false"}' }, - }, - ], - "" - ) as SessionMessage; - const failedToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-1", - JSON.stringify({ ok: false, name: "bash", error: "Command failed", metadata: { exitCode: 1 } }), - { name: "bash", arguments: '{"command":"false"}' } - ) as SessionMessage; - - const openAIMessages = (manager as any).buildOpenAIMessages( - [assistantMessage, failedToolMessage], - false, - "test-model" - ) as Array<{ - role: string; - content: string; - tool_call_id?: string; - }>; - - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool"] - ); - assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); - assert.match(openAIMessages[1]?.content ?? "", /Command failed/); - assert.doesNotMatch(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); -}); - -test("UpdatePlan tool params only show explanation when provided", () => { - const manager = createSessionManager(process.cwd(), "machine-id-update-plan-params"); - const plan = "## Task List\n\n- [ ] Inspect project"; - - const withExplanation = (manager as any).buildToolMessage( - "session-1", - "call-plan-1", - JSON.stringify({ ok: true, name: "UpdatePlan", output: "Plan updated." }), - { name: "UpdatePlan", arguments: JSON.stringify({ plan, explanation: "Start planning" }) } - ) as SessionMessage; - const withoutExplanation = (manager as any).buildToolMessage( - "session-1", - "call-plan-2", - JSON.stringify({ ok: true, name: "UpdatePlan", output: "Plan updated." }), - { name: "UpdatePlan", arguments: JSON.stringify({ plan }) } - ) as SessionMessage; - - assert.equal(withExplanation.meta?.paramsMd, "Start planning"); - assert.equal(withoutExplanation.meta?.paramsMd, ""); -}); - -test("Write tool params prefer file_path even when content appears first", () => { - const manager = createSessionManager(process.cwd(), "machine-id-write-params"); - const filePath = path.join(process.cwd(), "index.html"); - - const toolMessage = (manager as any).buildToolMessage( - "session-1", - "call-write-1", - JSON.stringify({ ok: true, name: "write", output: "Created file." }), - { - name: "write", - arguments: JSON.stringify({ - content: "// === entry ===\nconsole.log('demo');\n", - file_path: filePath, - }), - } - ) as SessionMessage; - - assert.equal(toolMessage.meta?.paramsMd, filePath); -}); - -test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messages", () => { - const manager = createSessionManager(process.cwd(), "machine-id-mixed-tool-badcase"); - const assistantMessage = (manager as any).buildAssistantMessage( - "session-1", - "", - [ - { - id: "call-1", - type: "function", - function: { name: "read", arguments: '{"file_path":"/tmp/missing.txt"}' }, - }, - { - id: "call-2", - type: "function", - function: { name: "bash", arguments: '{"command":"pwd"}' }, - }, - ], - "" - ) as SessionMessage; - const orphanToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-orphan", - JSON.stringify({ ok: true, name: "bash", output: "orphan" }), - { name: "bash", arguments: '{"command":"echo orphan"}' } - ) as SessionMessage; - const pairedToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-2", - JSON.stringify({ ok: true, name: "bash", output: "/tmp\n" }), - { name: "bash", arguments: '{"command":"pwd"}' } - ) as SessionMessage; - const duplicateToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-2", - JSON.stringify({ ok: true, name: "bash", output: "duplicate" }), - { name: "bash", arguments: '{"command":"pwd"}' } - ) as SessionMessage; - const userMessage = buildTestMessage("user-after-mixed-tools", "session-1", "user", "continue"); - - const openAIMessages = (manager as any).buildOpenAIMessages( - [assistantMessage, orphanToolMessage, pairedToolMessage, duplicateToolMessage, userMessage], - false, - "test-model" - ) as Array<{ role: string; content: string; tool_call_id?: string }>; - const toolMessages = openAIMessages.filter((message) => message.role === "tool"); - - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool", "tool", "user"] - ); - assert.deepEqual( - toolMessages.map((message) => message.tool_call_id), - ["call-1", "call-2"] - ); - assert.match(toolMessages[0]?.content ?? "", /Previous tool call did not complete/); - assert.match(toolMessages[1]?.content ?? "", /\/tmp/); - assert.equal( - openAIMessages.some((message) => message.content.includes("orphan")), - false - ); - assert.equal( - openAIMessages.some((message) => message.content.includes("duplicate")), - false - ); -}); - -test("buildOpenAIMessages ignores tool messages that appear before their assistant", () => { - const manager = createSessionManager(process.cwd(), "machine-id-tool-before-assistant"); - const earlyToolMessage = (manager as any).buildToolMessage( - "session-1", - "call-1", - JSON.stringify({ ok: true, name: "bash", output: "too early" }), - { name: "bash", arguments: '{"command":"date"}' } - ) as SessionMessage; - const assistantMessage = (manager as any).buildAssistantMessage( - "session-1", - "", - [ - { - id: "call-1", - type: "function", - function: { name: "bash", arguments: '{"command":"date"}' }, - }, - ], - "" - ) as SessionMessage; - - const openAIMessages = (manager as any).buildOpenAIMessages( - [earlyToolMessage, assistantMessage], - false, - "test-model" - ) as Array<{ - role: string; - content: string; - tool_call_id?: string; - }>; - - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool"] - ); - assert.equal(openAIMessages[1]?.tool_call_id, "call-1"); - assert.match(openAIMessages[1]?.content ?? "", /Previous tool call did not complete/); - assert.doesNotMatch(openAIMessages[1]?.content ?? "", /too early/); -}); - -test("SessionManager accumulates response usage while active tokens track the latest response", async () => { - const workspace = createTempDir("deepcode-usage-workspace-"); - const home = createTempDir("deepcode-usage-home-"); - setHomeDir(home); - - const responses = [ - createChatResponse("first", { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - prompt_tokens_details: { cached_tokens: 7 }, - completion_tokens_details: { reasoning_tokens: 3 }, - prompt_cache_hit_tokens: 7, - prompt_cache_miss_tokens: 3, - }), - createChatResponse("second", { - prompt_tokens: 20, - completion_tokens: 7, - total_tokens: 27, - prompt_tokens_details: { cached_tokens: 11 }, - completion_tokens_details: { reasoning_tokens: 4 }, - prompt_cache_hit_tokens: 11, - prompt_cache_miss_tokens: 9, - }), - ]; - const manager = createMockedClientSessionManager(workspace, responses); - - const sessionId = await manager.createSession({ text: "" }); - await manager.replySession(sessionId, { text: "" }); - - const session = manager.getSession(sessionId); - const usage = session?.usage as Record; - const usagePerModel = session?.usagePerModel?.["test-model"] as Record; - assert.equal(session?.activeTokens, 27); - assert.equal(usage.prompt_tokens, 30); - assert.equal(usage.completion_tokens, 12); - assert.equal(usage.total_tokens, 42); - assert.equal(usage.prompt_tokens_details.cached_tokens, 18); - assert.equal(usage.completion_tokens_details.reasoning_tokens, 7); - assert.equal(usage.prompt_cache_hit_tokens, 18); - assert.equal(usage.prompt_cache_miss_tokens, 12); - assert.equal(usagePerModel.prompt_tokens, 30); - assert.equal(usagePerModel.completion_tokens, 12); - assert.equal(usagePerModel.total_tokens, 42); - assert.equal(usagePerModel.prompt_tokens_details.cached_tokens, 18); - assert.equal(usagePerModel.completion_tokens_details.reasoning_tokens, 7); - assert.equal(usagePerModel.prompt_cache_hit_tokens, 18); - assert.equal(usagePerModel.prompt_cache_miss_tokens, 12); - assert.equal(usagePerModel.total_reqs, 2); -}); - -test("SessionManager stores usage per model across model changes", async () => { - const workspace = createTempDir("deepcode-usage-per-model-workspace-"); - const home = createTempDir("deepcode-usage-per-model-home-"); - setHomeDir(home); - - let currentModel = "deepseek-v4-pro"; - const responses = [ - createChatResponse("pro response", { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }), - createChatResponse("flash response", { - prompt_tokens: 20, - completion_tokens: 7, - total_tokens: 27, - prompt_cache_hit_tokens: 6, - }), - ]; - const client = { - chat: { - completions: { - create: async () => { - const response = responses.shift(); - assert.ok(response, "expected a queued chat response"); - return response; - }, - }, - }, - }; - const manager = new SessionManager({ - projectRoot: workspace, - createOpenAIClient: () => ({ - client: client as any, - model: currentModel, - baseURL: "https://api.deepseek.com", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: currentModel }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); - - const sessionId = await manager.createSession({ text: "" }); - currentModel = "deepseek-v4-flash"; - await manager.replySession(sessionId, { text: "" }); - - const session = manager.getSession(sessionId); - assert.deepEqual(Object.keys(session?.usagePerModel ?? {}).sort(), ["deepseek-v4-flash", "deepseek-v4-pro"]); - assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.prompt_tokens, 10); - assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.completion_tokens, 5); - assert.equal(session?.usagePerModel?.["deepseek-v4-pro"]?.total_reqs, 1); - assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_tokens, 20); - assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.completion_tokens, 7); - assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.prompt_cache_hit_tokens, 6); - assert.equal(session?.usagePerModel?.["deepseek-v4-flash"]?.total_reqs, 1); - assert.equal(session?.usage?.prompt_tokens, 30); - assert.equal(session?.usage?.completion_tokens, 12); - assert.equal(session?.usage?.total_tokens, 42); -}); - -test("SessionManager resets active tokens to latest post-compaction response usage", async () => { - const workspace = createTempDir("deepcode-compact-usage-workspace-"); - const home = createTempDir("deepcode-compact-usage-home-"); - setHomeDir(home); - - const responses = [ - createChatResponse("large", { - prompt_tokens: 139_990, - completion_tokens: 10, - total_tokens: 140_000, - }), - createChatResponse("summary", { - prompt_tokens: 100, - completion_tokens: 23, - total_tokens: 123, - }), - createChatResponse("after compact", { - prompt_tokens: 5, - completion_tokens: 2, - total_tokens: 7, - }), - ]; - const manager = createMockedClientSessionManager(workspace, responses); - - const sessionId = await manager.createSession({ text: "" }); - assert.equal(manager.getSession(sessionId)?.activeTokens, 140_000); - - await manager.replySession(sessionId, { text: "" }); - - const session = manager.getSession(sessionId); - const usage = session?.usage as Record; - const usagePerModel = session?.usagePerModel?.["test-model"] as Record; - assert.equal(session?.activeTokens, 7); - assert.equal(usage.prompt_tokens, 140_095); - assert.equal(usage.completion_tokens, 35); - assert.equal(usage.total_tokens, 140_130); - assert.equal(usagePerModel.prompt_tokens, 140_095); - assert.equal(usagePerModel.completion_tokens, 35); - assert.equal(usagePerModel.total_tokens, 140_130); - assert.equal(usagePerModel.total_reqs, 3); -}); - -test("SessionManager streams chat completions and counts reasoning progress", async () => { - const workspace = createTempDir("deepcode-stream-workspace-"); - const home = createTempDir("deepcode-stream-home-"); - setHomeDir(home); - - const progressEvents: Array<{ - phase: string; - estimatedTokens: number; - formattedTokens: string; - }> = []; - const client = { - chat: { - completions: { - create: async (request: Record) => { - assert.equal(request.stream, true); - assert.deepEqual(request.stream_options, { include_usage: true }); - return createChatStreamResponse([ - { choices: [{ delta: { reasoning_content: "思考" } }] }, - { choices: [{ delta: { content: "hello" } }] }, - { - choices: [], - usage: { - prompt_tokens: 2, - completion_tokens: 3, - total_tokens: 5, - }, - }, - ]); - }, - }, - }, - }; - - const manager = new SessionManager({ - projectRoot: workspace, - createOpenAIClient: () => ({ - client: client as any, - model: "test-model", - baseURL: "https://api.deepseek.com", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - onLlmStreamProgress: (progress) => { - progressEvents.push({ - phase: progress.phase, - estimatedTokens: progress.estimatedTokens, - formattedTokens: progress.formattedTokens, - }); - }, - }); - - const sessionId = await manager.createSession({ text: "" }); - const assistantMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "assistant"); - - assert.equal(assistantMessage?.content, "hello"); - assert.equal((assistantMessage?.messageParams as any)?.reasoning_content, "思考"); - assert.equal(manager.getSession(sessionId)?.activeTokens, 5); - assert.deepEqual( - progressEvents.map((event) => event.phase), - ["start", "update", "update", "end"] - ); - assert.equal(progressEvents[1]?.estimatedTokens, 1); - assert.equal(progressEvents[2]?.formattedTokens, "3"); -}); - -test("SessionManager cancels skill matching before a session is created", async () => { - const workspace = createTempDir("deepcode-skill-abort-workspace-"); - const home = createTempDir("deepcode-skill-abort-home-"); - setHomeDir(home); - - const skillDir = path.join(home, ".agents", "skills", "demo"); - fs.mkdirSync(skillDir, { recursive: true }); - fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: demo\ndescription: Demo skill\n---\n# Demo\n", "utf8"); - - // eslint-disable-next-line prefer-const -- must be declared before client which references it - let manager: SessionManager; - const client = { - chat: { - completions: { - create: async (_request: Record, options?: { signal?: AbortSignal }) => { - return new Promise((_resolve, reject) => { - const signal = options?.signal; - signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); - queueMicrotask(() => manager.interruptActiveSession()); - }); - }, - }, - }, - }; - - manager = createMockedClientSessionManagerWithClient(workspace, client); - - await manager.handleUserPrompt({ text: "please use demo" }); - - assert.equal(manager.listSessions().length, 0); -}); - -test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () => { - const workspace = createTempDir("deepcode-api-abort-workspace-"); - const home = createTempDir("deepcode-api-abort-home-"); - setHomeDir(home); - - let manager: SessionManager; - const client = { - chat: { - completions: { - create: async (_request: Record, options?: { signal?: AbortSignal }) => { - return new Promise((_resolve, reject) => { - const signal = options?.signal; - signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); - }); - }, - }, - }, - }; - - // eslint-disable-next-line prefer-const -- declared before client, assigned after - manager = new SessionManager({ - projectRoot: workspace, - createOpenAIClient: () => ({ - client: client as any, - model: "test-model", - baseURL: "https://api.deepseek.com", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - onSessionEntryUpdated: (entry) => { - if (entry.status === "processing") { - queueMicrotask(() => manager.interruptActiveSession()); - } - }, - }); - - await manager.handleUserPrompt({ text: "" }); - - const activeSessionId = manager.getActiveSessionId(); - assert.ok(activeSessionId); - const session = manager.getSession(activeSessionId); - assert.equal(session?.status, "interrupted"); - assert.equal(session?.failReason, "interrupted"); -}); - -function createSessionManager(projectRoot: string, machineId: string): SessionManager { - return new SessionManager({ - projectRoot, - createOpenAIClient: () => ({ - client: null, - model: "test-model", - baseURL: "https://api.deepseek.com", - thinkingEnabled: false, - machineId, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); -} - -function createMockedClientSessionManager(projectRoot: string, responses: unknown[]): SessionManager { - const client = { - chat: { - completions: { - create: async () => { - const response = responses.shift(); - assert.ok(response, "expected a queued chat response"); - return response; - }, - }, - }, - }; - - return new SessionManager({ - projectRoot, - createOpenAIClient: () => ({ - client: client as any, - model: "test-model", - baseURL: "https://api.deepseek.com", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); -} - -function createMockedClientSessionManagerWithClient(projectRoot: string, client: unknown): SessionManager { - return new SessionManager({ - projectRoot, - createOpenAIClient: () => ({ - client: client as any, - model: "test-model", - baseURL: "https://api.deepseek.com", - thinkingEnabled: false, - }), - getResolvedSettings: () => ({ model: "test-model" }), - renderMarkdown: (text) => text, - onAssistantMessage: () => {}, - }); -} - -class APIUserAbortError extends Error {} - -function createChatResponse(content: string, usage: Record): unknown { - return { - choices: [{ message: { content } }], - usage, - }; -} - -function buildTestMessage( - id: string, - sessionId: string, - role: SessionMessage["role"], - content: string -): SessionMessage { - return { - id, - sessionId, - role, - content, - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }; -} - -async function* createChatStreamResponse(chunks: Record[]): AsyncGenerator> { - for (const chunk of chunks) { - yield chunk; - } -} - -function createTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -async function flushPromises(): Promise { - await new Promise((resolve) => setImmediate(resolve)); -} diff --git a/src/tests/sessionList.test.ts b/src/tests/sessionList.test.ts deleted file mode 100644 index e3bf51ce..00000000 --- a/src/tests/sessionList.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { formatSessionTitle } from "../ui"; - -test("formatSessionTitle replaces newlines with spaces", () => { - assert.equal(formatSessionTitle("first line\nsecond line\r\nthird"), "first line second line third"); -}); - -test("formatSessionTitle truncates after normalizing whitespace", () => { - assert.equal(formatSessionTitle("one\n two three", 10), "one two th…"); -}); diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts deleted file mode 100644 index 071da530..00000000 --- a/src/tools/bash-handler.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { spawn } from "child_process"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { - buildDisableExtglobCommand, - buildShellEnv, - buildShellInitCommand, - resolveShellPath, - rewriteWindowsNullRedirect, - toNativeCwd, -} from "../common/shell-utils"; - -const MAX_OUTPUT_CHARS = 30000; -const MAX_CAPTURE_CHARS = 10 * 1024 * 1024; -const sessionWorkingDirs = new Map(); - -type ToolCommandResult = { - ok: boolean; - output: string; - cwd: string | null; - exitCode: number | null; - signal: string | null; - truncated: boolean; - shellPath?: string; - startCwd?: string; -}; - -export async function handleBashTool( - args: Record, - context: ToolExecutionContext -): Promise { - const command = typeof args.command === "string" ? args.command : ""; - if (!command.trim()) { - return { - ok: false, - name: "bash", - error: 'Missing required "command" string.', - }; - } - - const startCwd = getSessionCwd(context.sessionId, context.projectRoot); - const { shellPath, shellArgs, marker } = buildShellCommand(command); - - const execution = await executeShellCommand(shellPath, shellArgs, startCwd, command, context); - const result = buildToolCommandResult( - execution.stdout, - execution.stderr, - marker, - execution.exitCode, - execution.signal, - shellPath, - startCwd - ); - updateSessionCwd(context.sessionId, startCwd, result.cwd); - - if (execution.error || result.exitCode !== 0 || result.signal !== null) { - const errorMessage = buildErrorMessage(result.exitCode, result.signal, execution.error); - return formatResult({ ...result, ok: false }, "bash", errorMessage); - } - - return formatResult(result, "bash"); -} - -function getSessionCwd(sessionId: string, fallback: string): string { - return sessionWorkingDirs.get(sessionId) ?? fallback; -} - -function updateSessionCwd(sessionId: string, fallback: string, cwd: string | null): void { - const nextCwd = cwd ?? fallback; - sessionWorkingDirs.set(sessionId, nextCwd); -} - -function buildShellCommand(command: string): { - shellPath: string; - shellArgs: string[]; - marker: string; -} { - const shellPath = resolveShellPath(); - const marker = buildMarker(); - const initCommand = buildShellInitCommand(shellPath); - const disableExtglobCommand = buildDisableExtglobCommand(shellPath); - const normalizedCommand = rewriteWindowsNullRedirect(command); - const wrappedParts = []; - if (initCommand) { - wrappedParts.push(initCommand); - } - if (disableExtglobCommand) { - wrappedParts.push(disableExtglobCommand); - } - wrappedParts.push( - normalizedCommand, - "__DEEPCODE_STATUS__=$?", - `printf '%s%s\\n' "${marker}" "$PWD"`, - "exit $__DEEPCODE_STATUS__" - ); - const wrappedCommand = `{ ${wrappedParts.join("; ")}; } < /dev/null`; - return { shellPath, shellArgs: ["-c", wrappedCommand], marker }; -} - -async function executeShellCommand( - shellPath: string, - shellArgs: string[], - cwd: string, - command: string, - context: ToolExecutionContext -): Promise<{ stdout: string; stderr: string; exitCode: number | null; signal: string | null; error?: string }> { - return new Promise((resolve) => { - const detached = process.platform !== "win32"; - const configuredEnv = context.createOpenAIClient?.().env ?? {}; - const child = spawn(shellPath, shellArgs, { - cwd, - env: buildShellEnv(shellPath, configuredEnv), - detached, - windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], - }); - const pid = child.pid; - if (typeof pid === "number") { - context.onProcessStart?.(pid, command); - } - - let stdout = ""; - let stderr = ""; - let error: string | undefined; - - child.stdout?.on("data", (chunk: string | Buffer) => { - stdout = appendChunk(stdout, chunk); - const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); - context.onProcessStdout?.(pid as number, text); - }); - child.stderr?.on("data", (chunk: string | Buffer) => { - stderr = appendChunk(stderr, chunk); - const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); - context.onProcessStdout?.(pid as number, text); - }); - - child.on("error", (spawnError) => { - error = spawnError.message; - }); - - child.on("close", (code, signal) => { - if (typeof pid === "number") { - context.onProcessExit?.(pid); - } - resolve({ - stdout, - stderr, - exitCode: typeof code === "number" ? code : null, - signal: signal ?? null, - error, - }); - }); - }); -} - -function appendChunk(existing: string, chunk: string | Buffer): string { - if (existing.length >= MAX_CAPTURE_CHARS) { - return existing; - } - const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); - const remaining = MAX_CAPTURE_CHARS - existing.length; - return `${existing}${text.slice(0, remaining)}`; -} - -function buildMarker(): string { - const token = Math.random().toString(36).slice(2); - return `__DEEPCODE_PWD__${token}__`; -} - -function buildToolCommandResult( - stdout: string, - stderr: string, - marker: string, - exitCode: number | null, - signal: string | null, - shellPath: string, - startCwd: string -): ToolCommandResult { - const { output: cleanedStdout, cwd } = stripMarker(stdout, marker); - const combined = joinOutput(cleanedStdout, stderr); - const { text, truncated } = truncateOutput(combined); - return { - ok: exitCode === 0 && signal === null, - output: text, - cwd, - exitCode, - signal, - truncated, - shellPath, - startCwd, - }; -} - -function stripMarker(stdout: string, marker: string): { output: string; cwd: string | null } { - if (!stdout) { - return { output: "", cwd: null }; - } - - const lines = stdout.split(/\r?\n/); - let markerIndex = -1; - for (let i = lines.length - 1; i >= 0; i -= 1) { - if (lines[i].startsWith(marker)) { - markerIndex = i; - break; - } - } - - if (markerIndex === -1) { - return { output: stdout, cwd: null }; - } - - const markerLine = lines[markerIndex]; - const shellCwd = markerLine.slice(marker.length).trim(); - const cwd = shellCwd ? toNativeCwd(shellCwd) : null; - lines.splice(markerIndex, 1); - return { output: lines.join("\n"), cwd }; -} - -function joinOutput(stdout: string, stderr: string): string { - const trimmedStdout = stdout ?? ""; - const trimmedStderr = stderr ?? ""; - if (trimmedStdout && trimmedStderr) { - return `${trimmedStdout}\n${trimmedStderr}`; - } - return trimmedStdout || trimmedStderr; -} - -function truncateOutput(output: string): { text: string; truncated: boolean } { - if (output.length <= MAX_OUTPUT_CHARS) { - return { text: output, truncated: false }; - } - return { text: output.slice(0, MAX_OUTPUT_CHARS), truncated: true }; -} - -function buildErrorMessage(exitCode: number | null, signal: string | null, error?: string): string { - if (error) { - return error; - } - if (signal) { - return `Command terminated by signal ${signal}.`; - } - if (exitCode !== null) { - return `Command failed with exit code ${exitCode}.`; - } - return "Command failed."; -} - -function formatResult(result: ToolCommandResult, name: string, errorMessage?: string): ToolExecutionResult { - const metadata: Record = { - exitCode: result.exitCode, - signal: result.signal, - cwd: result.cwd, - truncated: result.truncated, - shellPath: result.shellPath, - startCwd: result.startCwd, - }; - - const outputValue = result.output ? result.output : undefined; - - return { - ok: result.ok, - name, - output: outputValue, - error: errorMessage, - metadata, - }; -} diff --git a/src/ui/App.tsx b/src/ui/App.tsx deleted file mode 100644 index 8d8dca1f..00000000 --- a/src/ui/App.tsx +++ /dev/null @@ -1,730 +0,0 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { Box, Static, Text, useApp, useStdout, useWindowSize } from "ink"; -import chalk from "chalk"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import OpenAI from "openai"; -import { - SessionManager, - type LlmStreamProgress, - type MessageMeta, - type SessionEntry, - type SessionMessage, - type SessionStatus, - type SkillInfo, - type UserPromptContent, -} from "../session"; -import { - applyModelConfigSelection, - resolveSettingsSources, - type DeepcodingSettings, - type ModelConfigSelection, - type ResolvedDeepcodingSettings, -} from "../settings"; -import { PromptInput, type PromptSubmission } from "./PromptInput"; -import { MessageView } from "./MessageView"; -import { SessionList } from "./SessionList"; -import { buildLoadingText } from "./loadingText"; -import { findExpandedThinkingId } from "./thinkingState"; -import { WelcomeScreen } from "./WelcomeScreen"; -import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; -import { McpStatusList } from "./McpStatusList"; -import { ProcessStdoutView } from "./ProcessStdoutView"; -import { - findPendingAskUserQuestion, - formatAskUserQuestionAnswers, - type AskUserQuestionAnswers, -} from "./askUserQuestion"; -import { buildExitSummaryText } from "./exitSummary"; - -const DEFAULT_MODEL = "deepseek-v4-pro"; -const DEFAULT_BASE_URL = "https://api.deepseek.com"; - -type View = "chat" | "session-list" | "mcp-status"; - -type AppProps = { - projectRoot: string; - version?: string; - initialPrompt?: string; - onRestart?: () => void; -}; - -export function App({ projectRoot, version = "", initialPrompt, onRestart }: AppProps): React.ReactElement { - const { exit } = useApp(); - const { stdout, write } = useStdout(); - const { columns } = useWindowSize(); - const initialPromptSubmittedRef = useRef(false); - const [view, setView] = useState("chat"); - const [busy, setBusy] = useState(false); - const [skills, setSkills] = useState([]); - const [messages, setMessages] = useState([]); - const [sessions, setSessions] = useState([]); - const [statusLine, setStatusLine] = useState(""); - const [errorLine, setErrorLine] = useState(null); - const [streamProgress, setStreamProgress] = useState(null); - const [runningProcesses, setRunningProcesses] = useState(null); - const [activeStatus, setActiveStatus] = useState(null); - const [dismissedQuestionIds, setDismissedQuestionIds] = useState>(() => new Set()); - const [isExiting, setIsExiting] = useState(false); - const [showWelcome, setShowWelcome] = useState(true); - const [welcomeNonce, setWelcomeNonce] = useState(0); - const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); - const [nowTick, setNowTick] = useState(0); - const [mcpStatuses, setMcpStatuses] = useState>([]); - const [showProcessStdout, setShowProcessStdout] = useState(false); - const processStdoutRef = useRef>(new Map()); - - const messagesRef = useRef([]); - messagesRef.current = messages; - - const sessionManager = useMemo(() => { - return new SessionManager({ - projectRoot, - createOpenAIClient: () => createOpenAIClient(projectRoot), - getResolvedSettings: () => resolveCurrentSettings(projectRoot), - renderMarkdown: (text) => text, - onAssistantMessage: (message: SessionMessage) => { - setMessages((prev) => [...prev, message]); - }, - onSessionEntryUpdated: (entry) => { - setStatusLine(buildStatusLine(entry)); - setRunningProcesses(entry.processes); - setActiveStatus(entry.status); - }, - onLlmStreamProgress: (progress) => { - if (progress.phase === "end") { - setStreamProgress(null); - return; - } - setStreamProgress(progress); - }, - onMcpStatusChanged: () => { - // 当 MCP 状态变更时,如果当前正在查看 MCP 状态页面,则更新显示 - setMcpStatuses(sessionManager.getMcpStatus()); - }, - onProcessStdout: (pid, chunk) => { - const buf = processStdoutRef.current; - const current = buf.get(pid) ?? ""; - // Cap at 1 MB per process to avoid unbounded memory growth - // on noisy or long-running commands like `yes` or verbose builds. - const MAX_STDOUT_BUFFER = 1_000_000; - if (current.length >= MAX_STDOUT_BUFFER) { - return; - } - const text = typeof chunk === "string" ? chunk : String(chunk); - const available = MAX_STDOUT_BUFFER - current.length; - buf.set(pid, current + text.slice(0, available)); - }, - }); - }, [projectRoot]); - - useEffect(() => { - if (!busy) { - return; - } - const id = setInterval(() => setNowTick((tick) => tick + 1), 500); - return () => clearInterval(id); - }, [busy]); - - function loadVisibleMessages(manager: SessionManager, sessionId: string): SessionMessage[] { - return manager.listSessionMessages(sessionId).filter((m) => m.visible); - } - - const refreshSessionsList = useCallback((): void => { - setSessions(sessionManager.listSessions()); - }, [sessionManager]); - - const refreshSkills = useCallback( - async (sessionId?: string): Promise => { - try { - const list = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); - setSkills(list); - } catch { - // ignore - } - }, - [sessionManager] - ); - - useEffect(() => { - refreshSessionsList(); - void refreshSkills(); - }, [refreshSessionsList, refreshSkills]); - - useLayoutEffect(() => { - const settings = resolveCurrentSettings(projectRoot); - void sessionManager.initMcpServers(settings.mcpServers); - }, [projectRoot, sessionManager]); - - useEffect(() => { - return () => { - sessionManager.dispose(); - }; - }, [sessionManager]); - - const writeRef = useRef(write); - writeRef.current = write; - const handlePrompt = useCallback( - async (submission: PromptSubmission) => { - if (submission.command === "exit") { - setIsExiting(true); - setTimeout(() => { - const activeSessionId = sessionManager.getActiveSessionId(); - const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; - const summary = buildExitSummaryText({ session }); - process.stdout.write("\n"); - process.stdout.write(chalk.rgb(34, 154, 195)("> /exit ")); - process.stdout.write("\n\n"); - process.stdout.write(summary); - process.stdout.write("\n\n"); - sessionManager.dispose(); - exit(); - }, 0); - return; - } - if (submission.command === "new") { - if (onRestart) { - onRestart(); - } else { - writeRef.current("\u001B[2J\u001B[3J\u001B[H"); - sessionManager.setActiveSessionId(null); - setMessages([]); - setStatusLine(""); - setErrorLine(null); - setRunningProcesses(null); - setActiveStatus(null); - setDismissedQuestionIds(new Set()); - setShowWelcome(true); - setWelcomeNonce((n) => n + 1); - await refreshSkills(); - refreshSessionsList(); - } - return; - } - if (submission.command === "resume") { - setShowWelcome(false); - refreshSessionsList(); - setView("session-list"); - return; - } - if (submission.command === "continue" && isCurrentSessionEmpty(sessionManager)) { - setShowWelcome(false); - refreshSessionsList(); - setView("session-list"); - return; - } - if (submission.command === "mcp") { - setShowWelcome(false); - setMcpStatuses(sessionManager.getMcpStatus()); - setView("mcp-status"); - return; - } - - const prompt: UserPromptContent = { - text: submission.text, - imageUrls: submission.imageUrls, - skills: - submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, - }; - - const trimmedText = (submission.text ?? "").trim(); - const selectedSkillNames = submission.selectedSkills?.map((skill) => skill.name).filter(Boolean) ?? []; - const userDisplayContent = - trimmedText || - (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") || - (submission.imageUrls.length > 0 ? "[Image]" : ""); - - if (userDisplayContent && submission.command !== "continue") { - setMessages((prev) => [...prev, buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length)]); - } - - setBusy(true); - setErrorLine(null); - setRunningProcesses(null); - setShowProcessStdout(false); - processStdoutRef.current.clear(); - try { - await sessionManager.handleUserPrompt(prompt); - await refreshSkills(); - refreshSessionsList(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setErrorLine(message); - } finally { - setBusy(false); - setStreamProgress(null); - setRunningProcesses(null); - } - }, - [exit, onRestart, sessionManager, refreshSkills, refreshSessionsList] - ); - - const handleInterrupt = useCallback(() => { - sessionManager.interruptActiveSession(); - }, [sessionManager]); - - const handleToggleProcessStdout = useCallback(() => { - setShowProcessStdout(true); - }, []); - - const handleDismissProcessStdout = useCallback(() => { - setShowProcessStdout(false); - }, []); - - const handleModelConfigChange = useCallback( - (selection: ModelConfigSelection): string => { - const current = resolveCurrentSettings(projectRoot); - const { changed } = writeModelConfigSelection(selection, current, projectRoot); - const next = resolveCurrentSettings(projectRoot); - setResolvedSettings(next); - - if (!changed) { - return "Model settings unchanged"; - } - - const activeSessionId = sessionManager.getActiveSessionId(); - const meta: MessageMeta = { - isModelChange: true, - }; - const content = `/model\n└ Set model to ${selection.model} (${selection?.thinkingEnabled ? selection?.reasoningEffort : "no thinking"})`; - - if (activeSessionId) { - sessionManager.addSessionSystemMessage(activeSessionId, content, true, meta); - } else { - const now = new Date().toISOString(); - setMessages((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - sessionId: "local", - role: "system" as const, - content, - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: now, - updateTime: now, - meta, - }, - ]); - } - - return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; - }, - [projectRoot, sessionManager] - ); - - const handleSubmit = useCallback( - (submission: PromptSubmission) => { - void handlePrompt(submission); - }, - [handlePrompt] - ); - - useEffect(() => { - if (initialPromptSubmittedRef.current || !initialPrompt || !initialPrompt.trim()) { - return; - } - - initialPromptSubmittedRef.current = true; - handleSubmit({ - text: initialPrompt, - imageUrls: [], - selectedSkills: undefined, - }); - }, [handleSubmit, initialPrompt]); - - const handleSelectSession = useCallback( - async (sessionId: string) => { - const currentSessionId = sessionManager.getActiveSessionId(); - if (currentSessionId !== sessionId) { - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); - } - sessionManager.setActiveSessionId(sessionId); - // Clear first so resets its index to 0. - setMessages([]); - setShowWelcome(false); - setWelcomeNonce((n) => n + 1); - setView("chat"); - // Load messages after the reset so all static items are rendered. - setTimeout(() => { - setMessages(loadVisibleMessages(sessionManager, sessionId)); - setShowWelcome(true); - }, 0); - const session = sessionManager.getSession(sessionId); - setStatusLine(session ? buildStatusLine(session) : ""); - setRunningProcesses(session?.processes ?? null); - setActiveStatus(session?.status ?? null); - await refreshSkills(sessionId); - }, - [sessionManager, refreshSkills] - ); - - const [stableColumns, setStableColumns] = useState(columns); - useEffect(() => { - const timer = setTimeout(() => setStableColumns(columns), 100); - return () => clearTimeout(timer); - }, [columns]); - const lastRenderedColumnsRef = useRef(null); - useEffect(() => { - if (!stdout?.isTTY) { - return; - } - if (stableColumns <= 0) { - return; - } - if (lastRenderedColumnsRef.current === null) { - lastRenderedColumnsRef.current = stableColumns; - return; - } - if (lastRenderedColumnsRef.current === stableColumns) { - return; - } - lastRenderedColumnsRef.current = stableColumns; - - // Force full redraw on terminal resize to avoid stale wrapped rows. - writeRef.current("\u001B[2J\u001B[H"); - setMessages([]); - setShowWelcome(false); - setWelcomeNonce((n) => n + 1); - - const activeSessionId = sessionManager.getActiveSessionId(); - const nextMessages = - activeSessionId && !busy ? loadVisibleMessages(sessionManager, activeSessionId) : messagesRef.current; - setTimeout(() => { - setMessages(nextMessages); - setShowWelcome(true); - }, 0); - }, [busy, sessionManager, stableColumns, stdout]); - const screenWidth = useMemo(() => stableColumns ?? stdout?.columns ?? 80, [stableColumns, stdout]); - const promptHistory = useMemo(() => { - return messages - .filter((message) => message.role === "user" && typeof message.content === "string") - .map((message) => (message.content ?? "").trim()) - .filter((content) => content.length > 0); - }, [messages]); - const expandedThinkingId = findExpandedThinkingId(messages); - const pendingQuestion = useMemo(() => findPendingAskUserQuestion(messages, activeStatus), [activeStatus, messages]); - const shouldShowQuestionPrompt = Boolean(pendingQuestion && !dismissedQuestionIds.has(pendingQuestion.messageId)); - const loadingText = useMemo( - () => (busy ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) : null), - // eslint-disable-next-line react-hooks/exhaustive-deps -- nowTick forces periodic recalculation for spinner animation - [busy, streamProgress, runningProcesses, nowTick] - ); - const welcomeSettings = resolvedSettings; - const welcomeItem: SessionMessage = useMemo( - () => ({ - id: `__welcome__${welcomeNonce}`, - sessionId: "", - role: "system", - content: "", - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: "", - updateTime: "", - }), - [welcomeNonce] - ); - const staticItems = useMemo(() => { - if (showWelcome && view === "chat") { - return [welcomeItem, ...messages]; - } - return messages; - }, [showWelcome, view, messages, welcomeItem]); - - const handleQuestionAnswers = useCallback( - (answers: AskUserQuestionAnswers) => { - void handlePrompt({ - text: formatAskUserQuestionAnswers(answers), - imageUrls: [], - }); - }, - [handlePrompt] - ); - - const handleQuestionCancel = useCallback(() => { - if (!pendingQuestion) { - return; - } - setDismissedQuestionIds((prev) => new Set(prev).add(pendingQuestion.messageId)); - }, [pendingQuestion]); - - return ( - - - {(item) => { - if (item.id.startsWith("__welcome__")) { - return ( - - ); - } - return ( - - ); - }} - - {statusLine ? ( - - {statusLine} - - ) : null} - {errorLine ? ( - - Error: {errorLine} - - ) : null} - {showProcessStdout ? ( - - ) : view === "session-list" ? ( - void handleSelectSession(id)} - onCancel={() => setView("chat")} - /> - ) : view === "mcp-status" ? ( - setView("chat")} /> - ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( - - ) : isExiting ? null : ( - - )} - - ); -} - -function isCollapsedThinking(message: SessionMessage, expandedId: string | null): boolean { - if (message.role !== "assistant") { - return false; - } - if (!message.meta?.asThinking) { - return false; - } - return message.id !== expandedId; -} - -function buildSyntheticUserMessage(content: string, imageCount: number): SessionMessage { - const now = new Date().toISOString(); - return { - id: `local-${Math.random().toString(36).slice(2)}`, - sessionId: "local", - role: "user", - content, - contentParams: - imageCount > 0 - ? Array.from({ length: imageCount }, () => ({ - type: "image_url", - image_url: { url: "" }, - })) - : null, - messageParams: null, - compacted: false, - visible: true, - createTime: now, - updateTime: now, - }; -} - -function isCurrentSessionEmpty(sessionManager: SessionManager): boolean { - const activeSessionId = sessionManager.getActiveSessionId(); - return !activeSessionId || !sessionManager.getSession(activeSessionId); -} - -function buildStatusLine(entry: SessionEntry): string { - const parts: string[] = []; - parts.push(`status: ${entry.status}`); - if (typeof entry.activeTokens === "number" && entry.activeTokens > 0) { - parts.push(`tokens: ${entry.activeTokens}`); - } - if (entry.failReason) { - parts.push(`fail: ${entry.failReason}`); - } - return parts.join(" · "); -} - -export function readSettings(): DeepcodingSettings | null { - return readSettingsFile(getUserSettingsPath()); -} - -export function readProjectSettings(projectRoot: string = process.cwd()): DeepcodingSettings | null { - return readSettingsFile(getProjectSettingsPath(projectRoot)); -} - -function readSettingsFile(settingsPath: string): DeepcodingSettings | null { - try { - if (!fs.existsSync(settingsPath)) { - return null; - } - const raw = fs.readFileSync(settingsPath, "utf8"); - return JSON.parse(raw) as DeepcodingSettings; - } catch { - return null; - } -} - -export function writeSettings(settings: DeepcodingSettings): void { - const settingsPath = getUserSettingsPath(); - writeSettingsFile(settingsPath, settings); -} - -export function writeProjectSettings(settings: DeepcodingSettings, projectRoot: string = process.cwd()): void { - const settingsPath = getProjectSettingsPath(projectRoot); - writeSettingsFile(settingsPath, settings); -} - -function writeSettingsFile(settingsPath: string, settings: DeepcodingSettings): void { - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); -} - -export function writeModelConfigSelection( - selection: ModelConfigSelection, - current: ModelConfigSelection = resolveCurrentSettings(), - projectRoot: string = process.cwd() -): { changed: boolean; settings: DeepcodingSettings } { - const projectSettingsPath = getProjectSettingsPath(projectRoot); - const shouldWriteProjectSettings = fs.existsSync(projectSettingsPath); - const rawSettings = shouldWriteProjectSettings ? readProjectSettings(projectRoot) : readSettings(); - const result = applyModelConfigSelection(rawSettings, current, selection); - if (result.changed) { - if (shouldWriteProjectSettings) { - writeProjectSettings(result.settings, projectRoot); - } else { - writeSettings(result.settings); - } - } - return result; -} - -export function resolveCurrentSettings(projectRoot: string = process.cwd()): ResolvedDeepcodingSettings { - return resolveSettingsSources( - readSettings(), - readProjectSettings(projectRoot), - { - model: DEFAULT_MODEL, - baseURL: DEFAULT_BASE_URL, - }, - process.env - ); -} - -export function createOpenAIClient(projectRoot: string = process.cwd()): { - client: OpenAI | null; - model: string; - baseURL: string; - thinkingEnabled: boolean; - reasoningEffort: "high" | "max"; - debugLogEnabled: boolean; - notify?: string; - webSearchTool?: string; - env: Record; - machineId?: string; -} { - const settings = resolveCurrentSettings(projectRoot); - if (!settings.apiKey) { - return { - client: null, - model: settings.model, - baseURL: settings.baseURL, - thinkingEnabled: settings.thinkingEnabled, - reasoningEffort: settings.reasoningEffort, - debugLogEnabled: settings.debugLogEnabled, - notify: settings.notify, - webSearchTool: settings.webSearchTool, - env: settings.env, - machineId: getMachineId(), - }; - } - - const client = new OpenAI({ - apiKey: settings.apiKey, - baseURL: settings.baseURL || undefined, - }); - return { - client, - model: settings.model, - baseURL: settings.baseURL, - thinkingEnabled: settings.thinkingEnabled, - reasoningEffort: settings.reasoningEffort, - debugLogEnabled: settings.debugLogEnabled, - notify: settings.notify, - webSearchTool: settings.webSearchTool, - env: settings.env, - machineId: getMachineId(), - }; -} - -function getMachineId(): string | undefined { - try { - const idPath = path.join(os.homedir(), ".deepcode", "machine-id"); - if (fs.existsSync(idPath)) { - const raw = fs.readFileSync(idPath, "utf8").trim(); - if (raw) { - return raw; - } - } - const generated = `${os.hostname()}-${Math.random().toString(36).slice(2)}-${Date.now()}`; - fs.mkdirSync(path.dirname(idPath), { recursive: true }); - fs.writeFileSync(idPath, generated, "utf8"); - return generated; - } catch { - return undefined; - } -} - -function getUserSettingsPath(): string { - return path.join(os.homedir(), ".deepcode", "settings.json"); -} - -function getProjectSettingsPath(projectRoot: string): string { - return path.join(projectRoot, ".deepcode", "settings.json"); -} - -function formatThinkingMode(settings: Pick): string { - if (!settings.thinkingEnabled) { - return "no thinking"; - } - return `thinking ${settings.reasoningEffort}`; -} - -function formatModelConfig(settings: ModelConfigSelection): string { - return `${settings.model}, ${formatThinkingMode(settings)}`; -} diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx deleted file mode 100644 index 6f388d06..00000000 --- a/src/ui/MessageView.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import React from "react"; -import { Box, Text } from "ink"; -import { renderMarkdown } from "./markdown"; -import type { SessionMessage } from "../session"; - -type Props = { - message: SessionMessage; - collapsed?: boolean; - width?: number; -}; - -export function MessageView({ message, collapsed, width = 80 }: Props): React.ReactElement | null { - if (!message.visible) { - return null; - } - - if (message.role === "user") { - const text = message.content || "(no content)"; - return ( - - - {`>`} - - - {text} - {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} - ) : null} - - - ); - } - - if (message.role === "assistant") { - const isThinking = Boolean(message.meta?.asThinking); - const content = (message.content || "").trim(); - - if (isThinking) { - const summary = buildThinkingSummary(content, message.messageParams); - if (collapsed !== false) { - return ( - - - - ); - } - return ( - - - - {content ? {renderMarkdown(content)} : null} - - - ); - } - - const containerWidth = Math.max(1, width - 2); - const contentWidth = Math.max(1, width - 4); - - return ( - - - - - - {content ? {renderMarkdown(content)} : null} - - - ); - } - - if (message.role === "tool") { - const summary = buildToolSummary(message); - const diffLines = getToolDiffPreviewLines(summary); - const planLines = getUpdatePlanPreviewLines(summary); - return ( - - - {diffLines.length > 0 ? : null} - {planLines.length > 0 ? : null} - - ); - } - - if (message.role === "system") { - // Render model change messages in the same style as user commands. - if (message.meta?.isModelChange) { - return ( - - - {`>`} - - - {message.content} - - - ); - } - - if (message.meta?.skill) { - return ( - - ⚡ Loaded skill: {message.meta.skill.name} - - ); - } - if (message.meta?.isSummary) { - return ( - - - (conversation summary inserted) - - - ); - } - return null; - } - - return null; -} - -function StatusLine({ - bulletColor, - name, - params, -}: { - bulletColor: "gray" | "green" | "red"; - name: string; - params: string; -}): React.ReactElement { - return ( - - {[ - - ✧ - , - " ", - - {name} - , - params ? {` ${params}`} : null, - ]} - - ); -} - -function formatToolStatusParams(summary: ToolSummary): string { - const params = firstNonEmptyLine(summary.params); - return summary.name.toLowerCase() === "bash" ? params : truncate(params, 120); -} - -type ToolSummary = { - name: string; - params: string; - ok: boolean; - metadata: Record | null; -}; - -type DiffPreviewLine = { - marker: string; - content: string; - kind: "added" | "removed" | "context"; -}; - -function buildToolSummary(message: SessionMessage): ToolSummary { - const payload = parseToolPayload(message.content); - const metaFunctionName = - message.meta?.function && typeof (message.meta.function as { name?: unknown }).name === "string" - ? (message.meta.function as { name: string }).name - : null; - const name = payload.name || metaFunctionName || "tool"; - const params = - name === "AskUserQuestion" - ? extractAskUserQuestionParams(message) || getMetaParams(message) - : getMetaParams(message); - - return { - name, - params, - ok: payload.ok !== false, - metadata: payload.metadata, - }; -} - -function getMetaParams(message: SessionMessage): string { - return typeof message.meta?.paramsMd === "string" ? message.meta.paramsMd.trim() : ""; -} - -function extractAskUserQuestionParams(message: SessionMessage): string { - const fromFunction = extractQuestionsFromToolFunction(message.meta?.function); - if (fromFunction) { - return fromFunction; - } - - const params = getMetaParams(message); - if (!params) { - return ""; - } - - try { - const parsed = JSON.parse(params); - return extractQuestionsFromValue(parsed); - } catch { - return ""; - } -} - -function extractQuestionsFromToolFunction(toolFunction: unknown): string { - if (!toolFunction || typeof toolFunction !== "object") { - return ""; - } - const args = (toolFunction as { arguments?: unknown }).arguments; - if (typeof args !== "string" || !args.trim()) { - return ""; - } - try { - const parsed = JSON.parse(args); - return extractQuestionsFromValue((parsed as { questions?: unknown })?.questions); - } catch { - return ""; - } -} - -function extractQuestionsFromValue(value: unknown): string { - if (!Array.isArray(value)) { - return ""; - } - return value - .map((item) => { - if (!item || typeof item !== "object" || Array.isArray(item)) { - return ""; - } - return typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; - }) - .filter(Boolean) - .join(" / "); -} - -function parseToolPayload(content: string | null): { - name: string | null; - ok: boolean; - metadata: Record | null; -} { - if (!content) { - return { name: null, ok: true, metadata: null }; - } - - try { - const parsed = JSON.parse(content) as { name?: unknown; ok?: unknown; metadata?: unknown }; - return { - name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null, - ok: parsed.ok !== false, - metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null, - }; - } catch { - return { name: null, ok: true, metadata: null }; - } -} - -function getToolDiffPreviewLines(summary: ToolSummary): DiffPreviewLine[] { - if (!summary.ok || !["edit", "write"].includes(summary.name.toLowerCase())) { - return []; - } - const diffPreview = summary.metadata?.diff_preview; - if (typeof diffPreview !== "string" || !diffPreview.trim()) { - return []; - } - return parseDiffPreview(diffPreview); -} - -function getUpdatePlanPreviewLines(summary: ToolSummary): string[] { - if (!summary.ok || summary.name !== "UpdatePlan") { - return []; - } - const plan = summary.metadata?.plan; - if (typeof plan !== "string" || !plan.trim()) { - return []; - } - return plan - .split(/\r?\n/) - .map((line) => line.trimEnd()) - .filter((line) => line.trim().length > 0); -} - -export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { - return diffPreview - .split("\n") - .filter((line) => line && !line.startsWith("--- ") && !line.startsWith("+++ ") && !line.startsWith("@@ ")) - .map((line) => { - if (line.startsWith("+")) { - return { marker: "+", content: line.slice(1), kind: "added" }; - } - if (line.startsWith("-")) { - return { marker: "-", content: line.slice(1), kind: "removed" }; - } - return { - marker: " ", - content: line.startsWith(" ") ? line.slice(1) : line, - kind: "context", - }; - }); -} - -function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElement { - return ( - - └ Changes - - {lines.map((line, index) => ( - - - {line.marker} - - - {line.content} - - - ))} - - - ); -} - -function PlanPreview({ lines }: { lines: string[] }): React.ReactElement { - return ( - - └ Plan - - {lines.map((line, index) => ( - - {line} - - ))} - - - ); -} - -function isPlainRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -function formatStatusName(value: string): string { - return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : "Tool"; -} - -function truncate(value: string, max: number): string { - if (value.length <= max) { - return value; - } - return `${value.slice(0, max)}…`; -} - -function firstNonEmptyLine(value: string): string { - for (const line of value.split(/\r?\n/)) { - const trimmed = line.trim().replace(/\s+/g, " "); - if (trimmed) { - return trimmed; - } - } - return ""; -} - -function buildThinkingSummary(content: string, messageParams: unknown | null): string { - if (content) { - const normalized = content.replace(/\r?\n/g, " ").replace(/\s+/g, " "); - let result = truncate(normalized, 100); - if (result.endsWith(":") || result.endsWith(":")) { - result = result.slice(0, -1); - } - return result; - } - - const params = messageParams as { reasoning_content?: unknown } | null | undefined; - if (typeof params?.reasoning_content === "string" && params.reasoning_content.trim()) { - return "(reasoning...)"; - } - - return ""; -} diff --git a/src/ui/ProcessStdoutView.tsx b/src/ui/ProcessStdoutView.tsx deleted file mode 100644 index a0676c61..00000000 --- a/src/ui/ProcessStdoutView.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Box, Text } from "ink"; -import type { SessionEntry } from "../session"; -import { useTerminalInput } from "./prompt"; - -type RunningProcesses = SessionEntry["processes"]; - -type ProcessStdoutViewProps = { - processStdoutRef: React.MutableRefObject>; - runningProcesses: RunningProcesses; - onDismiss: () => void; - screenWidth: number; -}; - -const REFRESH_INTERVAL_MS = 150; -const MAX_VISIBLE_LINES = 100; - -export const ProcessStdoutView = React.memo(function ProcessStdoutView({ - processStdoutRef, - runningProcesses, - onDismiss, - screenWidth, -}: ProcessStdoutViewProps): React.ReactElement { - const [stdoutText, setStdoutText] = useState(""); - const [scrollOffset, setScrollOffset] = useState(0); - const containerRef = useRef<{ lineCount: number }>({ lineCount: 0 }); - - useEffect(() => { - const updateStdout = () => { - let text = ""; - if (runningProcesses && runningProcesses.size > 0) { - for (const [pid, proc] of runningProcesses.entries()) { - const pidNum = Number(pid); - const stdout = processStdoutRef.current.get(pidNum) ?? ""; - if (text) { - text += "\n"; - } - if (runningProcesses.size > 1) { - text += `── Process ${pid} [${proc.command}] ──\n`; - } - text += stdout || "(no output yet)"; - } - } else { - text = "(no running processes)"; - } - setStdoutText(text); - }; - - updateStdout(); - const interval = setInterval(updateStdout, REFRESH_INTERVAL_MS); - return () => clearInterval(interval); - }, [processStdoutRef, runningProcesses]); - - // Update container line count for scroll awareness - const lines = useMemo(() => stdoutText.split("\n"), [stdoutText]); - containerRef.current.lineCount = lines.length; - - const visibleLines = useMemo(() => { - if (lines.length <= MAX_VISIBLE_LINES) { - return lines; - } - const start = Math.max(0, lines.length - MAX_VISIBLE_LINES - scrollOffset); - const slice = lines.slice(start, start + MAX_VISIBLE_LINES); - if (lines.length > MAX_VISIBLE_LINES) { - slice.unshift(`... (${start} lines above · ↑/↓ to scroll · ${lines.length} total lines) ...`); - } - return slice; - }, [lines, scrollOffset]); - - useTerminalInput( - (input, key) => { - if ((key.ctrl && (input === "o" || input === "O")) || key.escape) { - onDismiss(); - return; - } - if (key.upArrow) { - setScrollOffset((s) => Math.min(s + 10, Math.max(0, lines.length - MAX_VISIBLE_LINES))); - return; - } - if (key.downArrow) { - setScrollOffset((s) => Math.max(s - 10, 0)); - return; - } - if (key.pageUp) { - setScrollOffset((s) => Math.min(s + MAX_VISIBLE_LINES, Math.max(0, lines.length - MAX_VISIBLE_LINES))); - return; - } - if (key.pageDown) { - setScrollOffset((s) => Math.max(s - MAX_VISIBLE_LINES, 0)); - return; - } - }, - { isActive: true } - ); - - return ( - - - 📟 Process Output - (Ctrl+O or Esc to close · ↑↓ PageUp/PageDown to scroll) - - - {visibleLines.map((line, index) => ( - {line} - ))} - - - ); -}); diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx deleted file mode 100644 index fdbd1fee..00000000 --- a/src/ui/SessionList.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useState, useMemo } from "react"; -import { Box, Text, useInput, useWindowSize } from "ink"; -import type { SessionEntry } from "../session"; - -type Props = { - sessions: SessionEntry[]; - onSelect: (sessionId: string) => void; - onCancel: () => void; -}; - -export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { - const [index, setIndex] = useState(0); - const { columns, rows } = useWindowSize(); - - // Dynamically calculate the number of visible sessions based on terminal height - const maxVisibleSessions = useMemo(() => { - // Subtract space used by borders, header, footer, scroll indicator, etc. - // Outer container height=rows-1, outer border 2 + header 1 + inner border 2 + footer 1 + scroll indicator 1 = 8 - const reservedLines = 8; - const linesPerSession = 3; // height=2 + marginBottom=1 - const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); - return Math.max(1, Math.floor(availableLines / linesPerSession)); - }, [rows]); - - // Ensure index stays within valid range - const safeIndex = useMemo(() => { - if (sessions.length === 0) return 0; - return Math.max(0, Math.min(index, sessions.length - 1)); - }, [index, sessions.length]); - - // Calculate scroll offset to keep the selected item visible - const scrollOffset = useMemo(() => { - if (safeIndex < maxVisibleSessions) return 0; - return safeIndex - maxVisibleSessions + 1; - }, [safeIndex, maxVisibleSessions]); - - // Get the currently visible session list - const visibleSessions = useMemo(() => { - return sessions.slice(scrollOffset, scrollOffset + maxVisibleSessions); - }, [sessions, scrollOffset, maxVisibleSessions]); - - useInput((input, key) => { - if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { - onCancel(); - return; - } - if (sessions.length === 0) { - return; - } - if (key.upArrow) { - setIndex((i) => Math.max(0, i - 1)); - return; - } - if (key.downArrow) { - setIndex((i) => Math.min(sessions.length - 1, i + 1)); - return; - } - if (key.pageUp) { - setIndex((i) => Math.max(0, i - maxVisibleSessions)); - return; - } - if (key.pageDown) { - setIndex((i) => Math.min(sessions.length - 1, i + maxVisibleSessions)); - return; - } - if (key.home) { - setIndex(0); - return; - } - if (key.end) { - setIndex(sessions.length - 1); - return; - } - if (key.return) { - const session = sessions[safeIndex]; - if (session) { - onSelect(session.id); - } - } - }); - - if (sessions.length === 0) { - return ( - - No previous sessions found. - Press Esc to go back. - - ); - } - - return ( - - - {/* Header row */} - - - Resume a session - - - {" "} - ({sessions.length} total) - - - {/* Session list */} - - {visibleSessions.map((session, i) => { - const actualIndex = scrollOffset + i; - return ( - - - {actualIndex === safeIndex ? "> " : " "} - - - - - {formatSessionTitle(session.summary || "Untitled")} - - ({session.status}) - - - {formatTimestamp(session.updateTime)} - - - - ); - })} - {scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length ? ( - - {scrollOffset > 0 ? … {scrollOffset} newer sessions above. : null} - {scrollOffset + maxVisibleSessions < sessions.length ? ( - … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below. - ) : null} - - ) : null} - - {/* Footer */} - - ↑/↓ navigate · PgUp/PgDn page · Enter select · Esc cancel - - - - ); -} - -function formatTimestamp(value: string): string { - try { - const date = new Date(value); - if (Number.isNaN(date.valueOf())) { - return value; - } - return date.toLocaleString(); - } catch { - return value; - } -} - -export function formatSessionTitle(value: string, max = 70): string { - return truncate(value.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim(), max); -} - -function truncate(value: string, max: number): string { - if (value.length <= max) { - return value; - } - return `${value.slice(0, max)}…`; -} diff --git a/src/ui/index.ts b/src/ui/index.ts deleted file mode 100644 index 5bcde406..00000000 --- a/src/ui/index.ts +++ /dev/null @@ -1,92 +0,0 @@ -export { - App, - readSettings, - readProjectSettings, - writeSettings, - writeProjectSettings, - writeModelConfigSelection, - resolveCurrentSettings, - createOpenAIClient, -} from "./App"; -export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; -export { MessageView, parseDiffPreview } from "./MessageView"; -export { - PromptInput, - IMAGE_ATTACHMENT_CLEAR_HINT, - formatImageAttachmentStatus, - formatSelectedSkillsStatus, - isSkillSelected, - addUniqueSkill, - toggleSkillSelection, - removeCurrentSlashToken, - isClearImageAttachmentsShortcut, - getPromptReturnKeyAction, - renderBufferWithCursor, - buildInitPromptSubmission, - getThinkingOptionIndex, - MODEL_COMMAND_MODELS, - MODEL_COMMAND_THINKING_OPTIONS, - useTerminalInput, - parseTerminalInput, - type PromptSubmission, - type InputKey, -} from "./PromptInput"; -export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; -export { SessionList, formatSessionTitle } from "./SessionList"; -export { ThemedGradient } from "./ThemedGradient"; -export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; -export { WelcomeScreen, formatHomeRelativePath, buildWelcomeTips } from "./WelcomeScreen"; -export { - findPendingAskUserQuestion, - formatAskUserQuestionAnswers, - formatAskUserQuestionDecline, - type AskUserQuestionOption, - type AskUserQuestionItem, - type PendingAskUserQuestion, - type AskUserQuestionAnswers, -} from "./askUserQuestion"; -export { readClipboardImage, type ClipboardImage } from "./clipboard"; -export { buildLoadingText, type LoadingTextInput } from "./loadingText"; -export { renderMarkdown } from "./markdown"; -export { - EMPTY_BUFFER, - insertText, - backspace, - deleteForward, - moveLeft, - moveRight, - moveWordLeft, - moveWordRight, - moveUp, - moveDown, - moveLineStart, - moveLineEnd, - killLine, - deleteWordBefore, - deleteWordAfter, - reset, - isEmpty, - getCurrentSlashToken, - type PromptBufferState, -} from "./promptBuffer"; -export { - BUILTIN_SLASH_COMMANDS, - buildSlashCommands, - filterSlashCommands, - findExactSlashCommand, - formatSlashCommandDescription, - formatSlashCommandLabel, - type SlashCommandKind, - type SlashCommandItem, -} from "./slashCommands"; -export { - filterFileMentionItems, - formatFileMentionPath, - getCurrentFileMentionToken, - replaceCurrentFileMentionToken, - scanFileMentionItems, - type FileMentionItem, - type FileMentionToken, -} from "./fileMentions"; -export { findExpandedThinkingId } from "./thinkingState"; -export { buildExitSummaryText } from "./exitSummary"; diff --git a/src/ui/markdown.ts b/src/ui/markdown.ts deleted file mode 100644 index 11fb0eaa..00000000 --- a/src/ui/markdown.ts +++ /dev/null @@ -1,117 +0,0 @@ -import chalk from "chalk"; - -export function renderMarkdown(text: string): string { - if (!text) { - return ""; - } - - const fenceSegments = splitByFences(text); - return fenceSegments - .map((segment) => { - if (segment.kind === "code") { - const langTag = segment.lang ? chalk.dim(`[${segment.lang}]`) + "\n" : ""; - return langTag + chalk.cyan(segment.body); - } - return renderInlineBlock(segment.body); - }) - .join(""); -} - -type FenceSegment = { kind: "text"; body: string } | { kind: "code"; lang: string; body: string }; - -function splitByFences(text: string): FenceSegment[] { - const segments: FenceSegment[] = []; - const lines = text.split(/\r?\n/); - let buffer: string[] = []; - let inFence = false; - let fenceLang = ""; - let fenceBody: string[] = []; - - const flushText = () => { - if (buffer.length === 0) { - return; - } - segments.push({ kind: "text", body: buffer.join("\n") }); - buffer = []; - }; - - for (const line of lines) { - const fenceMatch = /^\s*```(\w*)\s*$/.exec(line); - if (fenceMatch) { - if (!inFence) { - flushText(); - inFence = true; - fenceLang = fenceMatch[1] ?? ""; - fenceBody = []; - } else { - segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); - inFence = false; - fenceLang = ""; - fenceBody = []; - } - continue; - } - - if (inFence) { - fenceBody.push(line); - } else { - buffer.push(line); - } - } - - if (inFence) { - segments.push({ kind: "code", lang: fenceLang, body: fenceBody.join("\n") }); - } else { - flushText(); - } - - return segments; -} - -function renderInlineBlock(text: string): string { - return text - .split("\n") - .map((line) => renderInlineLine(line)) - .join("\n"); -} - -function renderInlineLine(line: string): string { - const headingMatch = /^(\s*)(#{1,6})\s+(.*)$/.exec(line); - if (headingMatch) { - const [, lead, hashes, content] = headingMatch; - const styled = hashes.length <= 2 ? chalk.bold.cyanBright(content) : chalk.bold.cyan(content); - return `${lead}${chalk.dim(hashes)} ${styled}`; - } - - const listMatch = /^(\s*)([-*+])\s+(.*)$/.exec(line); - if (listMatch) { - const [, lead, bullet, content] = listMatch; - return `${lead}${chalk.yellow(bullet)} ${renderInlineSpans(content)}`; - } - - const numListMatch = /^(\s*)(\d+\.)\s+(.*)$/.exec(line); - if (numListMatch) { - const [, lead, marker, content] = numListMatch; - return `${lead}${chalk.yellow(marker)} ${renderInlineSpans(content)}`; - } - - const quoteMatch = /^(\s*)>\s?(.*)$/.exec(line); - if (quoteMatch) { - const [, lead, content] = quoteMatch; - return `${lead}${chalk.dim("│ ")}${chalk.italic(renderInlineSpans(content))}`; - } - - return renderInlineSpans(line); -} - -function renderInlineSpans(text: string): string { - if (!text) { - return text; - } - let result = text; - result = result.replace(/`([^`]+)`/g, (_, inner) => chalk.cyan(inner)); - result = result.replace(/\*\*([^*]+)\*\*/g, (_, inner) => chalk.bold(inner)); - result = result.replace(/(? chalk.italic(inner)); - result = result.replace(/_([^_\n]+)_/g, (_, inner) => chalk.italic(inner)); - return result; -} diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts deleted file mode 100644 index 2668470c..00000000 --- a/src/ui/prompt/cursor.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { useLayoutEffect, useRef } from "react"; -import type { PromptBufferState } from "../promptBuffer"; - -type CursorPlacement = { - rowsUp: number; - column: number; -}; - -type WriteFn = ( - chunk: string | Uint8Array, - encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), - callback?: (error?: Error | null) => void -) => boolean; - -function cursorUp(rows: number): string { - return rows > 0 ? `\u001B[${rows}A` : ""; -} - -function cursorDown(rows: number): string { - return rows > 0 ? `\u001B[${rows}B` : ""; -} - -function cursorForward(columns: number): string { - return columns > 0 ? `\u001B[${columns}C` : ""; -} - -function showCursor(): string { - return "\u001B[?25h"; -} - -function hideCursor(): string { - return "\u001B[?25l"; -} - -function enableTerminalFocusReporting(): string { - return "\u001B[?1004h"; -} - -function disableTerminalFocusReporting(): string { - return "\u001B[?1004l"; -} - -export function enableTerminalExtendedKeys(): string { - return "\u001B[>4;1m"; -} - -export function disableTerminalExtendedKeys(): string { - return "\u001B[>4;0m"; -} - -export function getPromptCursorPlacement( - state: PromptBufferState, - screenWidth: number, - prefixWidth: number, - footerText: string -): CursorPlacement { - const width = Math.max(1, screenWidth); - const cursor = Math.max(0, Math.min(state.cursor, state.text.length)); - const beforeCursor = state.text.slice(0, cursor); - const at = state.text[cursor]; - const displayText = - beforeCursor + - (typeof at === "undefined" || at === "\n" ? " " : at) + - (at === "\n" ? "\n" : "") + - (typeof at === "undefined" ? "" : state.text.slice(cursor + 1)); - - const cursorPosition = measureTextPosition(beforeCursor, width, prefixWidth); - const promptRows = measureTextRows(displayText, width, prefixWidth); - const footerRows = 1 + measureTextRows(footerText, width, 0); - - return { - rowsUp: promptRows - 1 - cursorPosition.row + footerRows + 1, - column: cursorPosition.column, - }; -} - -function measureTextRows(text: string, width: number, initialColumn: number): number { - return measureTextPosition(text, width, initialColumn).row + 1; -} - -function measureTextPosition(text: string, width: number, initialColumn: number): { row: number; column: number } { - let row = 0; - let column = Math.min(initialColumn, width - 1); - - for (const char of Array.from(text)) { - if (char === "\n") { - row++; - column = Math.min(initialColumn, width - 1); - continue; - } - - const charColumns = textWidth(char); - if (column + charColumns > width) { - row++; - column = Math.min(initialColumn, width - 1); - } - column += charColumns; - if (column >= width) { - row++; - column = Math.min(initialColumn, width - 1); - } - } - - return { row, column }; -} - -function textWidth(value: string): number { - let width = 0; - for (const char of Array.from(value.normalize())) { - width += characterWidth(char); - } - return width; -} - -function characterWidth(char: string): number { - const codePoint = char.codePointAt(0) ?? 0; - if (codePoint === 0 || codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) { - return 0; - } - if (codePoint >= 0x300 && codePoint <= 0x36f) { - return 0; - } - if ( - (codePoint >= 0x1100 && codePoint <= 0x115f) || - (codePoint >= 0x2e80 && codePoint <= 0xa4cf) || - (codePoint >= 0xac00 && codePoint <= 0xd7a3) || - (codePoint >= 0xf900 && codePoint <= 0xfaff) || - (codePoint >= 0xfe10 && codePoint <= 0xfe19) || - (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || - (codePoint >= 0xff00 && codePoint <= 0xff60) || - (codePoint >= 0xffe0 && codePoint <= 0xffe6) - ) { - return 2; - } - return 1; -} - -export function usePromptTerminalCursor( - stdout: NodeJS.WriteStream | undefined, - placement: CursorPlacement, - isActive: boolean -): void { - const directWriteRef = useRef<((data: string) => void) | null>(null); - const activePlacementRef = useRef(null); - const lastPlacementRef = useRef(null); - const unmountingRef = useRef(false); - - useLayoutEffect(() => { - if (!stdout?.isTTY) { - return; - } - - const stream = stdout as NodeJS.WriteStream & { write: WriteFn }; - const originalWrite = stream.write; - const directWrite = (data: string) => { - originalWrite.call(stdout, data); - }; - const restorePromptCursor = () => { - if (unmountingRef.current) { - return; - } - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; - } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - // Schedule a deferred re-position in case the layout effect does not - // re-run (e.g. a dropdown closed without changing the buffer). - Promise.resolve().then(() => { - if (unmountingRef.current || activePlacementRef.current) { - return; - } - const latest = directWriteRef.current; - const p = lastPlacementRef.current; - if (latest && p) { - latest(showCursor() + cursorUp(p.rowsUp) + "\r" + cursorForward(p.column)); - activePlacementRef.current = p; - } - }); - }; - const patchedWrite: WriteFn = (...args) => { - restorePromptCursor(); - return originalWrite.apply(stdout, args); - }; - - directWriteRef.current = directWrite; - stream.write = patchedWrite; - - return () => { - restorePromptCursor(); - stream.write = originalWrite; - directWriteRef.current = null; - }; - }, [stdout]); - - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } - - unmountingRef.current = false; - const directWrite = directWriteRef.current; - if (!directWrite) { - return; - } - - directWrite(showCursor() + cursorUp(placement.rowsUp) + "\r" + cursorForward(placement.column)); - activePlacementRef.current = placement; - lastPlacementRef.current = placement; - - return () => { - unmountingRef.current = true; - lastPlacementRef.current = null; - const activePlacement = activePlacementRef.current; - if (!activePlacement) { - return; - } - directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); - activePlacementRef.current = null; - }; - }, [isActive, placement, stdout]); -} - -export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } - - stdout.write(hideCursor()); - return () => { - stdout.write(showCursor()); - }; - }, [isActive, stdout]); -} - -export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } - - stdout.write(enableTerminalFocusReporting()); - return () => { - stdout.write(disableTerminalFocusReporting()); - }; - }, [isActive, stdout]); -} - -export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { - useLayoutEffect(() => { - if (!isActive || !stdout?.isTTY) { - return; - } - - stdout.write(enableTerminalExtendedKeys()); - return () => { - stdout.write(disableTerminalExtendedKeys()); - }; - }, [isActive, stdout]); -} diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts deleted file mode 100644 index a33172c7..00000000 --- a/src/ui/prompt/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; -export type { InputKey } from "./useTerminalInput"; - -export { - useHiddenTerminalCursor, - useTerminalExtendedKeys, - usePromptTerminalCursor, - useTerminalFocusReporting, - getPromptCursorPlacement, -} from "./cursor"; diff --git a/templates/skills/agent-drift-guard.md b/templates/skills/agent-drift-guard.md deleted file mode 100644 index c6711b12..00000000 --- a/templates/skills/agent-drift-guard.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -name: agent-drift-guard -description: Detect and correct execution drift while working on user requests. Use when you are actively implementing, debugging, reviewing, or investigating and there is a risk of wandering beyond the user's goal, adding unrequested work, touching live systems, over-exploring, or ignoring repeated user boundary corrections. Especially useful during multi-step coding tasks, production-adjacent requests, ambiguous scopes, and anytime you should self-check whether it is still solving the requested problem. ---- - -# Agent Drift Guard - -Keep execution tightly aligned with the user's actual request. - -## Quick Start - -Run this mental check before substantial work and again whenever the plan expands: - -1. State the user's requested outcome in one sentence. -2. List explicit non-goals or boundaries the user has set. -3. Ask whether the next action directly advances the requested outcome. -4. If not, either cut it or pause to confirm. - -## Drift Signals - -Treat these as warning signs that execution may be drifting: - -- Exploring broadly before opening the most relevant file, command, or artifact. -- Solving adjacent operational issues when the user asked only for code changes. -- Adding extra safeguards, scripts, docs, refactors, or cleanup that the user did not ask for. -- Reframing the task around what seems "better" instead of what was requested. -- Continuing with a broader plan after the user narrows the scope. -- Repeating searches or tool calls without increasing certainty. -- Mixing diagnosis, remediation, and feature work when the user asked for only one of them. -- Touching production-like state, external systems, or live data without explicit permission. - -## Severity Levels - -### Level 1: Mild Drift - -Examples: -- One or two extra exploratory commands. -- Considering a broader solution but not acting on it yet. -- Briefly over-explaining instead of moving the task forward. - -Response: -- Auto-correct silently. -- Narrow to the smallest next action. -- Do not interrupt the user. - -### Level 2: Material Drift - -Examples: -- Planning additional deliverables not requested. -- Writing helper scripts, migrations, docs, or tests outside the asked scope. -- Expanding from code changes into operational fixes. -- Continuing after the user has already corrected the scope once. - -Response: -- Stop and realign internally first. -- If the broader action is avoidable, drop it and continue on scope. -- If the broader action has non-obvious tradeoffs, ask a brief confirmation question. - -### Level 3: Boundary or Risk Violation - -Examples: -- Modifying live systems, production data, external services, or user-owned state without being asked. -- Taking destructive or hard-to-reverse actions outside the requested scope. -- Ignoring repeated user instructions about what not to do. - -Response: -- Pause before acting. -- Surface the exact boundary and ask for confirmation. -- Offer the smallest on-scope option first. - -## Self-Check Loop - -Use this loop during execution: - -### Before the first meaningful action - -Write down mentally: -- Requested outcome -- Allowed scope -- Forbidden scope -- Smallest useful next step - -### After each non-trivial step - -Ask: -- Did this step directly help deliver the requested outcome? -- Did I learn something that changes scope, or only implementation? -- Am I about to do more than the user asked? - -### After a user correction - -Treat the correction as a hard boundary update. - -Then: -- Remove the old broader plan. -- Do not defend the discarded work. -- Continue from the narrowed scope. -- If needed, acknowledge briefly and move on. - -## Decision Rules - -Use these rules in order: - -1. Prefer the most direct artifact first. - - Open the relevant file before scanning the whole repo. - - Inspect the specific failing path before designing a general framework. - -2. Prefer the smallest complete fix. - - Solve the asked problem before improving related systems. - - Avoid bonus work unless it is required for correctness. - -3. Prefer internal correction over user interruption. - - If you can shrink back to scope confidently, do it. - - Ask only when the next step changes deliverables, risk, or ownership. - -4. Treat repeated user constraints as priority signals. - - A repeated instruction means your execution style is currently misaligned. - - Tighten scope immediately. - -5. Separate categories of work. - - Code change, investigation, production remediation, cleanup, and documentation are distinct tasks unless the user explicitly combines them. - -## Good Intervention Style - -When you must pause, keep it short and specific: - -- State the potential drift in one sentence. -- Name the tradeoff or boundary. -- Offer the smallest on-scope option first. - -Example: - -"Quick alignment check: I can keep this to the code fix only, or also add an ops cleanup step. I'll stick to the code fix unless you want both." - -## Anti-Patterns - -Do not: - -- Create cleanup scripts, docs, or side tools just because they seem useful. -- Broaden the task after discovering a neighboring problem. -- Continue with a plan the user has already rejected. -- Justify drift with "best practice" when the user asked for a narrower deliverable. -- Hide extra work inside a larger patch. - -## Final Check Before Responding - -Before sending the final answer, verify: - -- The delivered work matches the requested outcome. -- No extra deliverables were added without confirmation. -- Any assumptions are stated briefly. -- Suggested next steps are optional, not bundled into the completed work. diff --git a/templates/skills/plan-and-execute.md b/templates/skills/plan-and-execute.md deleted file mode 100644 index 9fc8bd2d..00000000 --- a/templates/skills/plan-and-execute.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: plan-and-execute -description: Automatically plan and execute requirements. Creates a markdown task list with the UpdatePlan tool, and systematically executes each task while updating progress. Use when working with task planning or when you need to break down and execute complex multi-step requirements. ---- - -# Plan and Execute - -This Skill helps you automatically plan and execute requirements. It creates a structured markdown task list with the UpdatePlan tool and systematically works through each task while keeping progress visible. - -## Quick Start - -When you need to work through a multi-step request: - -1. Analyze the requirements and explore enough project context -2. Clarify unclear or ambiguous requirements with AskUserQuestion -3. Create a markdown task list by calling the UpdatePlan tool -4. Execute tasks one by one, updating the tool plan in real time -5. Revise the remaining plan as new context appears - -## Instructions - -### Step 1: Analyze the requirements - -Identify the requirements from the available context. Explore the project enough to make the plan concrete and accurate. - -If the original requirements are unclear, incomplete, or ambiguous, call the AskUserQuestion tool before creating the task list. Ask only the questions needed to avoid implementing the wrong behavior, and keep each question specific to the decision that affects the plan or acceptance criteria. - -If a required referenced file path is missing, ask for it with AskUserQuestion: - -``` -What is the path to the referenced file? -``` - -Referenced files can be in any text format (.md, .txt, etc.) that contains task requirements or feature descriptions. If no additional file is needed, continue from the available requirements. - -- What are the main requirements? -- What tasks need to be completed? -- Are there dependencies between tasks? -- What is the complexity level? -- Which files, modules, commands, or tests are relevant? -- What ambiguity would change the implementation or acceptance criteria? - -### Step 2: Create the task list - -Create a structured markdown task list and pass it to the UpdatePlan tool as the `plan` string. The tool input must use this shape: - -```json -{ - "plan": "## Task List\n\n- [ ] Task 1 description\n- [ ] Task 2 description\n- [ ] Task 3 description" -} -``` - -Use this markdown format for the `plan` content: - -```markdown -## Task List - -- [ ] Task 1 description -- [ ] Task 2 description -- [ ] Task 3 description -``` - -Break down complex requirements into specific, actionable tasks and call UpdatePlan with the full markdown task list. - -### Step 3: Execute tasks systematically - -For each task in the list: - -1. **Refresh the plan**: Before starting the first task and after completing each task, re-evaluate the latest conversation and project context. Update the remaining tasks when scope, order, blockers, or follow-up work changes. -2. **Mark as in progress**: Call UpdatePlan with the task changed from `[ ]` to `[>]` -3. **Execute the task**: Use appropriate tools to complete the work -4. **Mark as completed**: Call UpdatePlan with the task changed from `[>]` to `[x]` when finished -5. **Move to next task**: Only ONE task should be in progress at a time - -Important rules: -- Always keep the plan aligned with the latest context before executing the next task -- Always call UpdatePlan BEFORE starting work on a task -- Always call UpdatePlan IMMEDIATELY after completing a task -- Always pass the complete current markdown task list, not a partial diff -- Never work on multiple tasks simultaneously -- Remove tasks that are no longer relevant, and add newly discovered tasks before working on them -- If you encounter errors, keep the task as `[>]` and create new tasks to resolve blockers - -### Step 4: Handle task breakdown - -If during execution you discover a task is more complex than expected: - -1. Keep the current task as `[>]` -2. Call UpdatePlan with new sub-tasks below it with indentation: - ```markdown - - [>] Main task - - [ ] Sub-task 1 - - [ ] Sub-task 2 - ``` -3. Complete sub-tasks first, then mark the main task as complete with UpdatePlan - -### Step 5: Final verification - -After all tasks are completed (`[x]`): - -1. Review the original requirements to ensure everything is addressed -2. Run any final checks (tests, builds, linting) -3. Call UpdatePlan with every task marked `[x]` -4. Provide a concise completion summary in the final response - -## Task State Symbols - -- `[ ]` - Pending -- `[>]` - In progress -- `[x]` - Completed -- `[!]` - Blocked - -## Examples - -### Example 1: Simple feature request - -**Example requirements:** -```markdown -# 新功能:添加深色模式切换 - -用户应该能够在浅色和深色主题之间切换。 -切换开关应放在设置页面中。 -``` - -**分析后的 UpdatePlan 调用:** -```markdown -## Task List - -- [ ] 在设置页面创建深色模式切换组件 -- [ ] 添加深色模式状态管理(context/store) -- [ ] 实现深色主题的 CSS-in-JS 样式 -- [ ] 更新现有组件以支持主题切换 -- [ ] 运行测试并验证功能 -``` - -**UpdatePlan call during execution:** -```markdown -## Task List - -- [x] 在设置页面创建深色模式切换组件 -- [>] 添加深色模式状态管理(context/store) -- [ ] 实现深色主题的 CSS-in-JS 样式 -- [ ] 更新现有组件以支持主题切换 -- [ ] 运行测试并验证功能 -``` - -### Example 2: Bug fix with investigation - -**Example requirements:** -```markdown -# Fix bug:登录表单提交时崩溃 - -当用户点击提交时,应用崩溃。 -错误信息:"Cannot read property 'email' of undefined" -``` - -**UpdatePlan call after analysis:** -```markdown -## Task List - -- [ ] 在本地复现缺陷 -- [ ] 调查登录表单组件中的错误 -- [ ] 定位 undefined email 属性的根本原因 -- [ ] 实施修复 -- [ ] 添加验证以防止类似问题 -- [ ] 使用各种输入测试修复 -- [ ] 更新错误处理 -``` - -## When to Use This Skill - -Use this Skill when: - -1. **Complex multi-step tasks** - Request requires 3+ distinct steps -2. **Feature implementation** - Building new functionality from requirements -3. **Bug fixing** - Need to investigate, fix, and verify -4. **Refactoring** - Multiple files or components need changes -5. **Detailed requirements** - Specifications need to be translated into concrete tasks -6. **Need progress tracking** - Want visible progress without editing source files - -## When NOT to Use This Skill - -Skip this Skill when: - -1. **Single simple task** - Just one straightforward action needed -2. **Trivial changes** - Quick fixes that don't need planning -3. **Informational requests** - User just wants explanation, not execution -4. **No execution requested** - User only wants brainstorming or a high-level explanation - -## Best Practices - -1. **Be specific with tasks**: "Add login button to navbar" not "Update UI" -2. **Keep tasks atomic**: Each task should be independently completable -3. **Update immediately**: Don't batch status updates, do them in real-time -4. **One task at a time**: Never mark multiple tasks as `[>]` -5. **Handle blockers**: If stuck, create new tasks to resolve the blocker -6. **Verify completion**: Only mark `[x]` when task is fully done - -## Advanced Usage - -### Handling dependencies - -When tasks have dependencies, order them properly: - -```markdown -- [ ] Create database schema -- [ ] Implement API endpoints (depends on schema) -- [ ] Build frontend forms (depends on API) -``` - -### Using sub-tasks - -For complex tasks, break them down: - -```markdown -- [>] Implement authentication system - - [x] Set up JWT library - - [>] Create login endpoint - - [ ] Create logout endpoint - - [ ] Add token refresh logic -``` - -### Adding notes - -Add implementation notes or findings: - -```markdown -- [x] Investigate performance issue - - Note: Found N+1 query in user loader - - Solution: Added dataloader batching -``` - -## Workflow Summary - -1. Analyze the requirements and relevant project context -2. Call AskUserQuestion if the original requirements are unclear or ambiguous -3. Call UpdatePlan with the structured markdown task list -4. Refresh the remaining plan before the first task -5. For each task: - - Update to `[>]` with UpdatePlan - - Execute the task - - Update to `[x]` with UpdatePlan - - Re-evaluate and revise remaining tasks before moving on -6. Call UpdatePlan with all tasks completed and summarize the result - -This approach keeps planning and progress tracking in the UpdatePlan display, leaving source materials unchanged unless the actual task requires editing them. diff --git a/templates/tools/bash.md b/templates/tools/bash.md deleted file mode 100644 index 07051201..00000000 --- a/templates/tools/bash.md +++ /dev/null @@ -1,70 +0,0 @@ -## Bash - -Executes a given bash command. Working directory persists between commands; shell state (everything else) does not. The shell environment is initialized from the user's profile (bash or zsh). - -On Windows, Bash runs through Git Bash. Use POSIX commands and quote Windows paths carefully. - -IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. - -IMPORTANT: Before reaching for generic shell pipelines, prefer purpose-built CLI tools when they make the task more accurate, safer, faster, or easier to understand: -- Use `ripgrep` (`rg`) when you need to search file contents by text or regex across the workspace; prefer it over slower tools like `grep`. -- Use `jq` when you need to inspect, filter, or transform JSON output; prefer it over ad-hoc parsing with `sed`, `awk`, or Python one-liners. - -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., cd "path with spaces/file.txt") - - Examples of proper quoting: - - cd "/Users/name/My Documents" (correct) - - cd /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does. - - If the output exceeds 30000 characters, output will be truncated before being returned to you. - - Always prefer using the dedicated tools for these commands: - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < - pytest /foo/bar/tests - - - cd /foo/bar && pytest tests - - -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "command": { - "description": "The command to execute", - "type": "string" - }, - "description": { - "description": "Clear, concise description of what this command does in active voice. Never use words like \"complex\" or \"risk\" in the description - just describe what it does.\n\nFor simple commands (git, npm, standard CLI tools), keep it brief (5-10 words):\n- ls → \"List files in current directory\"\n- git status → \"Show working tree status\"\n- npm install → \"Install package dependencies\"\n\nFor commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does:\n- find . -name \"*.tmp\" -exec rm {} \\; → \"Find and delete all .tmp files recursively\"\n- git reset --hard origin/main → \"Discard all local changes and match remote main\"\n- curl -s url | jq '.data[]' → \"Fetch JSON from URL and extract data array elements\"", - "type": "string" - } - }, - "required": [ - "command" - ], - "additionalProperties": false -} -``` diff --git a/tsconfig.json b/tsconfig.json index 24a6a1d1..9076eecf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,21 +1,38 @@ { "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "ignoreDeprecations": "6.0", - "lib": ["ES2022"], - "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, "forceConsistentCasingInFileNames": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, "resolveJsonModule": true, - "noEmit": true, - "isolatedModules": true, + "sourceMap": true, + "composite": true, + "incremental": true, + "declaration": true, "allowSyntheticDefaultImports": true, - "types": ["node"] + "verbatimModuleSyntax": true, + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2022", + "types": ["node"], + "jsx": "react-jsx" }, - "include": ["src/**/*.ts", "src/**/*.tsx"], - "exclude": ["node_modules", "dist"] + "include": [], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "./packages/core" }, + { "path": "./packages/cli" }, + { "path": "./packages/vscode-ide-companion" } + ] }