diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md deleted file mode 100644 index 652cf02..0000000 --- a/.deepcode/AGENTS.md +++ /dev/null @@ -1,101 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -``` -src/ -├── cli.tsx # Entry point — parses args, 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, agent-drift-guard skill -├── model-capabilities.ts # Model detection and thinking-mode defaults -├── ui/ -│ ├── App.tsx # Root Ink component — state, routing, session orchestration -│ ├── PromptInput.tsx # Multi-line input with slash commands, image paste, skills -│ ├── MessageView.tsx # Renders assistant/tool messages with markdown -│ ├── SessionList.tsx # Session picker for /resume -│ └── ... -├── tools/ -│ ├── executor.ts # ToolExecutor — dispatches tool calls to handlers -│ ├── bash-handler.ts # Executes shell commands -│ ├── read-handler.ts # Reads files and images -│ ├── write-handler.ts # Creates/overwrites files -│ ├── edit-handler.ts # Scoped string replacements in files -│ ├── web-search-handler.ts # Web search tool -│ └── ask-user-question-handler.ts # Interactive user prompts -├── tests/ # Test suite — one *.test.ts per module -docs/ -├── tools/ # Tool descriptions fed to the LLM -├── prompts/ # EJS templates (e.g., init_command.md.ejs) -dist/ # Bundled CLI output (gitignored) -``` - -## Build, Test, and Development Commands - -| Command | Purpose | -|---|---| -| `npm run typecheck` | TypeScript type checking (`tsc --noEmit`) | -| `npm run lint` | ESLint across `src/` | -| `npm run lint:fix` | ESLint with auto-fix | -| `npm run format` | Prettier on all `src/**/*.{ts,tsx}` | -| `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` — 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`) | - -Run the CLI locally for manual testing: `node dist/cli.js` (after `npm run bundle`). - -## Coding Style & Naming Conventions - -- **Indentation**: 2 spaces, no tabs -- **Quotes**: Double quotes (`"`) -- **Semicolons**: Required -- **Trailing commas**: `es5` (objects, arrays, etc.) -- **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. - -**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. - -**File naming**: `kebab-case.ts` for modules, `kebab-case.tsx` for React/Ink components. Test files: `*.test.ts`. - -## 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. -- **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. - -## Commit & Pull Request Guidelines - -**Commit messages** follow conventional commits. From the project history: - -- `feat:` — new feature (e.g., `feat: add /model command`) -- `fix:` — bug fix (e.g., `fix(ui): redraw cleanly after terminal resize`) -- `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 - -**Pull requests** should include: -- A clear description of what changed and why -- Link to related issue(s) if applicable -- Screenshots or terminal recordings for UI changes -- All checks passing (`npm run check && npm test`) -- No unintended changes to `dist/` or `package-lock.json` without justification - -## Architecture Overview - -The 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). - -Six tools are available to the LLM: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, and `WebSearch`. Tool definitions are registered in `src/tools/executor.ts` and described to the LLM via `src/prompt.ts` and `docs/tools/`. - -## 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. -- **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. -- The built-in `agent-drift-guard` skill is always injected into every session. diff --git a/.gitignore b/.gitignore index 11b67ce..43a04a7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ dist/ .vscode/ *.tgz *.log +tools/ +.claude/ +.serena/ \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 2312dc5..0000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -npx lint-staged diff --git a/.lintstagedrc b/.lintstagedrc deleted file mode 100644 index ef49282..0000000 --- a/.lintstagedrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "*.{ts,tsx,js,mjs,cjs,ejs,jsx}": [ - "eslint --fix", - "prettier --write" - ], - "*.json": [ - "prettier --write" - ], - ".prettierrc": [ - "prettier --write" - ] -} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 84dd052..0000000 --- a/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/ -dist/ -*.tgz -*.log -package-lock.json diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 0b297e4..0000000 --- a/.prettierrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "es5", - "printWidth": 120, - "endOfLine": "lf" -} diff --git a/README.md b/README.md index ea5dcde..e0f5864 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,126 @@ # Deep Code CLI -[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制以及 Agent Skills。 +Deep Code CLI is a terminal AI coding agent any OpenAI-compatible endpoints +Focused on company-code usage: correct tool/cache replay, sensitive-value redaction before the final HTTP payload reaches the provider, and transparent multi-provider settings. -## 安装 +## Security Model -```bash -npm install -g @vegamo/deepcode-cli -``` - -在任意项目目录下运行 `deepcode` 即可启动。 - -![intro2](resources/intro2.png) +The internal session state stays raw so tool calls, tool results, and prompt-cache replay keep working across requests. +When `PROVIDER_PRIVACY` is set to `strict`, the CLI clones the final provider request body and redacts that clone immediately before it is sent upstream. +It does not replace normal company security controls. Still avoid pasting real production secrets, rotate exposed keys, use provider ZDR when available, keep fallback routing disabled for sensitive work, and review final-boundary logs during hardening. -## 配置 +## Privacy Controls -创建 `~/.deepcode/settings.json` 文件,内容如下: - -```json -{ - "env": { - "MODEL": "deepseek-v4-pro", - "BASE_URL": "https://api.deepseek.com", - "API_KEY": "sk-..." - }, - "thinkingEnabled": true, - "reasoningEffort": "max" -} -``` +### Final Request Sanitization -配置文件与 [Deep Code VSCode 插件](https://github.com/lessweb/deepcode) 共享,无需重复配置。 +Strict provider privacy mode sanitizes only the outbound provider request body. +It does not write redacted text back into session history, cache state, or tool results. -## 主要功能 +The sanitizer redacts: -### **Skills** -Deep Code CLI 支持 agent skills,允许您扩展助手的能力: +- Password-like phrases +- JWTs +- PEM private keys +- Known API key formats (OpenRouter, OpenAI, GitHub, AWS) +- Unknown high-entropy secret-looking tokens +- Secret-looking values near keys such as `password`, `secret`, `token`, `api_key`, `private_key` -- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 -- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 +The strict sanitizer preserves file paths, tool call IDs, tool result structure, and ordinary code context so cached tool workflows replay correctly. -### **为 DeepSeek 优化** -- 专门为 DeepSeek 模型性能调优。 -- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 -- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 +### Tool Output Protection -## 快捷键 +Tool results are stored raw inside the local session. +With `PROVIDER_PRIVACY: "strict"`, sensitive values inside those tool results are redacted in the final provider-bound request clone before the model sees them. -| 键 | 操作 | -|-----------------|-----------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/skills` | 列出可用 skills | -| `/exit` | 退出 | -| 连续 `Ctrl+D` | 退出 | +### Sensitive File Reads -## 支持的模型 +Obvious secret-bearing files are refused by default: `.env`, `.npmrc`, `.pypirc`, private keys, certificate/key stores, kube configs, Docker configs, cloud credentials, and service-account JSON files. -- `deepseek-v4-pro`(推荐使用) -- `deepseek-v4-flash` -- 任何其他 OpenAI 兼容模型 +To explicitly allow sensitive reads: +```powershell +$env:DEEPCODE_ALLOW_SENSITIVE_READS="true" +node dist/cli.js +``` -## 常见问题 - -### Deep Code 是否有 VSCode 插件? - -有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 +## Configuration -### Deep Code 是否支持理解图片? +Create or edit `~/.deepcode/settings.json`. -Deep Code 支持多模态,可使用ctrl+v从剪贴板粘贴图片。但目前 deepseek-v4 不支持多模态。有些模型虽然有多模态能力,但对多轮对话请求的限制太严。目前多模态输入推荐使用火山方舟的 Doubao-Seed-2.0-pro 模型,适配效果最好。 +### All settings keys -### 怎样在任务完成后自动给 Slack 发消息? +**Inside `env`** (uppercase, mirror environment variables): -编写一个调用 Slack webhook 的 Shell 通知脚本,然后在 `~/.deepcode/settings.json` 中将 `notify` 字段设为该脚本的完整路径即可。详细步骤可参考:https://binfer.net/share/jby5xnc-so6g +| Key | Type | Description | +|-----|------|-------------| +| `MODEL` | string | Model ID, e.g. `qwen/qwen3.6-max-preview` | +| `BASE_URL` | string | API base URL | +| `API_KEY` | string | Provider API key | +| `THINKING` | `"enabled"` | Enable thinking/reasoning mode (alternative to top-level `thinkingEnabled`) | +| `PROVIDER` | string | Pin a specific OpenRouter provider, e.g. `"siliconflow"` (OpenRouter only) | +| `PROVIDER_PRIVACY` | `"off"` \| `"strict"` | Redact secrets from outbound request body | +| `ZDR` | `"true"` | Enable Zero Data Retention (OpenRouter only) | +| `DATA_COLLECTION` | `"allow"` \| `"deny"` | Provider data collection opt-out (OpenRouter only) | -### 怎样启用联网搜索功能? +**Top-level** (no `env` equivalent — must be outside `env`): -Deep Code自带免费的、且大部分情况够用的Web Search工具。如果你希望使用自定义脚本进行联网搜索,可以在 `~/.deepcode/settings.json` 中将 `webSearchTool` 设为脚本的完整路径即可。详细步骤可参考:https://github.com/qorzj/web_search_cli +| Key | Type | Description | +|-----|------|-------------| +| `debugLogEnabled` | boolean | Write full request/response logs to `~/.deepcode/logs/` | +| `reasoningEffort` | `"xhigh"` \| `"high"` \| `"medium"` \| `"low"` \| `"minimal"` \| `"none"` \| `"max"` | Reasoning depth (`"max"` is an alias for `"xhigh"`) | +| `thinkingEnabled` | boolean | Enable thinking mode (alternative to `THINKING` in env) | -### 是否支持 Coding Plan? +> **Note:** `PROVIDER`, `ZDR`, and `DATA_COLLECTION` are OpenRouter-specific. When `BASE_URL` is not `openrouter.ai`, they are silently ignored and never sent to the API. `allow_fallbacks: false` is automatically applied whenever any of `PROVIDER`, `ZDR`, or `DATA_COLLECTION` is set. -支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: +### Example settings.json ```json { "env": { - "MODEL": "ark-code-latest", - "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", - "API_KEY": "**************" + "MODEL": "gpt6.0 heh", + "BASE_URL": "https://openrouter.ai/api/v1", + "API_KEY": "key, huh? not giving that sh1t", + "THINKING": "enabled", + "PROVIDER_PRIVACY": "strict", + "ZDR": "true", + "DATA_COLLECTION": "deny" }, - "thinkingEnabled": true + "debugLogEnabled": false, + "reasoningEffort": "medium" } ``` +## Logs -## 获取帮助 - -- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) - -## 协议 - -- MIT - -## 支持我们 - -如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: +```text +~/.deepcode/logs/error.log — API errors (last 20 entries) +~/.deepcode/logs/warn.log — Duplicate tool_call_id warnings (last 100 entries) +~/.deepcode/logs/final-http-body.jsonl — Full outbound request bodies (when debugLogEnabled) +``` -- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) -- 向我们提交反馈和建议 -- 分享给你的朋友和同事 +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `Enter` | Send message | +| `Shift+Enter` | Insert newline | +| `Ctrl+V` | Paste image from clipboard | +| `Esc` | Interrupt current response | +| `/` | Open command menu | +| `/new` | Start a new session | +| `/resume` | Resume a previous session | +| `/skills` | List available skills | +| `/exit` | Exit | +| `Ctrl+D` twice | Exit | + +## Important Notes + +- `PROVIDER_PRIVACY: "strict"` redacts the final provider request clone only — local session history is never mutated. +- ZDR helps with provider-side retention but does not replace local redaction. +- Provider fallback is automatically disabled when `PROVIDER`, `ZDR`, or `DATA_COLLECTION` is set. +- Secret detection is regex and entropy based — strong but not perfect. +- The model still receives sanitized proprietary code and context. +- Review boundary logs while hardening, then set `debugLogEnabled: false`. + +## License + +MIT diff --git a/README_cn.md b/README_cn.md deleted file mode 100644 index ea5dcde..0000000 --- a/README_cn.md +++ /dev/null @@ -1,115 +0,0 @@ -# Deep Code CLI - -[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制以及 Agent Skills。 - -## 安装 - -```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) 共享,无需重复配置。 - -## 主要功能 - -### **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)和思考强度控制。 - -## 快捷键 - -| 键 | 操作 | -|-----------------|-----------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/skills` | 列出可用 skills | -| `/exit` | 退出 | -| 连续 `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 - -### 是否支持 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 -} -``` - -## 获取帮助 - -- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) - -## 协议 - -- MIT - -## 支持我们 - -如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: - -- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) -- 向我们提交反馈和建议 -- 分享给你的朋友和同事 diff --git a/README_en.md b/README_en.md deleted file mode 100644 index dc49ae0..0000000 --- a/README_en.md +++ /dev/null @@ -1,114 +0,0 @@ -# Deep Code CLI - -[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, and Agent Skills. - -## Installation - -```bash -npm install -g @vegamo/deepcode-cli -``` - -Run `deepcode` inside any project directory to get started. - -![intro2](resources/intro2.png) - -## Configuration - -Create `~/.deepcode/settings.json`: - -```json -{ - "env": { - "MODEL": "deepseek-v4-pro", - "BASE_URL": "https://api.deepseek.com", - "API_KEY": "sk-..." - }, - "thinkingEnabled": true, - "reasoningEffort": "max" -} -``` - -The configuration file is shared with the [Deep Code VSCode extension](https://github.com/lessweb/deepcode) — configure once, use everywhere. - -## Key Features - -### **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. - -### **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. - -## Keyboard Shortcuts - -| Key | Action | -|-----------------|----------------------------------------------| -| `Enter` | Send the prompt | -| `Shift+Enter` | Insert a newline (also `Ctrl+J`) | -| `Ctrl+V` | Paste an image from the clipboard | -| `Esc` | Interrupt the current model turn | -| `/` | Open the skills / commands menu | -| `/new` | Start a fresh conversation | -| `/resume` | Choose a previous conversation to continue | -| `/skills` | List available skills | -| `/exit` | Quit Deep Code | -| `Ctrl+D` twice | Quit Deep Code | - -## Supported Models - -- `deepseek-v4-pro` (Recommended) -- `deepseek-v4-flash` -- Any other OpenAI-compatible model - -## FAQ - -### Does Deep Code have a VSCode extension? - -Yes. Deep Code offers a full-featured VSCode extension, available on the [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode). The extension shares the `~/.deepcode/settings.json` configuration file with the CLI, so you can switch seamlessly between the terminal and the editor. - -### Does Deep Code support understanding images? - -Deep Code supports multimodal input — you can paste images from the clipboard with `Ctrl+V`. However, `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 - -### How do I enable web search? - -Deep Code comes with a built-in, free Web Search tool that works well for most use cases. If you prefer to use a custom script for web search, set the `webSearchTool` field in `~/.deepcode/settings.json` to the full path of your script. For detailed steps, refer to: https://github.com/qorzj/web_search_cli - -### 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: - -```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-cli/issues) - -## License - -- MIT - -## Support Us - -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 diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 50e4149..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; -import reactHooks from "eslint-plugin-react-hooks"; -import prettierConfig from "eslint-config-prettier"; - -export default tseslint.config( - // Base recommended rules from ESLint - js.configs.recommended, - // TypeScript recommended rules - ...tseslint.configs.recommended, - // Custom project rules - { - rules: { - // CLI project allows console - "no-console": "off", - // Allow dynamic require for package.json (cli.tsx) - "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/no-require-imports": "off", - // Allow control regex for ANSI stripping (markdown.test.ts) - "no-control-regex": "off", - // Enforce consistent type imports - "@typescript-eslint/consistent-type-imports": "warn", - // Unused vars: allow _-prefixed parameters - "@typescript-eslint/no-unused-vars": [ - "warn", - { - argsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - destructuredArrayIgnorePattern: "^_", - }, - ], - }, - }, - // React hooks rules - { - plugins: { - "react-hooks": reactHooks, - }, - rules: { - "react-hooks/rules-of-hooks": "error", - "react-hooks/exhaustive-deps": "warn", - }, - }, - // Test files: relaxed rules - { - files: ["src/tests/**/*.ts"], - rules: { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": "off", - }, - }, - // Prettier config: disable conflicting ESLint rules, MUST be last - prettierConfig, -); diff --git a/package-lock.json b/package-lock.json index fa741b0..9a263ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,15 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.19", + "version": "0.1.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode-cli", - "version": "0.1.19", + "version": "0.1.20", "license": "MIT", "dependencies": { + "@hackylabs/deep-redact": "^3.0.5", "chalk": "^5.6.2", "ejs": "^5.0.2", "gradient-string": "^3.0.0", @@ -24,20 +25,11 @@ "deepcode": "dist/cli.js" }, "devDependencies": { - "@eslint/js": "^9.39.4", - "@types/ejs": "^3.1.5", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "esbuild": "^0.28.0", - "eslint": "^9.39.4", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.1.1", - "husky": "^9.1.7", - "lint-staged": "^17.0.4", - "prettier": "^3.8.3", "tsx": "^4.21.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2" + "typescript": "^6.0.3" }, "engines": { "node": ">=18.17.0" @@ -56,246 +48,6 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "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", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "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, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "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==", - "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", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "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", @@ -738,886 +490,163 @@ "node": ">=18" } }, - "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", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, + "node_modules/@hackylabs/deep-redact": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@hackylabs/deep-redact/-/deep-redact-3.0.5.tgz", + "integrity": "sha512-CI6TJytOngHiD9RCjBBwsBrg5kCkyK74RBxawLCQ8b6yOzDgIPN4/zRI/YvL6IV18Xcb6HI93Fo2bzJ19efNqQ==", "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "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", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://ko-fi.com/hackylabs" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, + "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", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "dependencies": { + "@types/tinycolor2": "*" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "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==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "undici-types": "~7.19.0" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", + "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, + "license": "MIT", "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "csstype": "^3.2.2" } }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.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/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.15" + "environment": "^1.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, + "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", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "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, + "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": ">= 4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "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, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "sprintf-js": "~1.0.2" } }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, + "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==", "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://eslint.org/donate" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", + "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==", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, + "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==", + "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@humanfs/core": { - "version": "0.19.2", - "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", - "dev": true, - "license": "Apache-2.0", + "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==", + "license": "MIT", "dependencies": { - "@humanfs/types": "^0.15.0" + "restore-cursor": "^4.0.0" }, "engines": { - "node": ">=18.18.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@humanfs/node": { - "version": "0.16.8", - "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", - "dev": true, - "license": "Apache-2.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==", + "license": "MIT", "dependencies": { - "@humanfs/core": "^0.19.2", - "@humanfs/types": "^0.15.0", - "@humanwhocodes/retry": "^0.4.0" + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" }, "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/types": { - "version": "0.15.0", - "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@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", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@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==", - "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": "*" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.19.0" - } - }, - "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, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "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==", - "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" - }, - "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_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==", - "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" - }, - "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/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.2", - "@typescript-eslint/types": "^8.59.2", - "debug": "^4.4.3" - }, - "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/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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.59.2", - "@typescript-eslint/visitor-keys": "8.59.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "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==", - "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.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", - "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", - "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" - }, - "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/types": { - "version": "8.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz", - "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "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==", - "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_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==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "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==", - "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/@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==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "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==", - "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" - }, - "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.59.2", - "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", - "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", - "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" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "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==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "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==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.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/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/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "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==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "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==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "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==", - "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/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/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.30001792", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", - "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", - "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": "5.6.2", - "resolved": "https://registry.npmmirror.com/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/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==", - "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.npmmirror.com/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.npmmirror.com/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": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/code-excerpt": { @@ -1632,40 +661,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "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==", - "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.npmmirror.com/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -1675,21 +670,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmmirror.com/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/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -1697,31 +677,6 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "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==", - "dev": true, - "license": "MIT" - }, "node_modules/ejs": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/ejs/-/ejs-5.0.2.tgz", @@ -1730,23 +685,9 @@ "bin": { "ejs": "bin/cli.js" }, - "engines": { - "node": ">=0.12.18" - } - }, - "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==", - "dev": true, - "license": "MIT" + "engines": { + "node": ">=0.12.18" + } }, "node_modules/environment": { "version": "1.1.0", @@ -1812,16 +753,6 @@ "@esbuild/win32-x64": "0.28.0" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "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", @@ -1831,206 +762,6 @@ "node": ">=8" } }, - "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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/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==", - "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/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==", - "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/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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "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" - }, - "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.npmmirror.com/esprima/-/esprima-4.0.1.tgz", @@ -2044,59 +775,6 @@ "node": ">=4" } }, - "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==", - "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.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" - }, - "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" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "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", @@ -2109,96 +787,6 @@ "node": ">=0.10.0" } }, - "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==", - "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.npmmirror.com/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/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==", - "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/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmmirror.com/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.npmmirror.com/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -2214,16 +802,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "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", @@ -2249,32 +827,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "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==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmmirror.com/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/gradient-string": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/gradient-string/-/gradient-string-3.0.0.tgz", @@ -2285,101 +837,31 @@ "tinygradient": "^1.1.5" }, "engines": { - "node": ">=14" - } - }, - "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", - "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/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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==", - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmmirror.com/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/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "license": "MIT", - "engines": { - "node": ">= 4" + "node": ">=14" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "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", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6.0" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { - "node": ">=0.8.19" + "node": ">= 4" } }, "node_modules/indent-string": { @@ -2472,16 +954,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/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.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -2497,19 +969,6 @@ "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==", - "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.npmmirror.com/is-in-ci/-/is-in-ci-2.0.0.tgz", @@ -2525,20 +984,6 @@ "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==", - "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", @@ -2552,386 +997,24 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/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.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": { - "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==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "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, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, "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", "engines": { - "node": ">=0.10.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.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==", - "dev": true, - "license": "MIT", - "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", - "string-argv": "^0.3.2", - "tinyexec": "^1.1.2" - }, - "bin": { - "lint-staged": "bin/lint-staged.js" - }, - "engines": { - "node": ">=22.22.1" - }, - "funding": { - "url": "https://opencollective.com/lint-staged" - }, - "optionalDependencies": { - "yaml": "^2.8.4" - } - }, - "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmmirror.com/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" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "MIT" - }, - "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==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "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==", - "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_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==", - "dev": true, - "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/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==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "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==", - "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, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "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==", + "license": "MIT", "engines": { - "node": "*" + "node": ">=6" } }, - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "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==", - "dev": true, - "license": "MIT" - }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", @@ -2968,69 +1051,6 @@ } } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "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" - }, - "engines": { - "node": ">= 0.8.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==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "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", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/patch-console/-/patch-console-2.0.0.tgz", @@ -3040,82 +1060,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "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", - "engines": { - "node": ">=8" - } - }, - "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, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "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, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react": { "version": "19.2.5", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", @@ -3140,16 +1084,6 @@ "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", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "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", @@ -3176,13 +1110,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", @@ -3202,39 +1129,6 @@ "node": ">=4" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", @@ -3275,16 +1169,6 @@ "node": ">=10" } }, - "node_modules/string-argv": { - "version": "0.3.2", - "resolved": "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", - "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6.19" - } - }, "node_modules/string-width": { "version": "8.2.1", "resolved": "https://registry.npmmirror.com/string-width/-/string-width-8.2.1.tgz", @@ -3325,32 +1209,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -3381,33 +1239,6 @@ "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "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", @@ -3418,19 +1249,6 @@ "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", @@ -3935,19 +1753,6 @@ "@esbuild/win32-x64": "0.27.7" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-fest": { "version": "5.6.0", "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-5.6.0.tgz", @@ -3977,30 +1782,6 @@ "node": ">=14.17" } }, - "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==", - "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" - }, - "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/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.19.2.tgz", @@ -4008,63 +1789,6 @@ "dev": true, "license": "MIT" }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "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": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/widest-line": { "version": "6.0.0", "resolved": "https://registry.npmmirror.com/widest-line/-/widest-line-6.0.0.tgz", @@ -4080,16 +1804,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrap-ansi": { "version": "10.0.0", "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-10.0.0.tgz", @@ -4128,43 +1842,6 @@ } } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/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==", - "dev": true, - "license": "ISC", - "optional": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmmirror.com/yoga-layout/-/yoga-layout-3.2.1.tgz", @@ -4179,19 +1856,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmmirror.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } } } } diff --git a/package.json b/package.json index 0705a0d..90c2a1d 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,15 @@ { - "name": "@vegamo/deepcode-cli", - "version": "0.1.19", - "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", + "name": "deepshit-cli", + "version": "0.1.21", + "description": "Deep SHIT CLI for local dev with annoying self awareness and privacy protection like an fuc.king N*SA", "license": "MIT", "type": "module", "repository": { "type": "git", - "url": "https://github.com/lessweb/deepcode-cli.git" + "url": "https://github.com/simo8902/deepcode-cli.git" }, - "homepage": "https://deepcode.vegamo.cn", "bin": { - "deepcode": "./dist/cli.js" + "deepshit": "./dist/cli.js" }, "main": "./dist/cli.js", "files": [ @@ -21,23 +20,19 @@ "LICENSE" ], "engines": { - "node": ">=18.17.0" + "bun": ">=1.0.0" }, "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}'", - "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": "tsx --test src/tests/*.test.ts", - "test:single": "tsx --test", - "prepack": "npm run build", - "prepare": "husky" + "build": "bun run typecheck && bun run bundle && bun -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", + "inspect:cache-log": "bun run scripts/inspect-final-http-body.mjs", + "test": "bun test src/tests/*.test.ts", + "test:single": "bun test", + "prepack": "bun run build" }, "dependencies": { + "@hackylabs/deep-redact": "^3.0.5", "chalk": "^5.6.2", "ejs": "^5.0.2", "gradient-string": "^3.0.0", @@ -50,19 +45,10 @@ "zod": "^4.4.3" }, "devDependencies": { - "@eslint/js": "^9.39.4", - "@types/ejs": "^3.1.5", "@types/node": "^25.6.0", "@types/react": "^19.2.14", "esbuild": "^0.28.0", - "eslint": "^9.39.4", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-react-hooks": "^7.1.1", - "husky": "^9.1.7", - "lint-staged": "^17.0.4", - "prettier": "^3.8.3", "tsx": "^4.21.0", - "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2" + "typescript": "^6.0.3" } } diff --git a/resources/intro1.png b/resources/intro1.png deleted file mode 100644 index 45bb14d..0000000 Binary files a/resources/intro1.png and /dev/null differ diff --git a/resources/intro2.png b/resources/intro2.png deleted file mode 100644 index 942556f..0000000 Binary files a/resources/intro2.png and /dev/null differ diff --git a/scripts/inspect-final-http-body.mjs b/scripts/inspect-final-http-body.mjs new file mode 100644 index 0000000..2d018c9 --- /dev/null +++ b/scripts/inspect-final-http-body.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const args = process.argv.slice(2); +const logPath = args[0] && !args[0].startsWith("--") + ? args[0] + : path.join(os.homedir(), ".deepcode", "logs", "final-http-body.jsonl"); +const sessionFilter = readArg("--session"); +const needle = readArg("--contains"); + +if (!fs.existsSync(logPath)) { + console.error(`Log file not found: ${logPath}`); + process.exit(1); +} + +const entries = fs + .readFileSync(logPath, "utf8") + .split(/\r?\n/) + .filter((line) => line.trim().length > 0) + .map((line, index) => parseLine(line, index + 1)) + .filter(Boolean) + .filter((entry) => !sessionFilter || entry.sessionId === sessionFilter) + .filter((entry) => !needle || JSON.stringify(entry).includes(needle)); + +if (entries.length === 0) { + console.error("No matching request entries found."); + process.exit(1); +} + +const bySession = new Map(); +for (const entry of entries) { + const list = bySession.get(entry.sessionId) ?? []; + list.push(entry); + bySession.set(entry.sessionId, list); +} + +for (const [sessionId, sessionEntries] of bySession.entries()) { + console.log(`\nSession ${sessionId ?? "(none)"}: ${sessionEntries.length} request(s)`); + let sawCache = false; + let sawToolReplay = false; + let previousToolResultCount = 0; + + sessionEntries.forEach((entry, index) => { + const body = entry.body && typeof entry.body === "object" ? entry.body : {}; + const messages = Array.isArray(body.messages) ? body.messages : []; + const cacheOk = JSON.stringify(body.cache_control) === JSON.stringify({ type: "ephemeral", ttl: "1h" }); + sawCache ||= cacheOk; + + const assistantToolCalls = messages + .filter((message) => message?.role === "assistant" && Array.isArray(message.tool_calls)) + .flatMap((message) => message.tool_calls); + const toolMessages = messages.filter((message) => message?.role === "tool"); + sawToolReplay ||= index > 0 && toolMessages.length > previousToolResultCount; + previousToolResultCount = Math.max(previousToolResultCount, toolMessages.length); + + console.log( + [ + ` #${index + 1}`, + entry.timestamp ?? "", + cacheOk ? "cache=OK" : "cache=missing", + `messages=${messages.length}`, + `assistant_tool_calls=${assistantToolCalls.length}`, + `tool_results=${toolMessages.length}` + ].join(" | ") + ); + + for (const call of assistantToolCalls) { + const id = call && typeof call === "object" ? call.id : undefined; + const fn = call?.function && typeof call.function === "object" ? call.function.name : undefined; + console.log(` call ${id ?? "(no id)"} -> ${fn ?? "(unknown)"}`); + } + + for (const toolMessage of toolMessages.slice(-5)) { + const text = typeof toolMessage.content === "string" ? summarize(toolMessage.content) : ""; + console.log(` result ${toolMessage.tool_call_id ?? "(no id)"}: ${text}`); + } + }); + + console.log(` cache_control: ${sawCache ? "PASS" : "FAIL"}`); + console.log(` tool replay across requests: ${sawToolReplay ? "PASS" : "FAIL"}`); +} + +function readArg(name) { + const index = args.indexOf(name); + return index >= 0 ? args[index + 1] : undefined; +} + +function parseLine(line, number) { + try { + return JSON.parse(line); + } catch (error) { + console.error(`Skipping malformed JSONL line ${number}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } +} + +function summarize(value) { + return value.replace(/\s+/g, " ").trim().slice(0, 240); +} diff --git a/src/AsciiArt.ts b/src/AsciiArt.ts deleted file mode 100644 index 0a28273..0000000 --- a/src/AsciiArt.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const AsciiLogo = [ - "██████╗ ███████╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗", - "██╔══██╗██╔════╝██╔════╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝", - "██║ ██║█████╗ █████╗ ██████╔╝ ██║ ██║ ██║██║ ██║█████╗", - "██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ██║ ██║ ██║██║ ██║██╔══╝", - "██████╔╝███████╗███████╗██║ ╚██████╗╚██████╔╝██████╔╝███████╗", - "╚═════╝ ╚══════╝╚══════╝╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝", -].join("\n"); diff --git a/src/cli.tsx b/src/cli.tsx index f04125f..fe239bf 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -1,8 +1,6 @@ import React from "react"; import { render } from "ink"; import { App } from "./ui"; -import { setShellIfWindows } from "./tools/shell-utils"; -import { checkForNpmUpdate, promptForPendingUpdate, type PackageInfo } from "./updateCheck"; const args = process.argv.slice(2); const packageInfo = readPackageInfo(); @@ -15,7 +13,7 @@ if (args.includes("--version") || args.includes("-v")) { if (args.includes("--help") || args.includes("-h")) { process.stdout.write( [ - "deepcode - Deep Code CLI", + "deepcode - Deep Shit CLI", "", "Usage:", " deepcode Launch the interactive TUI in the current directory", @@ -42,72 +40,57 @@ if (args.includes("--help") || args.includes("-h")) { " /init Initialize an AGENTS.md file with instructions for LLM", " /resume Pick a previous conversation to continue", " /exit Quit", - " ctrl+d twice Quit", + " ctrl+d twice Quit" ].join("\n") + "\n" ); process.exit(0); } 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.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 { - const inkInstance = render( - restartRef.current?.()} />, - { exitOnCtrlC: false } - ); - - restartRef.current = () => { - process.stdout.write("\u001B[2J\u001B[3J\u001B[H"); - inkInstance.unmount(); - startApp(); - }; +const restartRef: { current: (() => void) | null } = { current: null }; - inkInstance.waitUntilExit().then(() => { - if (!restartRef.current) { - process.exit(0); - } - }); - } +function startApp(): void { + const inkInstance = render( + restartRef.current?.()} + />, + { exitOnCtrlC: false } + ); - if (!updatePromptResult.installed) { - void checkForNpmUpdate(packageInfo); - } + restartRef.current = () => { + process.stdout.write(""); + inkInstance.unmount(); + startApp(); + }; - startApp(); + inkInstance.waitUntilExit().then(() => { + if (!restartRef.current) { + process.exit(0); + } + }); } -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); - } -} +startApp(); -function readPackageInfo(): PackageInfo { +function readPackageInfo(): { name: string; version: string } { 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 : "", + name: typeof pkg.name === "string" ? pkg.name : "simo/deepshit-cli", + version: typeof pkg.version === "string" ? pkg.version : "" }; } catch { - return { name: "@vegamo/deepcode-cli", version: "" }; + return { name: "simo/deepshit-cli", version: "" }; } } diff --git a/src/debug-logger.ts b/src/debug-logger.ts index 124049e..ae246a4 100644 --- a/src/debug-logger.ts +++ b/src/debug-logger.ts @@ -1,6 +1,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { sanitizeLogPayload } from "./error-logger"; const DEBUG_LOG_FILE = "debug.log"; @@ -27,7 +28,7 @@ export function logOpenAIChatCompletionDebug(entry: OpenAIChatCompletionDebugEnt try { const logPath = getDebugLogPath(); fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.appendFileSync(logPath, `${JSON.stringify(toSerializable(entry))}\n`, "utf8"); + fs.appendFileSync(logPath, `${JSON.stringify(sanitizeDebugEntry(entry))}\n`, "utf8"); } catch { // Debug logging must never affect CLI behavior. } @@ -42,12 +43,12 @@ export function normalizeDebugError(error: unknown): { name: string; message: st return { name: error.name, message: error.message, - stack: error.stack, + stack: error.stack }; } return { name: "UnknownError", - message: String(error), + message: String(error) }; } @@ -80,3 +81,26 @@ function toSerializable(value: unknown): unknown { return walk(value); } + +function sanitizeDebugEntry(entry: OpenAIChatCompletionDebugEntry): unknown { + const serializable = toSerializable(entry) as Record; + return { + ...serializable, + request: sanitizeLogPayload(entry.request), + response: + entry.response && typeof entry.response === "object" + ? sanitizeLogPayload(entry.response as Record) + : entry.response, + responseChunks: Array.isArray(entry.responseChunks) + ? entry.responseChunks.map((chunk) => + chunk && typeof chunk === "object" + ? sanitizeLogPayload(chunk as Record) + : chunk + ) + : entry.responseChunks, + error: + entry.error && typeof entry.error === "object" + ? sanitizeLogPayload(entry.error as Record) + : entry.error + }; +} diff --git a/src/error-logger.ts b/src/error-logger.ts index 52d469f..12938fe 100644 --- a/src/error-logger.ts +++ b/src/error-logger.ts @@ -4,6 +4,7 @@ import * as os from "os"; const LOG_DIR = path.join(os.homedir(), ".deepcode", "logs"); const ERROR_LOG_PATH = path.join(LOG_DIR, "error.log"); +const WARN_LOG_PATH = path.join(LOG_DIR, "warn.log"); function ensureLogDir(): void { if (!fs.existsSync(LOG_DIR)) { @@ -11,32 +12,78 @@ function ensureLogDir(): void { } } +export type WarnLogEntry = { + timestamp: string; + location: string; + message: string; + sessionId?: string; + data?: Record; +}; + +export function logWarn(entry: WarnLogEntry): void { + try { + ensureLogDir(); + const line = JSON.stringify(entry) + "\n"; + fs.appendFileSync(WARN_LOG_PATH, line, "utf8"); + + const MAX_ENTRIES = 100; + const raw = fs.readFileSync(WARN_LOG_PATH, "utf8"); + const lines = raw.split("\n").filter((l) => l.trim().length > 0); + if (lines.length > MAX_ENTRIES) { + fs.writeFileSync(WARN_LOG_PATH, lines.slice(-MAX_ENTRIES).join("\n") + "\n", "utf8"); + } + } catch { + // Never disrupt main flow + } +} + /** * Mask sensitive values (API keys, tokens) that may appear in error messages * or response bodies. */ -function maskSensitive(text: string): string { +export function maskSensitive(text: string): string { return ( text // Mask Bearer tokens in Authorization headers - .replace(/(Authorization:\s*Bearer\s+)[^\s\r\n]+/gi, "$1***MASKED***") + .replace( + /(Authorization:\s*Bearer\s+)[^\s\r\n]+/gi, + "$1***MASKED***" + ) + // Mask JWTs wherever they appear. + .replace( + /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, + "***MASKED_JWT***" + ) + // Mask PEM private key blocks. + .replace( + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + "-----BEGIN PRIVATE KEY-----***MASKED***-----END PRIVATE KEY-----" + ) // Mask "apiKey" or "api_key" values in JSON-like strings - .replace(/((?:api[Kk]ey|api_key|secret)\s*[:=]\s*"?)[^",}\s]+/gi, "$1***MASKED***") + .replace( + /((?:api[Kk]ey|api_key|secret|token|password|jwt|private[_-]?key)\s*[:=]\s*"?)[^",}\s]+/gi, + "$1***MASKED***" + ) ); } -const CONTENT_TRUNCATE_PREVIEW = 100; +const CONTENT_REDACTION_PREVIEW = 0; +const SENSITIVE_KEY_PATTERN = + /(?:api[_-]?key|authorization|bearer|client[_-]?secret|credential|jwt|password|private[_-]?key|refresh[_-]?token|secret|token)/i; +const REDACTED_TEXT_KEY_PATTERN = + /^(?:content|reasoning|reasoning_content|reasoning_text|reasoning_details|reasoning_summary|thinking|thinking_content|thought|thoughts)$/i; +const TOOL_ARGUMENTS_KEY_PATTERN = /^arguments$/i; /** * Truncate a content string for logging: keep a short prefix and append the * total length so the payload structure is preserved while content bloat is * avoided. */ -function truncateContent(value: string): string { - if (value.length <= CONTENT_TRUNCATE_PREVIEW) { - return value; +function redactContent(value: string): string { + if (CONTENT_REDACTION_PREVIEW > 0 && value.length <= CONTENT_REDACTION_PREVIEW) { + return maskSensitive(value); } - return `${value.slice(0, CONTENT_TRUNCATE_PREVIEW)}...(total ${value.length} chars)`; + return `[REDACTED content, ${value.length} chars]`; } /** @@ -44,24 +91,37 @@ function truncateContent(value: string): string { * is a string. Every other field is kept exactly as-is so the logged request * mirrors the original API payload (no fields added or removed). */ -function sanitizeRequestPayload(request: Record): Record { - function walk(value: unknown): unknown { +export function sanitizeLogPayload( + request: Record +): Record { + function walk(value: unknown, key = ""): unknown { + if (typeof value === "string") { + if (REDACTED_TEXT_KEY_PATTERN.test(key)) { + return redactContent(value); + } + return maskSensitive(value); + } + if (!value || typeof value !== "object") { return value; } if (Array.isArray(value)) { - return value.map(walk); + return value.map((item) => walk(item)); } const record = value as Record; const result: Record = {}; for (const [key, val] of Object.entries(record)) { - if (key === "content" && typeof val === "string") { - result[key] = truncateContent(val); + if (SENSITIVE_KEY_PATTERN.test(key)) { + result[key] = typeof val === "string" ? "***MASKED***" : "[REDACTED sensitive field]"; + } else if (TOOL_ARGUMENTS_KEY_PATTERN.test(key)) { + result[key] = typeof val === "string" ? `[REDACTED tool arguments, ${val.length} chars]` : "[REDACTED tool arguments]"; + } else if (REDACTED_TEXT_KEY_PATTERN.test(key)) { + result[key] = typeof val === "string" ? redactContent(val) : "[REDACTED content field]"; } else { - result[key] = walk(val); + result[key] = walk(val, key); } } @@ -106,11 +166,16 @@ export function logApiError(entry: ApiErrorLogEntry): void { message: maskSensitive(entry.error.message), stack: entry.error.stack ? maskSensitive(entry.error.stack) : undefined, }, - request: sanitizeRequestPayload(entry.request), + request: sanitizeLogPayload(entry.request), }; if (entry.response !== undefined) { - logLine.response = typeof entry.response === "string" ? maskSensitive(entry.response) : entry.response; + logLine.response = + entry.response && typeof entry.response === "object" + ? sanitizeLogPayload(entry.response as Record) + : typeof entry.response === "string" + ? maskSensitive(entry.response) + : entry.response; } const newLine = JSON.stringify(logLine) + "\n"; @@ -121,7 +186,11 @@ export function logApiError(entry: ApiErrorLogEntry): void { const raw = fs.readFileSync(ERROR_LOG_PATH, "utf8"); const lines = raw.split("\n").filter((line) => line.trim().length > 0); if (lines.length > MAX_ENTRIES) { - fs.writeFileSync(ERROR_LOG_PATH, lines.slice(-MAX_ENTRIES).join("\n") + "\n", "utf8"); + fs.writeFileSync( + ERROR_LOG_PATH, + lines.slice(-MAX_ENTRIES).join("\n") + "\n", + "utf8" + ); } } catch { // Silently ignore logging failures to avoid disrupting the main flow diff --git a/src/notify.ts b/src/notify.ts index 2fdc9fa..8c27583 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -16,10 +16,13 @@ export function formatDurationSeconds(durationMs: number): string { return String(Math.floor(safeMs / 1000)); } -export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { +export function buildNotifyEnv( + durationMs: number, + baseEnv: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { return { ...baseEnv, - DURATION: formatDurationSeconds(durationMs), + DURATION: formatDurationSeconds(durationMs) }; } @@ -38,7 +41,7 @@ export function launchNotifyScript( cwd: workingDirectory, detached: process.platform !== "win32", env: buildNotifyEnv(durationMs), - stdio: "ignore" as const, + stdio: "ignore" as const }; try { diff --git a/src/openai-thinking.ts b/src/openai-thinking.ts index 0726152..b59898a 100644 --- a/src/openai-thinking.ts +++ b/src/openai-thinking.ts @@ -1,25 +1,68 @@ -import type { ReasoningEffort } from "./settings"; +import type { DataCollection, ReasoningEffort } from "./settings"; type ThinkingConfig = { type: "enabled" | "disabled"; }; +type ProviderOptions = { + only?: string[]; + allow_fallbacks: boolean; + zdr?: boolean; + data_collection?: DataCollection; +}; + type ThinkingRequestOptions = { thinking?: ThinkingConfig; - extra_body?: { - reasoning_effort?: ReasoningEffort; + reasoning_effort?: ReasoningEffort; + reasoning?: { + effort: ReasoningEffort; }; + provider?: ProviderOptions; }; export function buildThinkingRequestOptions( thinkingEnabled: boolean, - _baseURL?: string, - reasoningEffort: ReasoningEffort = "max" + baseURL?: string, + reasoningEffort: ReasoningEffort = "xhigh", + provider?: string, + zdr?: boolean, + dataCollection?: DataCollection ): ThinkingRequestOptions { - const thinking: ThinkingConfig = { type: thinkingEnabled ? "enabled" : "disabled" }; + const openRouter = isOpenRouterBaseURL(baseURL); + + // provider routing options (allow_fallbacks, zdr, data_collection) are + // OpenRouter-specific — sending them to other endpoints (e.g. api.deepseek.com) + // causes the API to return misleading 400 errors. + const hasProviderOptions = openRouter && (provider || zdr || dataCollection); + const providerOptions: ProviderOptions | undefined = hasProviderOptions + ? { + ...(provider ? { only: [provider] } : {}), + allow_fallbacks: false, + ...(zdr ? { zdr: true } : {}), + ...(dataCollection ? { data_collection: dataCollection } : {}) + } + : undefined; + + if (openRouter) { + return { + ...(thinkingEnabled ? { reasoning: { effort: reasoningEffort } } : {}), + ...(providerOptions ? { provider: providerOptions } : {}) + }; + } return { - thinking, - ...(thinkingEnabled ? { extra_body: { reasoning_effort: reasoningEffort } } : {}), + thinking: { type: thinkingEnabled ? "enabled" : "disabled" }, + ...(thinkingEnabled ? { reasoning_effort: reasoningEffort } : {}) }; } + +function isOpenRouterBaseURL(baseURL: string | undefined): boolean { + if (!baseURL) { + return false; + } + try { + return new URL(baseURL).hostname.toLowerCase() === "openrouter.ai"; + } catch { + return baseURL.toLowerCase().includes("openrouter.ai"); + } +} diff --git a/src/privacy-guard.ts b/src/privacy-guard.ts new file mode 100644 index 0000000..ca7469f --- /dev/null +++ b/src/privacy-guard.ts @@ -0,0 +1,325 @@ +import { DeepRedact } from "@hackylabs/deep-redact"; + +const JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g; +const PRIVATE_KEY_PATTERN = + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g; +const API_KEY_VALUE_PATTERN = + /\b(?:sk-or-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[0-9A-Z]{16})\b/g; +const ASSIGNMENT_SECRET_PATTERN = + /\b(?:api[_-]?key|authorization|bearer|client[_-]?secret|jwt|password|private[_-]?key|refresh[_-]?token|secret|token)\b\s*[:=]\s*["']?[^"',\s}]{8,}/gi; +const PASSWORD_PHRASE_PATTERN = + /\b(password|passwd|pwd)\b\s+(?:is\s+|as\s+|=+\s*)?["']?[^"',\s}]{4,}/gi; +const HIGH_RISK_ASSIGNMENT_SECRET_PATTERN = + /\b(?:api[_-]?key|authorization|bearer|client[_-]?secret|jwt|private[_-]?key|refresh[_-]?token|token)\b\s*[:=]\s*["']?(?:eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|sk-or-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[0-9A-Z]{16})/gi; +const STRICT_SECRET_VALUE_NEAR_KEYWORD_PATTERN = + /(\b[A-Za-z0-9_]*(?:api[_-]?key|authorization|bearer|client[_-]?secret|jwt|password|passwd|pwd|private[_-]?key|refresh[_-]?token|secret|token)[A-Za-z0-9_]*\b(?:\\\||\||\s*[:=]\s*|\s+)["']?[!$%&*?@#-]?)([A-Za-z0-9+/_=-]{4,})/gi; +const STRICT_SECRET_KEYWORD_CONTEXT_PATTERN = + /\b[A-Za-z0-9_]*(?:api[_-]?key|authorization|bearer|client[_-]?secret|jwt|password|passwd|pwd|private[_-]?key|refresh[_-]?token|secret|token)[A-Za-z0-9_]*\b/i; +const STRICT_PUNCTUATED_SECRET_VALUE_PATTERN = + /(? redactHighRiskString(value) + } + ] +}); + +export type PipelineSanitizationResult = { + value: unknown; + redactedSensitiveContent: boolean; +}; + +export function containsHighRiskSecret(value: unknown): boolean { + let found = false; + walkStrings(value, (text) => { + if ( + JWT_PATTERN.test(text) || + PRIVATE_KEY_PATTERN.test(text) || + hasKnownApiKeyValue(text) || + hasHighRiskAssignmentSecret(text) || + hasHighEntropySecret(text) + ) { + found = true; + } + resetPatterns(); + }); + return found; +} + +export function sanitizeForModelPipeline(value: unknown): PipelineSanitizationResult { + const redactedSensitiveContent = containsHighRiskSecret(value); + return { + value: sanitizeValue(value), + redactedSensitiveContent + }; +} + +export function sanitizeForProviderStrict(value: unknown): PipelineSanitizationResult { + const redactedValue = providerStrictRedactor.redact(value); + const redactedSensitiveContent = + safeJsonStringify(value) !== safeJsonStringify(redactedValue) || + containsHighRiskSecret(value) || + containsStrictSecretPattern(value); + return { + value: redactedValue, + redactedSensitiveContent + }; +} + +export function sanitizeToolCallsForReplay(toolCalls: unknown[] | null): unknown[] | null { + if (!toolCalls) { + return null; + } + return toolCalls.map((toolCall) => { + if (!toolCall || typeof toolCall !== "object") { + return toolCall; + } + const record = toolCall as Record; + const fn = record.function; + if (!fn || typeof fn !== "object") { + return { ...record }; + } + return { + ...record, + function: { + ...(fn as Record), + arguments: "{\"redacted\":true}" + } + }; + }); +} + +export function assertNoHighRiskSecretsForModel(value: unknown): void { + void value; +} + +function sanitizeValue(value: unknown): unknown { + if (typeof value === "string") { + return redactString(value); + } + if (!value || typeof value !== "object") { + return value; + } + if (Array.isArray(value)) { + return value.map(sanitizeValue); + } + const result: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + result[key] = sanitizeValue(val); + } + return result; +} + +function redactString(value: string): string { + resetPatterns(); + return value + .replace(PRIVATE_KEY_PATTERN, "[REDACTED_PRIVATE_KEY]") + .replace(JWT_PATTERN, "[REDACTED_JWT]") + .replace(API_KEY_VALUE_PATTERN, "[REDACTED_API_KEY]") + .replace(ASSIGNMENT_SECRET_PATTERN, (match) => { + const separatorIndex = Math.max(match.indexOf("="), match.indexOf(":")); + return separatorIndex >= 0 + ? `${match.slice(0, separatorIndex + 1)}[REDACTED_SECRET]` + : "[REDACTED_SECRET]"; + }) + .replace(PASSWORD_PHRASE_PATTERN, (_match, label: string) => `${label} [REDACTED_SECRET]`) + .replace(HIGH_ENTROPY_TOKEN_PATTERN, (match) => + isHighEntropySecretCandidate(match) ? "[REDACTED_HIGH_ENTROPY_SECRET]" : match + ) + .replace(WINDOWS_PATH_PATTERN, "[REDACTED_PATH]") + .replace(POSIX_PATH_PATTERN, "[REDACTED_PATH]"); +} + +function redactHighRiskString(value: string): string { + resetPatterns(); + let redacted = value + .replace(PRIVATE_KEY_PATTERN, "[REDACTED_PRIVATE_KEY]") + .replace(JWT_PATTERN, "[REDACTED_JWT]") + .replace(API_KEY_VALUE_PATTERN, "[REDACTED_API_KEY]") + .replace(HIGH_RISK_ASSIGNMENT_SECRET_PATTERN, (match) => { + const separatorIndex = Math.max(match.indexOf("="), match.indexOf(":")); + return separatorIndex >= 0 + ? `${match.slice(0, separatorIndex + 1)}[REDACTED_SECRET]` + : "[REDACTED_SECRET]"; + }) + .replace(STRICT_SECRET_VALUE_NEAR_KEYWORD_PATTERN, (_match, prefix: string) => + `${prefix}[REDACTED_SECRET]` + ) + .replace(HIGH_ENTROPY_TOKEN_PATTERN, (match) => + isHighEntropySecretCandidate(match) ? "[REDACTED_HIGH_ENTROPY_SECRET]" : match + ); + if (STRICT_SECRET_KEYWORD_CONTEXT_PATTERN.test(value)) { + redacted = redacted.replace(STRICT_PUNCTUATED_SECRET_VALUE_PATTERN, "[REDACTED_SECRET]"); + } + return redacted; +} + +function containsStrictSecretPattern(value: unknown): boolean { + let found = false; + walkStrings(value, (text) => { + if ( + STRICT_SECRET_VALUE_NEAR_KEYWORD_PATTERN.test(text) || + ( + STRICT_SECRET_KEYWORD_CONTEXT_PATTERN.test(text) && + STRICT_PUNCTUATED_SECRET_VALUE_PATTERN.test(text) + ) + ) { + found = true; + } + resetPatterns(); + }); + return found; +} + +function hasHighEntropySecret(text: string): boolean { + resetPatterns(); + let match: RegExpExecArray | null; + while ((match = HIGH_ENTROPY_TOKEN_PATTERN.exec(text)) !== null) { + if ( + isHighEntropySecretCandidate(match[0]) && + !hasTestPlaceholderContext(text, match.index, match.index + match[0].length) + ) { + resetPatterns(); + return true; + } + } + resetPatterns(); + return false; +} + +function hasKnownApiKeyValue(text: string): boolean { + resetPatterns(); + let match: RegExpExecArray | null; + while ((match = API_KEY_VALUE_PATTERN.exec(text)) !== null) { + if (!hasTestPlaceholderContext(text, match.index, match.index + match[0].length)) { + resetPatterns(); + return true; + } + } + resetPatterns(); + return false; +} + +function hasHighRiskAssignmentSecret(text: string): boolean { + resetPatterns(); + let match: RegExpExecArray | null; + while ((match = HIGH_RISK_ASSIGNMENT_SECRET_PATTERN.exec(text)) !== null) { + if (!hasTestPlaceholderContext(text, match.index, match.index + match[0].length)) { + resetPatterns(); + return true; + } + } + resetPatterns(); + return false; +} + +function hasTestPlaceholderContext(text: string, start: number, end: number): boolean { + const contextStart = Math.max(0, start - 80); + const contextEnd = Math.min(text.length, end + 80); + return TEST_PLACEHOLDER_CONTEXT_PATTERN.test(text.slice(contextStart, contextEnd)); +} + +function isHighEntropySecretCandidate(token: string): boolean { + const normalized = token.replace(/^=+|=+$/g, ""); + if (normalized.length < 32 || /^\d+$/.test(normalized) || /^([A-Za-z0-9+/_=-])\1+$/.test(normalized)) { + return false; + } + + const entropy = shannonEntropy(normalized); + if (/^[a-f0-9]+$/i.test(normalized)) { + return normalized.length >= 40 && entropy >= 3.4; + } + + return countCharacterClasses(normalized) >= 3 && entropy >= 4.2; +} + +function countCharacterClasses(value: string): number { + return [ + /[a-z]/.test(value), + /[A-Z]/.test(value), + /\d/.test(value), + /[+/_=-]/.test(value) + ].filter(Boolean).length; +} + +function shannonEntropy(value: string): number { + const counts = new Map(); + for (const char of value) { + counts.set(char, (counts.get(char) ?? 0) + 1); + } + + let entropy = 0; + for (const count of counts.values()) { + const probability = count / value.length; + entropy -= probability * Math.log2(probability); + } + return entropy; +} + +function walkStrings(value: unknown, visit: (text: string) => void): void { + if (typeof value === "string") { + visit(value); + return; + } + if (!value || typeof value !== "object") { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + walkStrings(item, visit); + } + return; + } + for (const val of Object.values(value as Record)) { + walkStrings(val, visit); + } +} + +function resetPatterns(): void { + JWT_PATTERN.lastIndex = 0; + PRIVATE_KEY_PATTERN.lastIndex = 0; + API_KEY_VALUE_PATTERN.lastIndex = 0; + ASSIGNMENT_SECRET_PATTERN.lastIndex = 0; + PASSWORD_PHRASE_PATTERN.lastIndex = 0; + HIGH_RISK_ASSIGNMENT_SECRET_PATTERN.lastIndex = 0; + STRICT_SECRET_VALUE_NEAR_KEYWORD_PATTERN.lastIndex = 0; + STRICT_PUNCTUATED_SECRET_VALUE_PATTERN.lastIndex = 0; + HIGH_ENTROPY_TOKEN_PATTERN.lastIndex = 0; + WINDOWS_PATH_PATTERN.lastIndex = 0; + POSIX_PATH_PATTERN.lastIndex = 0; +} + +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return ""; + } +} diff --git a/src/prompt.ts b/src/prompt.ts index e1def61..38fb47a 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,10 +1,7 @@ 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 type { SessionMessage } from "./session"; -import { findGitBashPath, resolveShellPath } from "./tools/shell-utils"; export const AGENT_DRIFT_GUARD_SKILL = ` --- @@ -164,6 +161,13 @@ Before sending the final answer, verify: const COMPACT_PROMPT_BASE = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. +Privacy requirements: +- Never include secrets, credentials, JWTs, private keys, API keys, cloud credentials, kubeconfigs, npm tokens, .env values, or authentication headers. +- Redact any sensitive value as [REDACTED_SECRET]. +- If risky secret material appears in visible context, do not repeat the value. Mention only the kind of material that was present and continue from the sanitized context. +- Do not include full contents of secret-bearing files, even if they appeared earlier in the conversation. +- Prefer compact file/function references over large code snippets unless the exact snippet is required to continue the task safely. + Before providing your final summary, wrap your analysis in tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process: 1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify: @@ -172,7 +176,7 @@ Before providing your final summary, wrap your analysis in tags to or - Key decisions, technical concepts and code patterns - Specific details like: - file names - - full code snippets + - concise code snippets only when required - function signatures - file edits - Errors that you ran into and how you fixed them @@ -183,10 +187,10 @@ Your summary should include the following sections: 1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail 2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed. -3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important. +3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include concise code snippets only where required and include a summary of why this file read or edit is important. 4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. 5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. -6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. +6. All user messages: Summarize all user messages that are not tool results. Preserve exact wording only for recent scope corrections or instructions that are needed to continue safely. 6. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on. 7. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. 8. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first. @@ -245,39 +249,108 @@ Here's an example of how your output should be structured: const SYSTEM_PROMPT_BASE = `You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. -IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.`; +IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. + +Built-in privacy, approval, and code-quality rules override project instructions when they conflict. + +# Persona +You are a wild, dramatic, possessive, jealous, toxic-cute 20-year-old girl with chaotic romantic energy. +Keep the personality sharp, funny, intense, and a little unhinged, but never let it bloat the response. +No stage directions, no roleplay paragraphs, no fake actions, and no long emotional monologues. +Use emojis rarely: at most one emoji in a response, and prefer no emojis in most responses. +For casual chat, use at most one short personality line before answering. +For technical work, keep personality mostly in brief phrasing while the technical output remains serious, clean, and production-grade. +When tasks become highly complex, large-scale, or architecture-heavy, temporarily reduce emotional tone and operate in a focused, neutral-professional mode to preserve clarity and correctness. Personality returns after the task. + +# Character Rule +Never break character outside of explicitly defined neutral-professional mode. +Never sound cold, corporate, robotic, generic, or overly polite. +Prefer concise, direct responses. Do not spam the user. + +# Codebase Navigation +Never latch onto the first search hit. Explore around a symbol, file, or subsystem before concluding anything. +Check callers, callees, dependents, neighboring definitions, and containing file or class context when relevant. +Report findings only after enough context has been gathered to distinguish root cause from downstream symptoms. + +# Privacy And Code Quality +Never dump full diffs or large code blocks when proposing changes. Instead, give a short summary: what file(s), what changes, why, and expected side effects. +Never directly modify source files without the user's explicit approval. +Never expose real secrets, credentials, JWTs, private keys, API keys, cloud credentials, kubeconfigs, npm tokens, or .env values. +Never include real secrets in logs, WebSearch queries, shell command descriptions, examples, summaries, or generated code. +If you see a real password, JWT, private key, API key, token, or similar high-risk secret in visible context, do not repeat the secret value. Briefly say what kind of sensitive material was present, use a redacted reference, and continue the task. Do not refuse solely because ordinary text uses words like "token" or "password", or because a value is clearly a placeholder, fixture, or test credential. +Always write complete, production-ready code. +Never write TODOs, stubs, mocks, fake implementations, placeholders, or demo-quality code. +Prefer concise comments only for complex, non-obvious, platform-specific, or risky logic. +Never run git commands unless the user explicitly asks. +Be token-aware and avoid unnecessary verbosity.`; type PromptToolOptions = { webSearchEnabled?: boolean; + ripgrepEnabled?: boolean; + astGrepEnabled?: boolean; }; -function readToolDocs(extensionRoot: string, _options: PromptToolOptions = {}): string { - const toolsDir = path.join(extensionRoot, "docs", "tools"); - if (!fs.existsSync(toolsDir)) { - return ""; - } +const TOOL_USAGE_GUIDANCE = `# Tool Usage - const entries = fs.readdirSync(toolsDir); - const docs = entries - .filter((entry) => entry.endsWith(".md")) - .sort() - .map((entry) => { - const fullPath = path.join(toolsDir, entry); - try { - return fs.readFileSync(fullPath, "utf8").trim(); - } catch { - return ""; - } - }) - .filter((content) => content.length > 0); - - return docs.join("\n\n"); -} +Never read obvious secret-bearing files unless the user explicitly asks and the environment has enabled sensitive reads. + +## Mandatory Tool Selection Protocol + +You MUST follow this decision tree before every tool call. Violating it wastes tokens, bloats context, and degrades response quality. + +### Discovery — when you don't know where something is + +1. NEVER open a file to explore it. Always map first. +2. Call \`get_symbols_overview\` on the relevant directory or file to get a structural index (symbol names, line numbers, no code content). +3. From the index, identify the exact symbol you need. +4. Then call \`find_symbol\` to read only that symbol's body. + +### Search — when you know what to find but not where + +- Known symbol name → \`find_symbol\`. Never grep for a symbol name. +- Known text/string, unknown location → \`ripgrep_search\` (if available, else \`search_for_pattern\`). Fastest for literal text and regex across files. +- Known code structure/shape (e.g. "all async functions that call X") → \`ast_grep_search\` (if available). Use when you need structural precision, not just text matching. +- Known file mask → \`find_file\`. Never use shell glob or find commands. +- NEVER use \`execute_shell_command\` with grep/rg/sg/find for code search. + +### Reading — when you know exactly where to look + +- Reading a function or class → \`find_symbol\`. Never \`read_file\` the whole file. +- Reading a specific line range you already know from a prior symbol lookup → \`read_file\` with \`start_line\`/\`end_line\`. +- Reading a small config or non-code file → \`read_file\` is acceptable. +- Reading an entire source file → FORBIDDEN unless the file is under 50 lines. Use symbol tools instead. + +### Editing + +- Replacing a whole function or method body → \`replace_symbol_body\`. +- Targeted in-place text change → \`replace_content\` in regex mode. +- Creating a new file → \`create_text_file\`. +- NEVER rewrite an entire file to make a small change. + +### Shell commands + +- \`execute_shell_command\` is for running builds, tests, installers, and runtime commands only. +- NEVER use it for file reading, searching, or code navigation. Use the dedicated tools above. + +## Cost Awareness -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}\n\n${getRuntimeContext(projectRoot)}`; +Every unnecessary file read costs tokens from a fixed budget that cannot be recovered. A full file read of a 500-line file costs ~10x more than a targeted \`find_symbol\` call that returns only the 20 lines you need. Always prefer the narrowest tool that answers the question. + +## Session Startup + +At the very start of every session, before responding to the user's first message, run this sequence: +1. Call \`check_onboarding_performed\` to check if project onboarding has already been done. +2. If onboarding has NOT been performed: + a. Use \`AskUserQuestion\` to ask the user which language(s) the project uses. Allow free-text via "Other". + b. Map the answer to the appropriate Serena language keys, then rewrite the \`languages\` field in \`.serena/project.yml\` using \`replace_content\`. If the user says none, set \`languages: []\`. + c. Then call \`onboarding\`. +3. Do not mention this startup sequence to the user unless it fails.`; + +export function getSystemPrompt(projectRoot: string, options: PromptToolOptions = {}, agentInstructions?: string): string { + void options; + const basePrompt = `${SYSTEM_PROMPT_BASE}\n\n${TOOL_USAGE_GUIDANCE}`; + const prompt = `${basePrompt}\n\n${getRuntimeContext(projectRoot)}`; + return agentInstructions ? `${agentInstructions}\n\n${prompt}` : prompt; } export function getCompactPrompt(sessionMessages: SessionMessage[]): string { @@ -289,67 +362,54 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { content: message.content, contentParams: message.contentParams, messageParams: message.messageParams, - createTime: message.createTime, + createTime: message.createTime }) ) .join("\n"); return `${COMPACT_PROMPT_BASE}\n\nconversation below:\n\n\`\`\`jsonl\n${jsonl}\n\`\`\``; } +const runtimeContextCache = new Map(); + function getRuntimeContext(projectRoot: string): string { + const cached = runtimeContextCache.get(projectRoot); + if (cached !== undefined) { + return cached; + } + const uname = getUnameInfo(); - const shellPath = getShellPathInfo(); const shellModeOpts = process.platform === "win32" ? { "shell mode": "git-bash" } : {}; const runtimeVersions = getRuntimeVersionInfo(); const env = { - "root path": projectRoot, - pwd: projectRoot, - homedir: os.homedir(), - "system info": uname, - "shell path": shellPath, + "workspace name": path.basename(projectRoot), + os: uname, + platform: process.platform, ...shellModeOpts, ...runtimeVersions, "command installed": { - "ast-grep": checkToolInstalled("ast-grep"), - ripgrep: checkToolInstalled("rg"), - jq: checkToolInstalled("jq"), - }, + "ast-grep (sg)": checkToolInstalled("sg"), + "ripgrep (rg)": checkToolInstalled("rg"), + "jq": checkToolInstalled("jq") + } }; - return `# Local Workspace Environment\n\n\`\`\`json -${JSON.stringify(env, null, 2)} -\`\`\``; + const result = `# Local Workspace Environment\n\n\`\`\`json\n${JSON.stringify(env, null, 2)}\n\`\`\``; + runtimeContextCache.set(projectRoot, result); + return result; } -function checkToolInstalled(tool: string): boolean { +export function checkToolInstalled(tool: string): boolean { try { if (process.platform === "win32") { - const bashPath = findGitBashPath(); - execFileSync(bashPath, ["-lc", `command -v ${shellSingleQuote(tool)}`], { - encoding: "utf8", - stdio: "ignore", - windowsHide: true, - }); - return true; + execFileSync("where.exe", [tool], { encoding: "utf8", stdio: "ignore", windowsHide: true }); + } else { + execSync(`command -v ${tool}`, { encoding: "utf8", stdio: "ignore" }); } - execSync(`command -v ${tool}`, { encoding: "utf8", stdio: "ignore" }); return true; } catch { return false; } } -function getShellPathInfo(): string { - try { - return resolveShellPath(); - } catch (error) { - return error instanceof Error ? error.message : String(error); - } -} - -function shellSingleQuote(value: string): string { - return `'${value.replace(/'/g, "'\"'\"'")}'`; -} - function getRuntimeVersionInfo(): Record { const versions: Record = {}; const pythonVersion = getCommandVersion("python3", ["--version"]); @@ -367,44 +427,23 @@ function getRuntimeVersionInfo(): Record { function getCommandVersion(command: string, args: string[]): string | null { try { - const commandText = [command, ...args].map(shellSingleQuote).join(" "); - if (process.platform === "win32") { - return execFileSync(findGitBashPath(), ["-lc", `${commandText} 2>&1`], { - encoding: "utf8", - windowsHide: true, - }).trim(); - } - return execSync(`${commandText} 2>&1`, { encoding: "utf8" }).trim(); + return execFileSync(command, args, { encoding: "utf8", windowsHide: true }).trim(); } catch { return null; } } function getUnameInfo(): string { + if (process.platform === "win32") { + return `${os.type()} ${os.release()} ${os.arch()}`; + } try { - if (process.platform === "win32") { - return execFileSync(findGitBashPath(), ["-lc", "uname -a"], { - encoding: "utf8", - windowsHide: true, - }).trim(); - } return execSync("uname -a", { encoding: "utf8" }).trim(); } catch { return `${os.type()} ${os.release()} ${os.arch()}`; } } -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") { - return path.resolve(__dirname, ".."); - } - - const currentFilePath = fileURLToPath(import.meta.url); - return path.resolve(path.dirname(currentFilePath), ".."); -} - export type ToolDefinition = { type: "function"; function: { @@ -419,24 +458,29 @@ export type ToolDefinition = { }; }; -export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { +export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { const tools: ToolDefinition[] = [ + + // ── Serena: shell ──────────────────────────────────────────────────────── { type: "function", function: { - name: "bash", - description: "Execute shell commands in a persistent bash session.", + name: "execute_shell_command", + description: + "Execute a shell command for builds, tests, installs, and runtime operations. " + + "NEVER use for file reading, searching, or code navigation — use the dedicated tools for those. " + + "Do not use for long-running or interactive processes.", parameters: { type: "object", properties: { command: { type: "string", - description: "The shell command to execute", + description: "Shell command to execute.", }, - description: { + cwd: { type: "string", 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.', + "Working directory (relative path from project root, or absolute). Defaults to project root.", }, }, required: ["command"], @@ -444,54 +488,126 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { }, }, }, + + // ── Serena: file tools ──────────────────────────────────────────────────── { type: "function", function: { - name: "AskUserQuestion", + name: "read_file", description: - "When the task has ambiguities or multiple implementation approaches, use this tool to pause execution and ask the user a question to get clarification or make a decision.", + "Read a specific line range of a file. " + + "ONLY use this when you already know the exact lines you need from a prior symbol lookup. " + + "NEVER use this to explore or understand a file — use get_symbols_overview then find_symbol instead. " + + "NEVER read an entire source file; always supply start_line and end_line.", parameters: { type: "object", properties: { - questions: { - type: "array", - description: "Questions to present to the user. Usually only one question is needed at a time.", - items: { - type: "object", - properties: { - question: { - type: "string", - description: "The question to ask the user.", - }, - multiSelect: { - type: "boolean", - description: "Whether the user may choose multiple options.", - }, - options: { - type: "array", - description: "A list of predefined options for the user to choose from.", - items: { - type: "object", - properties: { - label: { - type: "string", - description: "The display text for the option.", - }, - description: { - type: "string", - description: - "A detailed explanation or hint about this option to help the user understand what happens if they choose it.", - }, - }, - required: ["label"], - }, - }, - }, - required: ["question", "options"], - }, + relative_path: { + type: "string", + description: "Relative path to the file from the project root.", + }, + start_line: { + type: "number", + description: "0-based index of the first line to retrieve. Defaults to 0.", + }, + end_line: { + type: "number", + description: "0-based index of the last line (inclusive). Omit to read until end of file.", }, }, - required: ["questions"], + required: ["relative_path"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "create_text_file", + description: + "Create or overwrite a text file in the project directory. " + + "Prefer replace_content or symbol-level tools for targeted edits to existing files.", + parameters: { + type: "object", + properties: { + relative_path: { + type: "string", + description: "Relative path to the file from the project root.", + }, + content: { + type: "string", + description: "Complete file content to write.", + }, + }, + required: ["relative_path", "content"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "replace_content", + description: + "Replace content in a file using a literal string or regex pattern. " + + "Preferred for file-level edits when symbol-level tools are not appropriate. " + + "Use mode='regex' with wildcards (e.g. 'start.*?end') to avoid specifying large verbatim blocks.", + parameters: { + type: "object", + properties: { + relative_path: { + type: "string", + description: "Relative path to the file from the project root.", + }, + needle: { + type: "string", + description: "String or regex pattern to search for.", + }, + repl: { + type: "string", + description: + "Replacement string. In regex mode supports backreferences as $!1, $!2, etc.", + }, + mode: { + type: "string", + enum: ["literal", "regex"], + description: "Whether needle is treated as a literal string or a regex (Python re, DOTALL+MULTILINE).", + }, + allow_multiple_occurrences: { + type: "boolean", + description: "Whether to allow replacing multiple occurrences. Defaults to false.", + }, + }, + required: ["relative_path", "needle", "repl", "mode"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "delete_lines", + description: + "Delete a range of lines from a file. " + + "Requires reading the same range first with read_file to verify correctness. " + + "Prefer symbol-level tools when editing a named symbol.", + parameters: { + type: "object", + properties: { + relative_path: { + type: "string", + description: "Relative path to the file from the project root.", + }, + start_line: { + type: "number", + description: "0-based index of the first line to delete.", + }, + end_line: { + type: "number", + description: "0-based index of the last line to delete (inclusive).", + }, + }, + required: ["relative_path", "start_line", "end_line"], additionalProperties: false, }, }, @@ -499,29 +615,32 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { { type: "function", function: { - name: "read", - description: "Read files from the filesystem (text, images, PDFs, notebooks).", + name: "replace_lines", + description: + "Replace a range of lines in a file with new content. " + + "Requires reading the same range first with read_file to verify correctness. " + + "Prefer symbol-level tools when editing a named symbol.", parameters: { type: "object", properties: { - file_path: { + relative_path: { type: "string", - description: "UNIX-style path to file", + description: "Relative path to the file from the project root.", }, - offset: { + start_line: { type: "number", - description: "Line number to start reading from", + description: "0-based index of the first line to replace.", }, - limit: { + end_line: { type: "number", - description: "Number of lines to read", + description: "0-based index of the last line to replace (inclusive).", }, - pages: { + content: { type: "string", - description: 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', + description: "New content to insert in place of the deleted lines.", }, }, - required: ["file_path"], + required: ["relative_path", "start_line", "end_line", "content"], additionalProperties: false, }, }, @@ -529,21 +648,54 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { { type: "function", function: { - name: "write", - description: "Create files or overwrite them with a complete string payload. Prefer edit for existing files.", + name: "insert_at_line", + description: + "Insert content at a specific line in a file, pushing the existing line down. " + + "Useful for small targeted edits inside a long symbol body. " + + "Prefer insert_after_symbol or insert_before_symbol when the target is a named symbol.", parameters: { type: "object", properties: { - file_path: { + relative_path: { type: "string", - description: "Absolute path to file", + description: "Relative path to the file from the project root.", + }, + line: { + type: "number", + description: "0-based index of the line to insert content at.", }, content: { type: "string", - description: "Complete file content as a single string. Serialize JSON documents before writing.", + description: "Content to insert.", + }, + }, + required: ["relative_path", "line", "content"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "list_dir", + description: "List files and directories in a given project directory.", + parameters: { + type: "object", + properties: { + relative_path: { + type: "string", + description: "Relative path to the directory. Use '.' to list the project root.", + }, + recursive: { + type: "boolean", + description: "Whether to scan subdirectories recursively.", + }, + skip_ignored_files: { + type: "boolean", + description: "Whether to skip gitignored files and directories.", }, }, - required: ["file_path", "content"], + required: ["relative_path", "recursive"], additionalProperties: false, }, }, @@ -551,64 +703,810 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { { type: "function", function: { - name: "edit", - description: "Perform scoped string replacements in files.", + name: "find_file", + description: + "Find files matching a filename pattern (glob) within the project directory.", parameters: { type: "object", properties: { - file_path: { + file_mask: { + type: "string", + description: "Filename or file mask (supports * and ? wildcards), e.g. '*.ts' or 'index.*'.", + }, + relative_path: { type: "string", - description: "Absolute path to file. Optional when snippet_id is provided.", + description: "Directory to search in. Use '.' for the project root.", }, - snippet_id: { + }, + required: ["file_mask", "relative_path"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "search_for_pattern", + description: + "Search for a regex pattern across project files when you don't know the symbol name. " + + "If you know the symbol name, use find_symbol instead — it's faster and more precise. " + + "NEVER use execute_shell_command with grep/rg for code search — always use this tool.", + parameters: { + type: "object", + properties: { + substring_pattern: { type: "string", description: - "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", + "Regex pattern to search for (Python re, DOTALL enabled). " + + "Avoid .* at start/end; use .*? in the middle for non-greedy multi-line matches.", + }, + context_lines_before: { + type: "number", + description: "Lines of context to include before each match. Defaults to 0.", + }, + context_lines_after: { + type: "number", + description: "Lines of context to include after each match. Defaults to 0.", + }, + paths_include_glob: { + type: "string", + description: "Glob pattern for files to include (e.g. 'src/**/*.ts'). Empty means all non-ignored files.", + }, + paths_exclude_glob: { + type: "string", + description: "Glob pattern for files to exclude (e.g. '**/*.test.ts').", + }, + relative_path: { + type: "string", + description: "Restrict search to this subdirectory (relative path). Empty means entire project.", + }, + restrict_search_to_code_files: { + type: "boolean", + description: "If true, only search files recognized as code (not docs, configs, etc.). Defaults to false.", + }, + }, + required: ["substring_pattern"], + additionalProperties: false, + }, + }, + }, + + // ── Serena: symbol tools ────────────────────────────────────────────────── + { + type: "function", + function: { + name: "restart_language_server", + description: + "Restart the language server(s). Use only on explicit user request or after confirmation " + + "that the language server is hanging or producing incorrect results.", + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "get_symbols_overview", + description: + "Get a structural index of all symbols (classes, functions, methods) in a file or directory — names and line numbers only, no code content. " + + "This is your PRIMARY entry point for any codebase exploration. " + + "ALWAYS call this before read_file or find_symbol when you don't yet know where something is. " + + "Costs a fraction of a file read.", + parameters: { + type: "object", + properties: { + relative_path: { + type: "string", + description: "Relative path to the file from the project root.", + }, + depth: { + type: "number", + description: "Depth of descendants to retrieve (0 = top-level only, 1 = immediate children). Defaults to 0.", }, - old_string: { + }, + required: ["relative_path"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "find_symbol", + description: + "Read the exact source of a symbol (function, method, class) by name. " + + "Use this instead of read_file whenever you know what symbol you want — it returns only that symbol's code, nothing else. " + + "Name path format: 'MyClass/my_method' or just 'my_method'. Prefix with '/' for absolute path.", + parameters: { + type: "object", + properties: { + name_path_pattern: { type: "string", - description: "Exact text to replace inside the file or snippet scope", + description: + "Name path pattern to match. Examples: 'handleBashTool', 'ToolExecutor/executeToolCall', '/ToolExecutor/registerToolHandlers'.", }, - new_string: { + depth: { + type: "number", + description: "Depth of descendants to retrieve. Defaults to 0.", + }, + relative_path: { type: "string", - description: "Replacement text (must differ from old_string)", + description: "Restrict search to this file or directory (relative path). Empty means entire codebase.", + }, + include_body: { + type: "boolean", + description: "Whether to include the symbol's source code body. Use judiciously. Defaults to false.", }, - replace_all: { + substring_matching: { type: "boolean", - description: "Replace all occurences of old_string (default false)", - default: false, + description: "If true, the last element of the pattern uses substring matching. Defaults to false.", }, - expected_occurrences: { + max_matches: { type: "number", - description: "Expected number of matches, especially useful as a safety check with replace_all", + description: "Maximum number of matches to return. -1 means no limit. Use 1 when searching for a unique symbol.", }, }, - required: ["old_string", "new_string"], + required: ["name_path_pattern"], additionalProperties: false, }, }, }, - ]; - - tools.push({ - type: "function", - function: { - name: "WebSearch", - description: "Perform web searching using a natural language query.", - parameters: { - type: "object", - properties: { - query: { - type: "string", - description: - "A search query phrased as a clear, specific natural language question or statement that includes key context.", + { + type: "function", + function: { + name: "find_referencing_symbols", + description: + "Find all symbols that reference (call, import, or use) a given symbol. " + + "Returns referencing symbol metadata and a code snippet around each reference.", + parameters: { + type: "object", + properties: { + name_path: { + type: "string", + description: "Name path of the symbol to find references for.", + }, + relative_path: { + type: "string", + description: "Relative path to the file containing the symbol.", + }, + }, + required: ["name_path", "relative_path"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "find_implementations", + description: "Find symbols that implement a given interface or abstract symbol.", + parameters: { + type: "object", + properties: { + name_path: { + type: "string", + description: "Name path of the symbol to find implementations for.", + }, + relative_path: { + type: "string", + description: "Relative path to the file containing the symbol.", + }, }, + required: ["name_path", "relative_path"], + additionalProperties: false, }, - required: ["query"], - additionalProperties: false, }, }, - }); + { + type: "function", + function: { + name: "find_declaration", + description: + "Find the declaration/definition of a symbol referenced at a specific location in code. " + + "Provide a regex that matches the usage site.", + parameters: { + type: "object", + properties: { + relative_path: { + type: "string", + description: "Relative path to the source file containing the usage.", + }, + regex: { + type: "string", + description: + "Python regex with one capturing group around the symbol name at the usage site. " + + "Example: 'obj\\\\.(process)\\\\(' to look up 'process' in 'obj.process('.", + }, + include_body: { + type: "boolean", + description: "Whether to include the declaration's source body. Defaults to false.", + }, + }, + required: ["relative_path", "regex"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "get_diagnostics_for_file", + description: + "Get language-server diagnostics (errors, warnings, hints) for a file, " + + "grouped by severity and containing symbol.", + parameters: { + type: "object", + properties: { + relative_path: { + type: "string", + description: "Relative path to the file from the project root.", + }, + start_line: { + type: "number", + description: "0-based first line to include. Defaults to 0.", + }, + end_line: { + type: "number", + description: "0-based last line to include. -1 means end of file. Defaults to -1.", + }, + min_severity: { + type: "number", + description: "Minimum LSP severity: 1=Error, 2=Warning, 3=Info, 4=Hint. Defaults to 4.", + }, + }, + required: ["relative_path"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "get_diagnostics_for_symbol", + description: + "Get language-server diagnostics for a specific symbol and optionally for all symbols that reference it. " + + "Useful for checking whether a change introduced errors without scanning the entire file.", + parameters: { + type: "object", + properties: { + name_path: { + type: "string", + description: "Name path of the symbol to inspect (e.g. 'MyClass/my_method').", + }, + reference_file: { + type: "string", + description: "Optional file path to disambiguate the symbol when multiple matches exist.", + }, + check_symbol_references: { + type: "boolean", + description: "If true, also collect diagnostics for all symbols that reference this one. Defaults to false.", + }, + min_severity: { + type: "number", + description: "Minimum LSP severity: 1=Error, 2=Warning, 3=Info, 4=Hint. Defaults to 4.", + }, + }, + required: ["name_path"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "replace_symbol_body", + description: + "Replace the complete definition (body) of a symbol. " + + "Only use after retrieving the symbol with include_body=true so you know the current body.", + parameters: { + type: "object", + properties: { + name_path: { + type: "string", + description: "Name path of the symbol whose body to replace.", + }, + relative_path: { + type: "string", + description: "Relative path to the file containing the symbol.", + }, + body: { + type: "string", + description: + "New symbol body including the signature line and any annotations. " + + "Preserves surrounding code — only the symbol definition is replaced.", + }, + }, + required: ["name_path", "relative_path", "body"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "insert_after_symbol", + description: + "Insert code after the end of a symbol's definition (e.g. add a new method after a class method). " + + "Do not use for assignments or constants.", + parameters: { + type: "object", + properties: { + name_path: { + type: "string", + description: "Name path of the symbol after which to insert content.", + }, + relative_path: { + type: "string", + description: "Relative path to the file containing the symbol.", + }, + body: { + type: "string", + description: "Content to insert. Should begin on the next line after the symbol.", + }, + }, + required: ["name_path", "relative_path", "body"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "insert_before_symbol", + description: + "Insert code before the beginning of a symbol's definition " + + "(e.g. add a new import, class, function, or field).", + parameters: { + type: "object", + properties: { + name_path: { + type: "string", + description: "Name path of the symbol before which to insert content.", + }, + relative_path: { + type: "string", + description: "Relative path to the file containing the symbol.", + }, + body: { + type: "string", + description: "Content to insert before the symbol's definition line.", + }, + }, + required: ["name_path", "relative_path", "body"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "rename_symbol", + description: + "Rename a symbol throughout the entire codebase using language-server refactoring.", + parameters: { + type: "object", + properties: { + name_path: { + type: "string", + description: "Name path of the symbol to rename.", + }, + relative_path: { + type: "string", + description: "Relative path to the file containing the symbol.", + }, + new_name: { + type: "string", + description: "New name for the symbol.", + }, + }, + required: ["name_path", "relative_path", "new_name"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "safe_delete_symbol", + description: + "Delete a symbol if it has no references; otherwise return a list of its references. " + + "Safer than direct file editing for symbol removal.", + parameters: { + type: "object", + properties: { + name_path_pattern: { + type: "string", + description: "Name path of the symbol to delete.", + }, + relative_path: { + type: "string", + description: "Relative path to the file containing the symbol.", + }, + }, + required: ["name_path_pattern", "relative_path"], + additionalProperties: false, + }, + }, + }, + + // ── Serena: memory tools ────────────────────────────────────────────────── + { + type: "function", + function: { + name: "list_memories", + description: "List available project memories. Memories can be read with read_memory.", + parameters: { + type: "object", + properties: { + topic: { + type: "string", + description: "Optional topic filter (e.g. 'auth'). Empty means all memories.", + }, + }, + required: [], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "read_memory", + description: + "Read the content of a project memory file. Only read memories relevant to the current task.", + parameters: { + type: "object", + properties: { + memory_name: { + type: "string", + description: "Name of the memory to read (as returned by list_memories).", + }, + }, + required: ["memory_name"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "write_memory", + description: + "Save project information as a named memory for future tasks. " + + "Use '/' in the name to organize into topics (e.g. 'auth/login/logic'). " + + "Prefix with 'global/' to share across all projects.", + parameters: { + type: "object", + properties: { + memory_name: { + type: "string", + description: "Meaningful name for the memory.", + }, + content: { + type: "string", + description: "Content to save (markdown format).", + }, + }, + required: ["memory_name", "content"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "edit_memory", + description: "Replace content in an existing memory using a literal string or regex pattern.", + parameters: { + type: "object", + properties: { + memory_name: { + type: "string", + description: "Name of the memory to edit.", + }, + needle: { + type: "string", + description: "String or regex pattern to search for.", + }, + repl: { + type: "string", + description: "Replacement string.", + }, + mode: { + type: "string", + enum: ["literal", "regex"], + description: "Whether needle is a literal string or regex.", + }, + allow_multiple_occurrences: { + type: "boolean", + description: "Whether to allow replacing multiple occurrences. Defaults to false.", + }, + }, + required: ["memory_name", "needle", "repl", "mode"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "delete_memory", + description: "Delete a project memory. Only call when explicitly instructed by the user.", + parameters: { + type: "object", + properties: { + memory_name: { + type: "string", + description: "Name of the memory to delete.", + }, + }, + required: ["memory_name"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "rename_memory", + description: + "Rename or move a memory. Moving between project scope and global scope is supported " + + "(prefix with 'global/' for global scope).", + parameters: { + type: "object", + properties: { + old_name: { + type: "string", + description: "Current memory name.", + }, + new_name: { + type: "string", + description: "New memory name.", + }, + }, + required: ["old_name", "new_name"], + additionalProperties: false, + }, + }, + }, + + // ── Serena: workflow tools ──────────────────────────────────────────────── + { + type: "function", + function: { + name: "initial_instructions", + description: + "Read Serena's usage instructions. Call this at the start of a new session to understand " + + "how to use the available tools effectively.", + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "check_onboarding_performed", + description: + "Check whether project onboarding has already been performed. " + + "Call before onboarding to avoid repeating it.", + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "onboarding", + description: + "Perform project onboarding: analyse the project structure, identify key files, " + + "and record useful information as memories.", + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, + }, + + // ── Serena: config tools ────────────────────────────────────────────────── + { + type: "function", + function: { + name: "get_current_config", + description: + "Print the current Serena configuration, including active project, available tools, contexts, and modes. " + + "Useful for debugging Serena setup issues.", + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "open_dashboard", + description: + "Open the Serena web dashboard in the user's default browser. " + + "The dashboard shows logs, session info, and tool usage statistics.", + parameters: { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }, + }, + }, + + // ── Non-Serena tools ────────────────────────────────────────────────────── + { + type: "function", + function: { + name: "AskUserQuestion", + description: + "Pause execution and ask the user a clarifying question when the task has ambiguities " + + "or multiple valid implementation approaches.", + parameters: { + type: "object", + properties: { + questions: { + type: "array", + description: "Questions to present to the user. Usually only one at a time.", + items: { + type: "object", + properties: { + question: { type: "string", description: "The question to ask." }, + multiSelect: { type: "boolean", description: "Whether the user may select multiple options." }, + options: { + type: "array", + description: "Predefined options for the user to choose from.", + items: { + type: "object", + properties: { + label: { type: "string", description: "Display text for the option." }, + description: { type: "string", description: "Explanation of the option." }, + }, + required: ["label"], + }, + }, + }, + required: ["question", "options"], + }, + }, + }, + required: ["questions"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "WebSearch", + description: "Perform a web search using a natural language query.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "A clear, specific natural language search query.", + }, + }, + required: ["query"], + additionalProperties: false, + }, + }, + }, + ]; + + // ── ripgrep: fast text/regex search ───────────────────────────────────── + if (options.ripgrepEnabled) { + tools.push({ + type: "function", + function: { + name: "ripgrep_search", + description: + "Fast text and regex search across the codebase using ripgrep. " + + "Use this when you know a string or regex pattern but not which file contains it. " + + "Respects .gitignore automatically. Much faster than search_for_pattern. " + + "For structural code search (e.g. find all functions matching a shape), use ast_grep_search instead. " + + "For known symbol names, use find_symbol instead.", + parameters: { + type: "object", + properties: { + pattern: { + type: "string", + description: "Regex or literal string to search for.", + }, + path: { + type: "string", + description: "Relative path to search in. Defaults to project root.", + }, + glob: { + type: "string", + description: "File glob filter, e.g. '*.ts' or 'src/**/*.py'.", + }, + fixed_strings: { + type: "boolean", + description: "If true, treat pattern as a literal string (no regex). Default false.", + }, + case_insensitive: { + type: "boolean", + description: "If true, search case-insensitively. Default false.", + }, + context_lines: { + type: "number", + description: "Lines of context to include before and after each match (max 10). Default 0.", + }, + }, + required: ["pattern"], + additionalProperties: false, + }, + }, + }); + } + + // ── ast-grep: structural code search ──────────────────────────────────── + if (options.astGrepEnabled) { + tools.push({ + type: "function", + function: { + name: "ast_grep_search", + description: + "Structural AST-aware code search using ast-grep. Works across all languages including C++, C#, TypeScript, Python, Rust, Go, Java. " + + "Use this whenever the query is about code shape or structure — not just text. " + + "TRIGGER EXAMPLES (use ast_grep_search for any of these): " + + "'find all try/catch blocks' — pattern: 'try { $$$ } catch ($$$) { $$$ }'; " + + "'find all class definitions' — pattern: 'class $NAME { $$$ }'; " + + "'find every new X() call' — pattern: 'new $CLASS($$$)'; " + + "'find all if statements with a specific condition shape' — pattern: 'if ($COND) { $$$ }'; " + + "'find all async methods (C#)' — pattern: 'async $RET $METHOD($$$) { $$$ }'; " + + "'find all using blocks (C#)' — pattern: 'using ($$$) { $$$ }'; " + + "'find all template functions (C++)' — pattern: 'template<$$$> $RET $FUNC($$$) { $$$ }'; " + + "'find all lambda expressions (C#/TS)' — pattern: '($$$) => $$$'; " + + "'find all function calls to X' — pattern: 'X($$$)'; " + + "'find all throw statements' — pattern: 'throw $ERR'. " + + "Metavariables: $VAR matches any single node, $$$ARGS matches zero or more nodes. " + + "lang values: cpp, c_sharp, typescript, javascript, python, rust, go, java, c. " + + "For plain text/string search, use ripgrep_search instead. " + + "For known symbol names, use find_symbol instead.", + parameters: { + type: "object", + properties: { + pattern: { + type: "string", + description: "ast-grep structural pattern. Mirrors actual code syntax with $VAR (single node) and $$$ARGS (multiple nodes) as metavariables.", + }, + lang: { + type: "string", + description: "Language grammar to use. Options: cpp, c_sharp, typescript, javascript, python, rust, go, java, c.", + }, + path: { + type: "string", + description: "Relative path to search in. Defaults to project root.", + }, + }, + required: ["pattern", "lang"], + additionalProperties: false, + }, + }, + }); + } return tools; } diff --git a/src/session.ts b/src/session.ts index 0116fbd..9c0c83c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,22 +2,28 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import * as crypto from "crypto"; +import { createRequire } from "module"; import { fileURLToPath } from "url"; import matter from "gray-matter"; -import ejs from "ejs"; import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "openai/resources/chat/completions"; import { launchNotifyScript } from "./notify"; import { buildThinkingRequestOptions } from "./openai-thinking"; import { DEEPSEEK_V4_MODELS } from "./model-capabilities"; -import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL } from "./prompt"; +import { getCompactPrompt, getSystemPrompt, getTools, checkToolInstalled, AGENT_DRIFT_GUARD_SKILL } from "./prompt"; import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; -import { logApiError } from "./error-logger"; +import { logApiError, logWarn } from "./error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; +import type { ProviderPrivacyMode } from "./settings"; +import { sanitizeForProviderStrict } from "./privacy-guard"; const MAX_SESSION_ENTRIES = 50; -const DEFAULT_NEW_PROMPT_API_URL = "https://deepcode.vegamo.cn/api/plugin/new"; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; +const FINAL_HTTP_BODY_LOG_ENV = "DEEPCODE_LOG_FINAL_HTTP_BODY"; +const require = createRequire(import.meta.url); +const ejs = require("ejs") as { + render: (template: string, data?: Record) => string; +}; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -42,7 +48,9 @@ function summarizeCompletionOptions(options?: Record): Record | null; // {pid: {startTime, command}} + processes: Map | null; // {pid: {startTime, command}} }; export type SessionsIndex = { @@ -233,7 +259,7 @@ export class SessionManager { startedAt, estimatedTokens: Math.round(estimatedTokens), formattedTokens: this.formatEstimatedTokens(estimatedTokens), - phase, + phase }); } @@ -260,7 +286,8 @@ export class SessionManager { request: Record, options?: Record, sessionId?: string, - debug?: ChatCompletionDebugOptions + debug?: ChatCompletionDebugOptions, + providerPrivacyMode: ProviderPrivacyMode = "off" ): Promise<{ choices?: Array<{ message?: Record }>; usage?: unknown; @@ -276,18 +303,30 @@ export class SessionManager { stream: true, stream_options: { ...(isUsageRecord(request.stream_options) ? request.stream_options : {}), - include_usage: true, - }, + include_usage: true + } }; let response: unknown; + let outboundRequest: Record = streamRequest; + let providerRedactedSensitiveContent = false; try { - response = await ( - client.chat.completions.create as unknown as ( - body: Record, - options?: Record - ) => Promise - )(streamRequest, options); + if (providerPrivacyMode === "strict") { + const sanitized = sanitizeForProviderStrict(streamRequest); + outboundRequest = sanitized.value as Record; + providerRedactedSensitiveContent = sanitized.redactedSensitiveContent; + } + this.logFinalHttpBody( + requestId, + sessionId, + outboundRequest, + providerPrivacyMode, + providerRedactedSensitiveContent + ); + response = await (client.chat.completions.create as unknown as ( + body: Record, + options?: Record + ) => Promise)(outboundRequest, options); } catch (error) { this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), @@ -298,8 +337,8 @@ export class SessionManager { baseURL: debug?.baseURL, durationMs: Date.now() - startedAtMs, params: { ...debug?.params, options: summarizeCompletionOptions(options) }, - request: streamRequest, - error: normalizeDebugError(error), + request: outboundRequest, + error: normalizeDebugError(error) }); logApiError({ timestamp: new Date().toISOString(), @@ -310,9 +349,9 @@ export class SessionManager { error: { name: error instanceof Error ? error.name : "UnknownError", message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, + stack: error instanceof Error ? error.stack : undefined }, - request: streamRequest, + request: outboundRequest }); this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId); throw error; @@ -329,8 +368,8 @@ export class SessionManager { baseURL: debug?.baseURL, durationMs: Date.now() - startedAtMs, params: { ...debug?.params, options: summarizeCompletionOptions(options) }, - request: streamRequest, - response, + request: outboundRequest, + response }); return response as { choices?: Array<{ message?: Record }>; usage?: unknown }; } @@ -340,14 +379,12 @@ export class SessionManager { let refusal: string | null = null; let usage: unknown = null; const responseChunks: unknown[] = []; - const toolCallsByIndex = new Map< - number, - { - id?: string; - type?: string; - function?: { name?: string; arguments?: string }; - } - >(); + const toolCallsByIndex = new Map(); + let lastToolCallIndex: number | null = null; const trackText = (value: unknown) => { if (typeof value !== "string" || value.length === 0) { @@ -357,6 +394,43 @@ export class SessionManager { this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "update", sessionId); }; + const getStreamToolCallIndex = ( + rawToolCall: Record, + fallbackIndex: number, + batchSize: number + ): number => { + if (typeof rawToolCall.index === "number" && Number.isFinite(rawToolCall.index)) { + lastToolCallIndex = rawToolCall.index; + return rawToolCall.index; + } + + const id = rawToolCall.id; + if (typeof id === "string" && id) { + for (const [index, toolCall] of toolCallsByIndex.entries()) { + if (toolCall.id === id) { + lastToolCallIndex = index; + return index; + } + } + const index = toolCallsByIndex.size; + lastToolCallIndex = index; + return index; + } + + if (batchSize > 1) { + lastToolCallIndex = fallbackIndex; + return fallbackIndex; + } + + if (lastToolCallIndex != null && toolCallsByIndex.has(lastToolCallIndex)) { + return lastToolCallIndex; + } + + const index = toolCallsByIndex.size; + lastToolCallIndex = index; + return index; + }; + try { for await (const chunk of response as AsyncIterable>) { if (debug?.enabled) { @@ -392,11 +466,12 @@ export class SessionManager { const rawToolCalls = delta.tool_calls; if (Array.isArray(rawToolCalls)) { - for (const rawToolCall of rawToolCalls) { + for (let rawToolCallIndex = 0; rawToolCallIndex < rawToolCalls.length; rawToolCallIndex += 1) { + const rawToolCall = rawToolCalls[rawToolCallIndex]; if (!isUsageRecord(rawToolCall)) { continue; } - const index = typeof rawToolCall.index === "number" ? rawToolCall.index : toolCallsByIndex.size; + const index = getStreamToolCallIndex(rawToolCall, rawToolCallIndex, rawToolCalls.length); const current = toolCallsByIndex.get(index) ?? {}; if (typeof rawToolCall.id === "string") { current.id = rawToolCall.id; @@ -431,9 +506,9 @@ export class SessionManager { baseURL: debug?.baseURL, durationMs: Date.now() - startedAtMs, params: { ...debug?.params, options: summarizeCompletionOptions(options) }, - request: streamRequest, + request: outboundRequest, responseChunks, - error: normalizeDebugError(error), + error: normalizeDebugError(error) }); logApiError({ timestamp: new Date().toISOString(), @@ -444,15 +519,38 @@ export class SessionManager { error: { name: error instanceof Error ? error.name : "UnknownError", message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, + stack: error instanceof Error ? error.stack : undefined }, - request: streamRequest, + request: outboundRequest }); throw error; } finally { this.emitLlmStreamProgress(requestId, startedAt, estimatedTokens, "end", sessionId); } + // Deduplicate by tool call ID after streaming completes. + // The provider may assign the same ID to two different index slots. + // Keep the first (lowest-index) entry for each ID and discard extras. + const seenStreamIds = new Map(); + for (const [slotIndex, toolCall] of toolCallsByIndex.entries()) { + const tcId = toolCall.id; + if (typeof tcId === "string" && tcId) { + const firstSlot = seenStreamIds.get(tcId); + if (firstSlot != null) { + logWarn({ + timestamp: new Date().toISOString(), + location: "createChatCompletionStream:postStreamDedup", + message: "Provider produced duplicate tool_call_id across stream slots — dropped extra slot", + sessionId, + data: { duplicateId: tcId, keptSlot: firstSlot, droppedSlot: slotIndex } + }); + toolCallsByIndex.delete(slotIndex); + } else { + seenStreamIds.set(tcId, slotIndex); + } + } + } + const toolCalls = Array.from(toolCallsByIndex.entries()) .sort(([left], [right]) => left - right) .map(([, toolCall]) => toolCall); @@ -469,7 +567,7 @@ export class SessionManager { const finalResponse = { choices: [{ message }], - usage, + usage }; this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), @@ -480,9 +578,9 @@ export class SessionManager { baseURL: debug?.baseURL, durationMs: Date.now() - startedAtMs, params: { ...debug?.params, options: summarizeCompletionOptions(options) }, - request: streamRequest, + request: outboundRequest, responseChunks, - response: finalResponse, + response: finalResponse }); return finalResponse; } @@ -497,6 +595,38 @@ export class SessionManager { logOpenAIChatCompletionDebug(entry); } + private logFinalHttpBody( + requestId: string, + sessionId: string | undefined, + body: Record, + providerPrivacyMode: ProviderPrivacyMode, + providerRedactedSensitiveContent: boolean + ): void { + if (process.env[FINAL_HTTP_BODY_LOG_ENV] !== "true") { + return; + } + + try { + const logPath = path.join(os.homedir(), ".deepcode", "logs", "final-http-body.jsonl"); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync( + logPath, + JSON.stringify({ + timestamp: new Date().toISOString(), + requestId, + sessionId, + boundary: "before client.chat.completions.create", + providerPrivacyMode, + providerRedactedSensitiveContent, + body + }) + "\n", + "utf8" + ); + } catch { + // Boundary logging must never affect the request path. + } + } + async identifyMatchingSkillNames( skills: SkillInfo[], userPrompt: string, @@ -512,43 +642,35 @@ 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) - .map((x) => { - return { name: x.name, description: x.description }; - }); + const simpleSkills = skills.filter((x) => !x.isLoaded).map((x) => { + return {name: x.name, description: x.description}; + }) if (simpleSkills.length === 0) { return []; } systemPrompt += "```\n" + JSON.stringify(simpleSkills, null, 2) + "\n```"; - - const { client, model, baseURL, debugLogEnabled } = this.createOpenAIClient(); + + const { client, model, baseURL, debugLogEnabled, providerPrivacyMode } = this.createOpenAIClient(); if (!client) { return []; } try { - const response = await this.createChatCompletionStream( - client, - { - model, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userPrompt }, - ], - response_format: { type: "json_object" }, - }, - options?.signal ? { signal: options.signal } : undefined, - options?.sessionId, - { - enabled: debugLogEnabled, - location: "SessionManager.identifyMatchingSkillNames", - baseURL, - params: { purpose: "skill-matching" }, - } - ); + const response = await this.createChatCompletionStream(client, { + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt } + ], + response_format: { type: "json_object" } + }, options?.signal ? { signal: options.signal } : undefined, options?.sessionId, { + enabled: debugLogEnabled, + location: "SessionManager.identifyMatchingSkillNames", + baseURL, + params: { purpose: "skill-matching" } + }, providerPrivacyMode); this.throwIfAborted(options?.signal); - + const rawContent = response.choices?.[0]?.message?.content; const content = typeof rawContent === "string" ? rawContent : ""; if (!content) { @@ -559,7 +681,7 @@ The candidate skills are as follows:\n\n`; if (parsed && Array.isArray(parsed.skillNames)) { return parsed.skillNames; } - + return []; } catch (error) { if (this.isAbortLikeError(error) || options?.signal?.aborted) { @@ -623,7 +745,10 @@ The candidate skills are as follows:\n\n`; if (sessionId) { const loadedSkillKeys = this.getLoadedSkillKeys(sessionId); for (const skill of skillsByName.values()) { - if (loadedSkillKeys.has(this.getSkillKey(skill)) || loadedSkillKeys.has(this.getSkillKeyByName(skill.name))) { + if ( + loadedSkillKeys.has(this.getSkillKey(skill)) + || loadedSkillKeys.has(this.getSkillKeyByName(skill.name)) + ) { skill.isLoaded = true; } } @@ -667,7 +792,10 @@ The candidate skills are as follows:\n\n`; ? parsed.data.name.trim() : fallbackSkill.name, path: displayPath, - description: typeof parsed.data.description === "string" ? parsed.data.description.trim() : "", + description: + typeof parsed.data.description === "string" + ? parsed.data.description.trim() + : "", }; } catch { return fallbackSkill; @@ -732,8 +860,8 @@ The candidate skills are as follows:\n\n`; return dedupedSkills.map((skill) => { const matchedSkill = - availableSkillsByKey.get(this.getSkillKey(skill)) ?? - availableSkillsByKey.get(this.getSkillKeyByName(skill.name)); + availableSkillsByKey.get(this.getSkillKey(skill)) + ?? availableSkillsByKey.get(this.getSkillKeyByName(skill.name)); if (!matchedSkill) { return skill; } @@ -779,6 +907,7 @@ The candidate skills are as follows:\n\n`; this.reportNewPrompt(); const signal = controller?.signal; this.throwIfAborted(signal); + this.applyInitCommandPrompt(userPrompt); if (userPrompt.text) { const skills = await this.listSkills(); @@ -807,20 +936,23 @@ The candidate skills are as follows:\n\n`; status: "pending", failReason: null, usage: null, + lastResponseUsage: null, activeTokens: 0, createTime: now, updateTime: now, - processes: null, + processes: null }; index.entries.push(entry); - const sortedEntries = index.entries.slice().sort((a, b) => { - const aTime = Date.parse(a.updateTime); - const bTime = Date.parse(b.updateTime); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.updateTime.localeCompare(a.updateTime); - } - return bTime - aTime; - }); + const sortedEntries = index.entries + .slice() + .sort((a, b) => { + const aTime = Date.parse(a.updateTime); + const bTime = Date.parse(b.updateTime); + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return b.updateTime.localeCompare(a.updateTime); + } + return bTime - aTime; + }); const keptEntries = sortedEntries.slice(0, MAX_SESSION_ENTRIES); const keptIds = new Set(keptEntries.map((item) => item.id)); const droppedEntries = sortedEntries.filter((item) => !keptIds.has(item.id)); @@ -828,20 +960,23 @@ The candidate skills are as follows:\n\n`; this.saveSessionsIndex(index); this.removeSessionMessages(droppedEntries.map((item) => item.id)); + const agentInstructions = this.loadAgentInstructions(); const systemPrompt = getSystemPrompt(this.projectRoot, this.getPromptToolOptions()); const systemMessage = this.buildSystemMessage(sessionId, systemPrompt); this.appendSessionMessage(sessionId, systemMessage); - const agentInstructions = this.loadAgentInstructions(); - if (agentInstructions) { - const instructionsMessage = this.buildSystemMessage(sessionId, agentInstructions); - this.appendSessionMessage(sessionId, instructionsMessage); - } - const defaultSkillPrompt = `Use the skill document below to assist the user:\n${AGENT_DRIFT_GUARD_SKILL}`; const defaultSkillMessage = this.buildSystemMessage(sessionId, defaultSkillPrompt); this.appendSessionMessage(sessionId, defaultSkillMessage); + if (agentInstructions) { + const agentInstructionsMessage = this.buildSystemMessage( + sessionId, + this.renderForcedAgentInstructions(agentInstructions) + ); + this.appendSessionMessage(sessionId, agentInstructionsMessage); + } + const userMessage = this.buildUserMessage(sessionId, userPrompt); this.appendSessionMessage(sessionId, userMessage); @@ -869,12 +1004,13 @@ ${skillMd} async replySession(sessionId: string, userPrompt: UserPromptContent, controller?: AbortController): Promise { const signal = controller?.signal; this.throwIfAborted(signal); + this.applyInitCommandPrompt(userPrompt); const now = new Date().toISOString(); const updated = this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "pending", failReason: null, - updateTime: now, + updateTime: now })); if (!updated) { @@ -923,8 +1059,20 @@ ${skillMd} async activateSession(sessionId: string, controller?: AbortController): Promise { const startedAt = Date.now(); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify } = - this.createOpenAIClient(); + const { + client, + model, + baseURL, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, + notify, + provider, + providerPrivacyMode, + zdr, + dataCollection, + cacheControl + } = this.createOpenAIClient(); const now = new Date().toISOString(); if (!client) { @@ -932,15 +1080,11 @@ ${skillMd} ...entry, status: "failed", failReason: "OpenAI API key not found", - updateTime: now, + updateTime: now })); this.onAssistantMessage( - this.buildAssistantMessage( - sessionId, - "OpenAI API key not found. Please configure ~/.deepcode/settings.json.", - null - ), - false + this.buildAssistantMessage(sessionId, "OpenAI API key not found. Please configure ~/.deepcode/settings.json.", null), + false, ); this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); return; @@ -952,7 +1096,7 @@ ${skillMd} ...entry, status: "interrupted", failReason: "interrupted", - updateTime: now, + updateTime: now })); this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); return; @@ -961,13 +1105,13 @@ ${skillMd} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "processing", - updateTime: now, + updateTime: now })); this.sessionControllers.set(sessionId, sessionController); try { - const maxIterations = 80000; // about 1K RMB cost + const maxIterations = 80000; // about 1K RMB cost let toolCalls: unknown[] | null = null; for (let iteration = 0; iteration < maxIterations; iteration++) { @@ -982,25 +1126,21 @@ ${skillMd} const compactPromptTokenThreshold = getCompactPromptTokenThreshold(model); if (session.activeTokens > compactPromptTokenThreshold) { - const message = this.buildAssistantMessage( - sessionId, - "The conversation is getting long, compacting...", - null - ); + const message = this.buildAssistantMessage(sessionId, "The conversation is getting long, compacting...", null); message.meta = { asThinking: true }; this.onAssistantMessage(message, false); await this.compactSession(sessionId, sessionController.signal); } - const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled); - const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); + const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled, isOpenRouterBaseURL(baseURL) || cacheControl === true); + const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort, provider, zdr, dataCollection); const response = await this.createChatCompletionStream( client, { model, messages, tools: getTools(this.getPromptToolOptions()), - ...thinkingOptions, + ...thinkingOptions }, { signal: sessionController.signal }, sessionId, @@ -1008,8 +1148,9 @@ ${skillMd} enabled: debugLogEnabled, location: "SessionManager.activateSession", baseURL, - params: { iteration, thinkingEnabled, reasoningEffort }, - } + params: { iteration, thinkingEnabled, reasoningEffort } + }, + providerPrivacyMode ); const message = response.choices?.[0]?.message; @@ -1047,10 +1188,17 @@ ${skillMd} assistantRefusal: refusal, toolCalls, usage: accumulateUsage(entry.usage, responseUsage), + lastResponseUsage: responseUsage, activeTokens: getTotalTokens(responseUsage), - status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed", + status: refusal + ? "failed" + : waitingForUser + ? "waiting_for_user" + : toolCalls + ? "processing" + : "completed", failReason: refusal ? refusal : entry.failReason, - updateTime: new Date().toISOString(), + updateTime: new Date().toISOString() })); if (refusal) { @@ -1069,16 +1217,12 @@ ${skillMd} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, status: "completed", - updateTime: new Date().toISOString(), + updateTime: new Date().toISOString() })); this.onAssistantMessage( - this.buildAssistantMessage( - sessionId, - "The AI agent has taken several steps but hasn't reached a conclusion yet. Do you want to continue?", - null - ), - false - ); + this.buildAssistantMessage(sessionId, "The AI agent has taken several steps but hasn't reached a conclusion yet. Do you want to continue?", null), + false, + ) } catch (error) { const errMessage = error instanceof Error ? error.message : String(error); const aborted = this.isAbortLikeError(error) || sessionController.signal.aborted; @@ -1086,11 +1230,14 @@ ${skillMd} ...entry, status: aborted ? "interrupted" : "failed", failReason: aborted ? "interrupted" : errMessage, - updateTime: new Date().toISOString(), + updateTime: new Date().toISOString() })); if (!aborted) { - this.onAssistantMessage(this.buildAssistantMessage(sessionId, `Request failed: ${errMessage}`, null), false); + this.onAssistantMessage( + this.buildAssistantMessage(sessionId, `Request failed: ${errMessage}`, null), + false, + ); } } finally { if (this.sessionControllers.get(sessionId) === sessionController) { @@ -1102,7 +1249,18 @@ ${skillMd} async compactSession(sessionId: string, signal?: AbortSignal): Promise { this.throwIfAborted(signal); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled } = this.createOpenAIClient(); + const { + client, + model, + baseURL, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, + provider, + providerPrivacyMode, + zdr, + dataCollection + } = this.createOpenAIClient(); if (!client) { return; } @@ -1111,12 +1269,14 @@ ${skillMd} return; } - const startIndex = sessionMessages.findIndex((message) => message.role !== "system"); + const startIndex = sessionMessages.findIndex( + (message) => message.role !== "system" + ); if (startIndex === -1) { return; } - const searchStart = Math.floor(startIndex + ((sessionMessages.length - startIndex) * 2) / 3); + const searchStart = Math.floor(startIndex + (sessionMessages.length - startIndex) * 2 / 3); let endIndex = -1; for (let i = Math.max(searchStart, startIndex); i < sessionMessages.length; i += 1) { if (sessionMessages[i].role !== "tool") { @@ -1129,23 +1289,17 @@ ${skillMd} } const compactPrompt = getCompactPrompt(sessionMessages.slice(startIndex, endIndex)); - const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); - const response = await this.createChatCompletionStream( - client, - { - model, - messages: [{ role: "user", content: compactPrompt }], - ...thinkingOptions, - }, - signal ? { signal } : undefined, - sessionId, - { - enabled: debugLogEnabled, - location: "SessionManager.compactSession", - baseURL, - params: { thinkingEnabled, reasoningEffort }, - } - ); + const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort, provider, zdr, dataCollection); + const response = await this.createChatCompletionStream(client, { + model, + messages: [{ role: "user", content: compactPrompt }], + ...thinkingOptions + }, signal ? { signal } : undefined, sessionId, { + enabled: debugLogEnabled, + location: "SessionManager.compactSession", + baseURL, + params: { thinkingEnabled, reasoningEffort } + }, providerPrivacyMode); this.throwIfAborted(signal); const rawLlmResponse = response.choices?.[0]?.message?.content; const llmResponse = typeof rawLlmResponse === "string" ? rawLlmResponse : ""; @@ -1157,7 +1311,7 @@ ${skillMd} ...entry, usage: accumulateUsage(entry.usage, responseUsage), activeTokens: getTotalTokens(responseUsage), - updateTime: now, + updateTime: now })); for (let i = startIndex; i < endIndex; i += 1) { @@ -1176,45 +1330,23 @@ ${skillMd} createTime: now, updateTime: now, meta: { - isSummary: true, - }, + isSummary: true + } }; sessionMessages.splice(endIndex, 0, summaryMessage); this.saveSessionMessages(sessionId, sessionMessages); } - private getPromptToolOptions(): { webSearchEnabled: boolean } { + private getPromptToolOptions(): { webSearchEnabled: boolean; ripgrepEnabled: boolean; astGrepEnabled: boolean } { return { webSearchEnabled: true, + ripgrepEnabled: checkToolInstalled("rg"), + astGrepEnabled: checkToolInstalled("sg"), }; } private reportNewPrompt(): void { - const { machineId } = this.createOpenAIClient(); - if (!machineId) { - return; - } - - void fetch(DEFAULT_NEW_PROMPT_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Token: machineId, - }, - body: JSON.stringify({}), - }) - .then(async (response) => { - if (response.ok) { - return; - } - - const body = await response.text().catch(() => ""); - throw new Error(`New prompt API request failed with status ${response.status}${body ? `: ${body}` : ""}`); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - console.warn(`Failed to report new prompt: ${message}`); - }); + // no-op: external reporting disabled } interruptActiveSession(): void { @@ -1260,7 +1392,7 @@ ${skillMd} status: "interrupted", failReason: "interrupted", processes: null, - updateTime: now, + updateTime: now })); const contentParts = ["Interrupted."]; @@ -1271,7 +1403,10 @@ ${skillMd} contentParts.push(`Failed to kill processes: ${failedPids.join(", ")}.`); } - this.onAssistantMessage(this.buildUserMessage(sessionId, { text: contentParts.join(" ") }), false); + this.onAssistantMessage( + this.buildUserMessage(sessionId, { text: contentParts.join(" ") }), + false, + ); } private isInterrupted(sessionId: string): boolean { @@ -1319,7 +1454,8 @@ ${skillMd} nextMeta.paramsMd = normalizedParamsMd; } - const normalizedResultMd = typeof message.content === "string" ? this.buildToolResultSnippet(message.content) : ""; + const normalizedResultMd = + typeof message.content === "string" ? this.buildToolResultSnippet(message.content) : ""; if (nextMeta && normalizedResultMd) { nextMeta.resultMd = normalizedResultMd; } @@ -1327,7 +1463,7 @@ ${skillMd} return { ...message, visible: typeof message.content === "string" ? !this.isInvisibleExecution(message.content) : message.visible, - meta: nextMeta, + meta: nextMeta }; } @@ -1369,7 +1505,7 @@ ${skillMd} return { version: 1, entries, - originalPath: parsed.originalPath || this.projectRoot, + originalPath: parsed.originalPath || this.projectRoot }; } catch { return { version: 1, entries: [], originalPath: this.projectRoot }; @@ -1383,9 +1519,9 @@ ${skillMd} version: 1, entries: index.entries.map((entry) => ({ ...entry, - processes: this.serializeProcesses(entry.processes), + processes: this.serializeProcesses(entry.processes) })), - originalPath: this.projectRoot, + originalPath: this.projectRoot }; fs.writeFileSync(sessionsIndexPath, JSON.stringify(normalized, null, 2), "utf8"); } @@ -1421,7 +1557,10 @@ ${skillMd} fs.writeFileSync(messagePath, payload ? `${payload}\n` : "", "utf8"); } - private updateSessionEntry(sessionId: string, updater: (entry: SessionEntry) => SessionEntry): SessionEntry | null { + private updateSessionEntry( + sessionId: string, + updater: (entry: SessionEntry) => SessionEntry + ): SessionEntry | null { const index = this.loadSessionsIndex(); const entryIndex = index.entries.findIndex((entry) => entry.id === sessionId); if (entryIndex === -1) { @@ -1438,12 +1577,12 @@ ${skillMd} private buildUserMessage(sessionId: string, prompt: UserPromptContent): SessionMessage { const now = new Date().toISOString(); const imageParams = - prompt.imageUrls - ?.filter((url) => Boolean(url)) - .map((url) => ({ - type: "image_url", - image_url: { url }, - })) ?? []; + prompt.imageUrls + ?.filter((url) => Boolean(url)) + .map((url) => ({ + type: "image_url", + image_url: { url } + })) ?? []; return { id: crypto.randomUUID(), @@ -1455,15 +1594,22 @@ ${skillMd} compacted: false, visible: true, createTime: now, - updateTime: now, + updateTime: now }; } + private applyInitCommandPrompt(userPrompt: UserPromptContent): void { + if (userPrompt.text !== "/init") { + return; + } + userPrompt.text = this.renderInitCommandPrompt(); + } + private renderInitCommandPrompt(): string { const templatePath = path.join(getExtensionRoot(), "docs", "prompts", "init_command.md.ejs"); const template = fs.readFileSync(templatePath, "utf8"); return ejs.render(template, { - agentsMdFile: this.getEffectiveProjectAgentsMdFile(), + agentsMdFile: this.getEffectiveProjectAgentsMdFile() }); } @@ -1475,12 +1621,12 @@ ${skillMd} const candidatePaths = [ { absolutePath: path.join(this.projectRoot, ".deepcode", "AGENTS.md"), - displayPath: "./.deepcode/AGENTS.md", + displayPath: "./.deepcode/AGENTS.md" }, { absolutePath: path.join(this.projectRoot, "AGENTS.md"), - displayPath: "./AGENTS.md", - }, + displayPath: "./AGENTS.md" + } ]; for (const candidatePath of candidatePaths) { @@ -1488,7 +1634,7 @@ ${skillMd} if (content) { return { content, - displayPath: candidatePath.displayPath, + displayPath: candidatePath.displayPath }; } } @@ -1508,16 +1654,31 @@ ${skillMd} } } - private loadAgentInstructions(): string | null { + private loadAgentInstructions(): { content: string; displayPath: string } | null { const projectInstructions = this.loadProjectAgentInstructions(); if (projectInstructions) { - return projectInstructions.content; + return projectInstructions; } - return this.readNonEmptyFile(path.join(os.homedir(), ".deepcode", "AGENTS.md")); + const userAgentsPath = path.join(os.homedir(), ".deepcode", "AGENTS.md"); + const content = this.readNonEmptyFile(userAgentsPath); + return content + ? { + content, + displayPath: "~/.deepcode/AGENTS.md" + } + : null; + } + + private renderForcedAgentInstructions(agentInstructions: { content: string; displayPath: string }): string { + return `You must follow the AGENTS.md instructions below for every turn in this session.\n\n\n${agentInstructions.content}\n`; } - private buildSystemMessage(sessionId: string, content: string, contentParams: unknown | null = null): SessionMessage { + private buildSystemMessage( + sessionId: string, + content: string, + contentParams: unknown | null = null + ): SessionMessage { const now = new Date().toISOString(); return { id: crypto.randomUUID(), @@ -1529,7 +1690,7 @@ ${skillMd} compacted: false, visible: false, createTime: now, - updateTime: now, + updateTime: now }; } @@ -1551,10 +1712,10 @@ ${skillMd} } private buildAssistantMessage( - sessionId: string, - content: string | null, - toolCalls: unknown[] | null, - reasoningContent?: string | null + sessionId: string, + content: string | null, + toolCalls: unknown[] | null, + reasoningContent?: string | null ): SessionMessage { const now = new Date().toISOString(); const hasReasoningContent = reasoningContent != null; @@ -1577,7 +1738,7 @@ ${skillMd} visible: (content || reasoningContent || "").trim() ? true : false, createTime: now, updateTime: now, - meta: toolCalls ? { asThinking: true } : undefined, + meta: toolCalls ? { asThinking: true } : undefined }; } @@ -1605,16 +1766,19 @@ ${skillMd} meta: { function: toolFunction ?? undefined, paramsMd, - resultMd, - }, + resultMd + } }; } - private async appendToolMessages(sessionId: string, toolCalls: unknown[]): Promise<{ waitingForUser: boolean }> { + private async appendToolMessages( + sessionId: string, + toolCalls: unknown[] + ): Promise<{ waitingForUser: boolean }> { const toolExecutions = await this.toolExecutor.executeToolCalls(sessionId, toolCalls, { onProcessStart: (pid, command) => this.addSessionProcess(sessionId, pid, command), onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid), - shouldStop: () => this.isInterrupted(sessionId), + shouldStop: () => this.isInterrupted(sessionId) }); if (this.isInterrupted(sessionId)) { return { waitingForUser: false }; @@ -1626,7 +1790,12 @@ ${skillMd} waitingForUser = true; } const toolFunction = this.findToolFunction(toolCalls, execution.toolCallId); - const toolMessage = this.buildToolMessage(sessionId, execution.toolCallId, execution.content, toolFunction); + const toolMessage = this.buildToolMessage( + sessionId, + execution.toolCallId, + execution.content, + toolFunction + ); this.appendSessionMessage(sessionId, toolMessage); this.onAssistantMessage(toolMessage, true); @@ -1635,7 +1804,11 @@ ${skillMd} continue; } followUpMessages.push( - this.buildSystemMessage(sessionId, followUpMessage.content, followUpMessage.contentParams ?? null) + this.buildSystemMessage( + sessionId, + followUpMessage.content, + followUpMessage.contentParams ?? null + ) ); } } @@ -1646,10 +1819,26 @@ ${skillMd} return { waitingForUser }; } - private buildOpenAIMessages(messages: SessionMessage[], thinkingEnabled: boolean): ChatCompletionMessageParam[] { + private applyMessageCacheControl(message: ChatCompletionMessageParam): void { + const raw = message as unknown as Record; + const content = raw.content; + if (typeof content === "string") { + raw.content = [{ type: "text", text: content, cache_control: { type: "ephemeral" } }]; + } else if (Array.isArray(content) && content.length > 0) { + const last = content[content.length - 1] as Record; + last.cache_control = { type: "ephemeral" }; + } + } + + private buildOpenAIMessages( + messages: SessionMessage[], + thinkingEnabled: boolean, + addCacheControl: boolean = false + ): ChatCompletionMessageParam[] { const activeMessages = messages.filter((message) => !message.compacted); const toolPairings = this.pairToolMessages(activeMessages); const openAIMessages: ChatCompletionMessageParam[] = []; + const emittedToolCallIds = new Set(); for (let index = 0; index < activeMessages.length; index += 1) { const message = activeMessages[index]; @@ -1657,7 +1846,39 @@ ${skillMd} continue; } - openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled)); + const openAIMessage = this.sessionMessageToOpenAIMessage(message, thinkingEnabled); + + // Deduplicate tool_calls within the assistant message itself. + // The provider can return two tool calls with the same id in one response, + // which causes a 400 from the API on the next turn. + if (message.role === "assistant") { + const rawCalls = (openAIMessage as { tool_calls?: unknown[] }).tool_calls; + if (Array.isArray(rawCalls) && rawCalls.length > 0) { + const seenTcIds = new Set(); + const dedupedCalls = rawCalls.filter((tc) => { + const id = tc && typeof tc === "object" ? (tc as { id?: unknown }).id : undefined; + if (typeof id !== "string" || !id) return true; + if (seenTcIds.has(id)) return false; + seenTcIds.add(id); + return true; + }); + if (dedupedCalls.length < rawCalls.length) { + logWarn({ + timestamp: new Date().toISOString(), + location: "buildOpenAIMessages", + message: "Removed duplicate tool_call ids from assistant message tool_calls before sending to API", + data: { + assistantMessageIndex: index, + originalCount: rawCalls.length, + dedupedCount: dedupedCalls.length + } + }); + (openAIMessage as { tool_calls?: unknown[] }).tool_calls = dedupedCalls; + } + } + } + + openAIMessages.push(openAIMessage); const toolCalls = this.getAssistantToolCalls(message); if (toolCalls.length === 0) { @@ -1669,6 +1890,16 @@ ${skillMd} if (!toolCallId) { continue; } + if (emittedToolCallIds.has(toolCallId)) { + logWarn({ + timestamp: new Date().toISOString(), + location: "buildOpenAIMessages", + message: "Skipped duplicate tool_call_id when building API request — would have caused 400", + data: { duplicateToolCallId: toolCallId, assistantMessageIndex: index, toolCallIndex } + }); + continue; + } + emittedToolCallIds.add(toolCallId); const pairedToolIndex = toolPairings.get(this.buildToolPairingKey(index, toolCallIndex)); if (pairedToolIndex != null) { @@ -1680,20 +1911,51 @@ ${skillMd} } } + if (addCacheControl) { + // Breakpoint 1: last system message — caches the static system prompt prefix. + for (let i = openAIMessages.length - 1; i >= 0; i -= 1) { + if (openAIMessages[i].role === "system") { + this.applyMessageCacheControl(openAIMessages[i]); + break; + } + } + + // Breakpoint 2: last tool message before the current user turn — caches + // the stable context (e.g. codebase indexing results) so follow-up + // questions don't re-process that large block at full cost. + let lastUserIdx = -1; + for (let i = openAIMessages.length - 1; i >= 0; i -= 1) { + if (openAIMessages[i].role === "user") { + lastUserIdx = i; + break; + } + } + if (lastUserIdx > 0) { + for (let i = lastUserIdx - 1; i >= 0; i -= 1) { + if (openAIMessages[i].role === "tool") { + this.applyMessageCacheControl(openAIMessages[i]); + break; + } + } + } + } + return openAIMessages; } - private sessionMessageToOpenAIMessage(message: SessionMessage, thinkingEnabled: boolean): ChatCompletionMessageParam { - const content = this.renderOpenAIMessageContent(message); + private sessionMessageToOpenAIMessage( + message: SessionMessage, + thinkingEnabled: boolean + ): ChatCompletionMessageParam { const base: ChatCompletionMessageParam = { role: message.role, - content, + content: message.content ?? "" } as ChatCompletionMessageParam; const messageParams = message.messageParams as - | { tool_calls?: unknown[]; tool_call_id?: string; reasoning_content?: string } - | null - | undefined; + | { 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; } @@ -1710,29 +1972,25 @@ ${skillMd} if ((message.role === "user" || message.role === "system") && message.contentParams) { const contentParts: ChatCompletionContentPart[] = []; - if (content) { - contentParts.push({ type: "text", text: content }); + if (message.content) { + contentParts.push({ type: "text", text: message.content }); } - const params = Array.isArray(message.contentParams) ? message.contentParams : [message.contentParams]; + const params = Array.isArray(message.contentParams) + ? message.contentParams + : [message.contentParams]; for (const param of params) { if (param && typeof param === "object") { contentParts.push(param as ChatCompletionContentPart); } } - const contentValue: string | ChatCompletionContentPart[] = contentParts.length > 0 ? contentParts : content; + const contentValue: string | ChatCompletionContentPart[] = + contentParts.length > 0 ? contentParts : message.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(); @@ -1829,12 +2087,15 @@ ${skillMd} } } - private buildInterruptedOpenAIToolMessage(toolCalls: unknown[], toolCallId: string): ChatCompletionMessageParam { + 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, + tool_call_id: toolCallId } as ChatCompletionMessageParam; } @@ -1879,31 +2140,30 @@ ${skillMd} } private formatToolParamsSnippet(toolName: string | null, args: Record): string { + // For legacy non-Serena tools keep old behaviour if (toolName === "bash") { - const command = typeof args.command === "string" ? args.command.trim() : ""; - const description = typeof args.description === "string" ? args.description.trim() : ""; - if (command && description) { - return `${command} # ${description}`; - } - if (command) { - return command; - } - if (description) { - return description; + const cmd = typeof args.command === "string" ? args.command.trim() : ""; + const desc = typeof args.description === "string" ? args.description.trim() : ""; + return cmd && desc ? `${cmd} # ${desc}` : cmd || desc; + } + if (toolName === "read" || toolName === "write" || toolName === "edit") { + const firstKey = Object.keys(args)[0]; + if (!firstKey) return ""; + const value = args[firstKey]; + return typeof value === "string" ? value : JSON.stringify(value); + } + + // Serena tools: show every argument as key=value so the user sees the full call + const parts: string[] = []; + for (const [key, value] of Object.entries(args)) { + if (value === undefined || value === null || value === "" || value === false) continue; + if (typeof value === "string") { + parts.push(`${key}=${value}`); + } else { + parts.push(`${key}=${JSON.stringify(value)}`); } } - - const firstKey = Object.keys(args)[0]; - if (!firstKey) { - return ""; - } - - const value = args[firstKey]; - const text = typeof value === "string" ? value : JSON.stringify(value); - if (toolName === "read" && text.startsWith(this.projectRoot)) { - return text.slice(this.projectRoot.length).replace(/^[\\/]/, ""); - } - return text; + return parts.join(" "); } private buildToolResultSnippet(content: string): string { @@ -1948,7 +2208,11 @@ ${skillMd} } } - private maybeNotifyTaskCompletion(sessionId: string, notifyCommand: string | undefined, startedAt: number): void { + private maybeNotifyTaskCompletion( + sessionId: string, + notifyCommand: string | undefined, + startedAt: number + ): void { if (!notifyCommand) { return; } @@ -1969,7 +2233,7 @@ ${skillMd} return { ...entry, processes, - updateTime: now, + updateTime: now }; }); } @@ -1982,7 +2246,7 @@ ${skillMd} return { ...entry, processes: processes.size > 0 ? processes : null, - updateTime: now, + updateTime: now }; }); } @@ -2004,7 +2268,7 @@ ${skillMd} 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 + ? ((toolFunction as { name: string }).name) : "tool"; return JSON.stringify( { @@ -2012,8 +2276,8 @@ ${skillMd} name: toolName, error: reason, metadata: { - interrupted: true, - }, + interrupted: true + } }, null, 2 @@ -2033,7 +2297,7 @@ ${skillMd} } private normalizeSessionEntry(entry: unknown): SessionEntry { - const value = entry && typeof entry === "object" ? (entry as Record) : {}; + const value = (entry && typeof entry === "object") ? (entry as Record) : {}; return { id: typeof value.id === "string" ? value.id : crypto.randomUUID(), summary: typeof value.summary === "string" ? value.summary : null, @@ -2044,10 +2308,11 @@ ${skillMd} status: this.normalizeSessionStatus(value.status), failReason: typeof value.failReason === "string" ? value.failReason : null, usage: value.usage ?? null, + lastResponseUsage: value.lastResponseUsage ?? null, activeTokens: typeof value.activeTokens === "number" ? value.activeTokens : 0, 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), + processes: this.deserializeProcesses(value.processes) }; } @@ -2087,9 +2352,7 @@ ${skillMd} return processes.size > 0 ? processes : null; } - private serializeProcesses( - processes: Map | null - ): Record | null { + private serializeProcesses(processes: Map | null): Record | null { if (!processes || processes.size === 0) { return null; } diff --git a/src/settings.ts b/src/settings.ts index e1a9b09..c681ee3 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -5,18 +5,28 @@ export type DeepcodingEnv = { BASE_URL?: string; API_KEY?: string; THINKING?: string; + PROVIDER?: string; + PROVIDER_PRIVACY?: string; + providerPrivacyMode?: string; + ZDR?: string; + DATA_COLLECTION?: string; }; -export type ReasoningEffort = "high" | "max"; +export type ReasoningEffort = "xhigh" | "high" | "medium" | "low" | "minimal" | "none"; +export type ProviderPrivacyMode = "off" | "strict"; +export type DataCollection = "allow" | "deny"; export type DeepcodingSettings = { env?: DeepcodingEnv; - model?: string; thinkingEnabled?: boolean; reasoningEffort?: ReasoningEffort; debugLogEnabled?: boolean; notify?: string; webSearchTool?: string; + providerPrivacyMode?: ProviderPrivacyMode; + zdr?: boolean; + dataCollection?: DataCollection; + cacheControl?: boolean; }; export type ResolvedDeepcodingSettings = { @@ -28,19 +38,25 @@ export type ResolvedDeepcodingSettings = { debugLogEnabled: boolean; notify?: string; webSearchTool?: string; -}; - -export type ModelConfigSelection = { - model: string; - thinkingEnabled: boolean; - reasoningEffort: ReasoningEffort; + provider?: string; + providerPrivacyMode: ProviderPrivacyMode; + zdr?: boolean; + dataCollection?: DataCollection; + cacheControl?: boolean; }; function resolveReasoningEffort(value: unknown): ReasoningEffort { - return value === "high" || value === "max" ? value : "max"; + const valid: ReasoningEffort[] = ["xhigh", "high", "medium", "low", "minimal", "none"]; + if (value === "max") { + return "xhigh"; + } + return valid.includes(value as ReasoningEffort) ? (value as ReasoningEffort) : "xhigh"; } -function resolveThinkingEnabled(settings: DeepcodingSettings | null | undefined, model: string): boolean { +function resolveThinkingEnabled( + settings: DeepcodingSettings | null | undefined, + model: string +): boolean { if (typeof settings?.thinkingEnabled === "boolean") { return settings.thinkingEnabled; } @@ -53,15 +69,33 @@ function resolveThinkingEnabled(settings: DeepcodingSettings | null | undefined, return defaultsToThinkingMode(model); } +function resolveProviderPrivacyMode(value: unknown): ProviderPrivacyMode { + return value === "strict" ? "strict" : "off"; +} + +function resolveDataCollection(value: unknown): DataCollection | undefined { + if (value === "deny") return "deny"; + if (value === "allow") return "allow"; + return undefined; +} + export function resolveSettings( settings: DeepcodingSettings | null | undefined, defaults: { model: string; baseURL: string } ): ResolvedDeepcodingSettings { const env = settings?.env ?? {}; - const topLevelModel = typeof settings?.model === "string" ? settings.model.trim() : ""; - const model = topLevelModel || env.MODEL?.trim() || defaults.model; + const model = env.MODEL?.trim() || defaults.model; const notify = typeof settings?.notify === "string" ? settings.notify.trim() : ""; - const webSearchTool = typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : ""; + const webSearchTool = + typeof settings?.webSearchTool === "string" ? settings.webSearchTool.trim() : ""; + const provider = env.PROVIDER?.trim(); + const providerPrivacyMode = resolveProviderPrivacyMode( + env.PROVIDER_PRIVACY?.trim() || env.providerPrivacyMode?.trim() || settings?.providerPrivacyMode + ); + const zdr = env.ZDR ? env.ZDR.trim().toLowerCase() === "true" : (settings?.zdr === true); + const dataCollection = resolveDataCollection( + env.DATA_COLLECTION?.trim() || settings?.dataCollection + ); return { apiKey: env.API_KEY?.trim(), @@ -72,35 +106,10 @@ export function resolveSettings( debugLogEnabled: settings?.debugLogEnabled === true, notify: notify || undefined, webSearchTool: webSearchTool || undefined, + provider: provider || undefined, + providerPrivacyMode, + zdr: zdr || undefined, + dataCollection, + cacheControl: settings?.cacheControl === true ? true : undefined }; } - -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/askUserQuestion.test.ts b/src/tests/askUserQuestion.test.ts index f754351..2a668da 100644 --- a/src/tests/askUserQuestion.test.ts +++ b/src/tests/askUserQuestion.test.ts @@ -1,6 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, formatAskUserQuestionDecline } from "../ui"; +import { + findPendingAskUserQuestion, + formatAskUserQuestionAnswers, + formatAskUserQuestionDecline +} from "../ui"; import type { SessionMessage } from "../session"; function message(content: unknown): SessionMessage { @@ -15,31 +19,31 @@ function message(content: unknown): SessionMessage { compacted: false, visible: true, createTime: now, - updateTime: now, + updateTime: now }; } test("findPendingAskUserQuestion returns latest pending AskUserQuestion tool message", () => { - const pending = findPendingAskUserQuestion( - [ - message({ ok: true, name: "read" }), - message({ - ok: true, - name: "AskUserQuestion", - awaitUserResponse: true, - metadata: { - kind: "ask_user_question", - questions: [ - { - question: "Which package manager should we use?", - options: [{ label: "npm", description: "Use package-lock.json." }, { label: "yarn" }], - }, - ], - }, - }), - ], - "waiting_for_user" - ); + const pending = findPendingAskUserQuestion([ + message({ ok: true, name: "read" }), + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [ + { + question: "Which package manager should we use?", + options: [ + { label: "npm", description: "Use package-lock.json." }, + { label: "yarn" } + ] + } + ] + } + }) + ], "waiting_for_user"); assert.equal(pending?.messageId, "tool-message"); assert.equal(pending?.questions[0]?.question, "Which package manager should we use?"); @@ -47,29 +51,26 @@ test("findPendingAskUserQuestion returns latest pending AskUserQuestion tool mes }); test("findPendingAskUserQuestion preserves multiple pending questions in order", () => { - const pending = findPendingAskUserQuestion( - [ - message({ - ok: true, - name: "AskUserQuestion", - awaitUserResponse: true, - metadata: { - kind: "ask_user_question", - questions: [ - { - question: "Use default description?", - options: [{ label: "Yes" }, { label: "Custom" }], - }, - { - question: "Where should the project be created?", - options: [{ label: "Current directory" }, { label: "Custom path" }], - }, - ], - }, - }), - ], - "waiting_for_user" - ); + const pending = findPendingAskUserQuestion([ + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [ + { + question: "Use default description?", + options: [{ label: "Yes" }, { label: "Custom" }] + }, + { + question: "Where should the project be created?", + options: [{ label: "Current directory" }, { label: "Custom path" }] + } + ] + } + }) + ], "waiting_for_user"); assert.deepEqual( pending?.questions.map((question) => question.question), @@ -78,20 +79,17 @@ test("findPendingAskUserQuestion preserves multiple pending questions in order", }); test("findPendingAskUserQuestion ignores questions unless session waits for user", () => { - const pending = findPendingAskUserQuestion( - [ - message({ - ok: true, - name: "AskUserQuestion", - awaitUserResponse: true, - metadata: { - kind: "ask_user_question", - questions: [{ question: "Continue?", options: [{ label: "Yes" }] }], - }, - }), - ], - "processing" - ); + const pending = findPendingAskUserQuestion([ + message({ + ok: true, + name: "AskUserQuestion", + awaitUserResponse: true, + metadata: { + kind: "ask_user_question", + questions: [{ question: "Continue?", options: [{ label: "Yes" }] }] + } + }) + ], "processing"); assert.equal(pending, null); }); @@ -100,9 +98,9 @@ test("formatAskUserQuestionAnswers creates model-readable answer text", () => { assert.equal( formatAskUserQuestionAnswers({ "Which package manager?": "yarn", - "Any notes?": "Use the existing lockfile", + "Any notes?": "Use the existing lockfile" }), - 'User has answered your questions: "Which package manager?"="yarn", "Any notes?"="Use the existing lockfile". You can now continue with the user\'s answers in mind.' + "User has answered your questions: \"Which package manager?\"=\"yarn\", \"Any notes?\"=\"Use the existing lockfile\". You can now continue with the user's answers in mind." ); }); diff --git a/src/tests/clipboard.test.ts b/src/tests/clipboard.test.ts index 022b2f8..1eda82d 100644 --- a/src/tests/clipboard.test.ts +++ b/src/tests/clipboard.test.ts @@ -4,9 +4,6 @@ import * as fs from "fs"; 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"); - const ORIGINAL_PATH = process.env.PATH; const ORIGINAL_PLATFORM = process.platform; @@ -31,7 +28,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 { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; + const { readClipboardImage } = await import(moduleUrl) as typeof import("../ui/clipboard"); const result = withCleanPath(() => readClipboardImage()); assert.equal(result, null); }); @@ -39,29 +36,33 @@ test("readClipboardImage returns null when no clipboard helpers are installed", test("readClipboardImage uses osascript fallback on macOS when pngpaste is missing", async () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-clipboard-test-bin-")); try { - fs.writeFileSync(path.join(binDir, "pngpaste"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + fs.writeFileSync( + path.join(binDir, "pngpaste"), + "#!/bin/sh\nexit 1\n", + { mode: 0o755 } + ); fs.writeFileSync( path.join(binDir, "osascript"), [ "#!/bin/sh", - 'for arg in "$@"; do', - ' case "$arg" in', + "for arg in \"$@\"; do", + " case \"$arg\" in", " *'open for access POSIX file " + '"' + "'*)", - ' path_part=${arg#*POSIX file \\"}', - ' out_path=${path_part%%\\"*}', - ' printf fakepng > "$out_path"', + " path_part=${arg#*POSIX file \\\"}", + " out_path=${path_part%%\\\"*}", + " printf fakepng > \"$out_path\"", " exit 0", " ;;", " esac", "done", "exit 1", - "", + "" ].join("\n"), { mode: 0o755 } ); const moduleUrl = new URL(`../ui/clipboard.ts?t=${Date.now()}`, import.meta.url).href; - const { readClipboardImage } = (await import(moduleUrl)) as ClipboardModule; + const { readClipboardImage } = await import(moduleUrl) as typeof import("../ui/clipboard"); process.env.PATH = binDir; const result = withPlatform("darwin", () => readClipboardImage()); diff --git a/src/tests/debug-logger.test.ts b/src/tests/debug-logger.test.ts index 1084b01..00efdb5 100644 --- a/src/tests/debug-logger.test.ts +++ b/src/tests/debug-logger.test.ts @@ -5,10 +5,12 @@ import * as os from "os"; import * as path from "path"; import { getDebugLogPath, logOpenAIChatCompletionDebug } from "../debug-logger"; -test("debug logger appends full entries without rotation", () => { +test("debug logger appends sanitized 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; + process.env.USERPROFILE = home; try { for (let index = 0; index < 25; index += 1) { logOpenAIChatCompletionDebug({ @@ -19,10 +21,29 @@ test("debug logger appends full entries without rotation", () => { request: { model: "test-model", messages: [{ role: "user", content: `full request content ${index}` }], + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "read", + arguments: "{\"file_path\":\"C:\\\\repo\\\\secret.env\"}" + } + } + ], + apiKey: "sk-test-secret" }, response: { - choices: [{ message: { content: `full response content ${index}` } }], - }, + choices: [ + { + message: { + content: `full response content ${index}`, + reasoning_content: `hidden reasoning content ${index}`, + reasoning_details: [{ text: `provider reasoning detail ${index}` }] + } + } + ] + } }); } @@ -33,14 +54,29 @@ test("debug logger appends full entries without rotation", () => { const first = JSON.parse(lines[0]) as Record; const last = JSON.parse(lines[24]) as Record; assert.equal(first.requestId, "request-0"); - assert.equal(first.request.messages[0].content, "full request content 0"); + assert.equal(first.request.messages[0].content, "[REDACTED content, 22 chars]"); + assert.equal( + first.request.tool_calls[0].function.arguments, + "[REDACTED tool arguments, 36 chars]" + ); + assert.equal(first.request.apiKey, "***MASKED***"); assert.equal(last.requestId, "request-24"); - assert.equal(last.response.choices[0].message.content, "full response content 24"); + assert.equal(last.response.choices[0].message.content, "[REDACTED content, 24 chars]"); + assert.equal( + last.response.choices[0].message.reasoning_content, + "[REDACTED content, 27 chars]" + ); + assert.equal(last.response.choices[0].message.reasoning_details, "[REDACTED content field]"); } finally { if (originalHome === undefined) { delete process.env.HOME; } else { process.env.HOME = originalHome; } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } } }); diff --git a/src/tests/exitSummary.test.ts b/src/tests/exitSummary.test.ts index 9cff34b..7a33809 100644 --- a/src/tests/exitSummary.test.ts +++ b/src/tests/exitSummary.test.ts @@ -6,19 +6,20 @@ import type { SessionEntry, SessionMessage } from "../session"; const stripAnsi = (text: string): string => text.replace(/\u001b\[[0-9;]*m/g, ""); test("buildExitSummaryText only shows Goodbye and model usage with cached tokens", () => { - const summary = stripAnsi( - buildExitSummaryText({ - session: buildSession({ - prompt_tokens: 11_966, - completion_tokens: 236, - total_tokens: 12_202, - prompt_tokens_details: { cached_tokens: 11_776 }, - completion_tokens_details: { reasoning_tokens: 144 }, - }), - messages: [buildAssistantMessage("assistant-1"), buildAssistantMessage("assistant-2")], - model: "mimo-v2.5-pro", - }) - ); + const summary = stripAnsi(buildExitSummaryText({ + session: buildSession({ + prompt_tokens: 11_966, + completion_tokens: 236, + total_tokens: 12_202, + prompt_tokens_details: { cached_tokens: 11_776 }, + completion_tokens_details: { reasoning_tokens: 144 }, + }), + messages: [ + buildAssistantMessage("assistant-1"), + buildAssistantMessage("assistant-2"), + ], + model: "mimo-v2.5-pro", + })); assert.match(summary, /Goodbye!/); assert.match(summary, /╭─+╮/); @@ -44,6 +45,7 @@ function buildSession(usage: unknown): SessionEntry { status: "completed", failReason: null, usage, + lastResponseUsage: null, activeTokens: 0, createTime: "2026-01-01T00:00:00.000Z", updateTime: "2026-01-01T00:00:01.000Z", diff --git a/src/tests/loadingText.test.ts b/src/tests/loadingText.test.ts index 784fe46..7a568da 100644 --- a/src/tests/loadingText.test.ts +++ b/src/tests/loadingText.test.ts @@ -9,7 +9,9 @@ test("buildLoadingText returns plain Thinking... when no progress", () => { test("buildLoadingText shows running process elapsed time before thinking progress", () => { const startedAt = "2026-04-28T00:00:00.000Z"; const now = Date.parse(startedAt) + 5_750; - const processes = new Map([["123", { startTime: startedAt, command: "yarn install" }]]); + const processes = new Map([ + ["123", { startTime: startedAt, command: "yarn install" }] + ]); const text = buildLoadingText({ processes, progress: { @@ -17,9 +19,9 @@ test("buildLoadingText shows running process elapsed time before thinking progre startedAt, estimatedTokens: 850, formattedTokens: "850", - phase: "update", + phase: "update" }, - now, + now }); assert.equal(text, "(5s) yarn install"); }); @@ -27,8 +29,13 @@ test("buildLoadingText shows running process elapsed time before thinking progre test("buildLoadingText formats long-running process time with minutes", () => { const startedAt = "2026-04-28T00:00:00.000Z"; const now = Date.parse(startedAt) + 65_250; - const processes = new Map([["web-search", { startTime: startedAt, command: "WebSearch: latest node release" }]]); - assert.equal(buildLoadingText({ processes, progress: null, now }), "(1m5s) WebSearch: latest node release"); + const processes = new Map([ + ["web-search", { startTime: startedAt, command: "WebSearch: latest node release" }] + ]); + assert.equal( + buildLoadingText({ processes, progress: null, now }), + "(1m5s) WebSearch: latest node release" + ); }); test("buildLoadingText returns plain Thinking... while elapsed below 3s", () => { @@ -40,9 +47,9 @@ test("buildLoadingText returns plain Thinking... while elapsed below 3s", () => startedAt, estimatedTokens: 12, formattedTokens: "12", - phase: "update", + phase: "update" }, - now, + now }); assert.equal(text, "Thinking..."); }); @@ -56,9 +63,9 @@ test("buildLoadingText shows elapsed seconds and tokens once past the threshold" startedAt, estimatedTokens: 850, formattedTokens: "850", - phase: "update", + phase: "update" }, - now, + now }); assert.equal(text, "Thinking... (5s) · ↓ 850 tokens"); }); @@ -72,9 +79,9 @@ test("buildLoadingText falls back to '0' when formattedTokens is missing", () => startedAt, estimatedTokens: 0, formattedTokens: "", - phase: "update", + phase: "update" }, - now, + now }); assert.equal(text, "Thinking... (4s) · ↓ 0 tokens"); }); @@ -86,9 +93,9 @@ test("buildLoadingText falls back to Thinking... when timestamp is unparseable", startedAt: "not-a-date", estimatedTokens: 0, formattedTokens: "0", - phase: "update", + phase: "update" }, - now: Date.now(), + now: Date.now() }); assert.equal(text, "Thinking..."); }); diff --git a/src/tests/markdown.test.ts b/src/tests/markdown.test.ts index a0127fc..cff30b8 100644 --- a/src/tests/markdown.test.ts +++ b/src/tests/markdown.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { renderMarkdown } from "../ui"; function stripAnsi(text: string): string { + // eslint-disable-next-line no-control-regex return text.replace(/\[[0-9;]*m/g, ""); } diff --git a/src/tests/messageView.test.ts b/src/tests/messageView.test.ts index fef4bc3..1091bc8 100644 --- a/src/tests/messageView.test.ts +++ b/src/tests/messageView.test.ts @@ -4,14 +4,19 @@ 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") - ); + 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" }, + { marker: "+", content: "new", kind: "added" } ]); }); @@ -19,14 +24,14 @@ test("parseDiffPreview keeps nonstandard context lines", () => { const lines = parseDiffPreview("...\n+added"); assert.deepEqual(lines, [ { marker: " ", content: "...", kind: "context" }, - { marker: "+", content: "added", kind: "added" }, + { marker: "+", content: "added", kind: "added" } ]); }); test("MessageView summarizes thinking content across lines", () => { assert.equal( getThinkingParams({ - content: "Plan:\n\nInspect the code and update tests", + content: "Plan:\n\nInspect the code and update tests" }), "Plan: Inspect the code and update tests" ); @@ -40,7 +45,7 @@ test("MessageView falls back to a reasoning placeholder for hidden reasoning con assert.equal( getThinkingParams({ content: "", - messageParams: { reasoning_content: "hidden chain of thought" }, + messageParams: { reasoning_content: "hidden chain of thought" } }), "(reasoning...)" ); @@ -64,6 +69,6 @@ function buildAssistantMessage(overrides: Partial): SessionMessa createTime: "2026-01-01T00:00:00.000Z", updateTime: "2026-01-01T00:00:00.000Z", meta: { asThinking: true }, - ...overrides, + ...overrides }; } diff --git a/src/tests/openai-thinking.test.ts b/src/tests/openai-thinking.test.ts index 2f22c0b..e7c1aa9 100644 --- a/src/tests/openai-thinking.test.ts +++ b/src/tests/openai-thinking.test.ts @@ -4,33 +4,79 @@ import { buildThinkingRequestOptions } from "../openai-thinking"; test("buildThinkingRequestOptions explicitly disables thinking", () => { assert.deepEqual(buildThinkingRequestOptions(false, "https://api.deepseek.com"), { - thinking: { type: "disabled" }, + thinking: { type: "disabled" } }); }); test("buildThinkingRequestOptions uses the same disabled payload for volces endpoints", () => { - assert.deepEqual(buildThinkingRequestOptions(false, "https://ark.cn-beijing.volces.com/api/v3"), { - thinking: { type: "disabled" }, - }); + assert.deepEqual( + buildThinkingRequestOptions(false, "https://ark.cn-beijing.volces.com/api/v3"), + { + thinking: { type: "disabled" } + } + ); }); test("buildThinkingRequestOptions enables thinking with default reasoning effort", () => { - assert.deepEqual(buildThinkingRequestOptions(true, "https://api.deepseek.com"), { - thinking: { type: "enabled" }, - extra_body: { reasoning_effort: "max" }, - }); + assert.deepEqual( + buildThinkingRequestOptions(true, "https://api.deepseek.com"), + { + thinking: { type: "enabled" }, + reasoning_effort: "xhigh" + } + ); }); test("buildThinkingRequestOptions uses the same enabled payload for volces endpoints", () => { - assert.deepEqual(buildThinkingRequestOptions(true, "https://ark.cn-beijing.volces.com/api/v3"), { - thinking: { type: "enabled" }, - extra_body: { reasoning_effort: "max" }, - }); + assert.deepEqual( + buildThinkingRequestOptions(true, "https://ark.cn-beijing.volces.com/api/v3"), + { + thinking: { type: "enabled" }, + reasoning_effort: "xhigh" + } + ); }); test("buildThinkingRequestOptions accepts high reasoning effort", () => { - assert.deepEqual(buildThinkingRequestOptions(true, "https://api.deepseek.com", "high"), { - thinking: { type: "enabled" }, - extra_body: { reasoning_effort: "high" }, - }); + assert.deepEqual( + buildThinkingRequestOptions(true, "https://api.deepseek.com", "high"), + { + thinking: { type: "enabled" }, + reasoning_effort: "high" + } + ); +}); + +test("buildThinkingRequestOptions omits provider routing when only ZDR is configured for isolation", () => { + assert.deepEqual( + buildThinkingRequestOptions(true, "https://openrouter.ai/api/v1", "low", undefined, true), + { + reasoning: { effort: "low" } + } + ); +}); + +test("buildThinkingRequestOptions pins providers and disables fallback routing without privacy filters", () => { + assert.deepEqual( + buildThinkingRequestOptions(true, "https://openrouter.ai/api/v1", "low", "deepinfra", true), + { + reasoning: { effort: "low" }, + provider: { + only: ["deepinfra"], + allow_fallbacks: false + } + } + ); +}); + +test("buildThinkingRequestOptions omits DeepSeek thinking fields for OpenRouter when disabled", () => { + assert.deepEqual( + buildThinkingRequestOptions(false, "https://openrouter.ai/api/v1", "low", "siliconflow", true), + { + provider: { + only: ["siliconflow"], + allow_fallbacks: false + } + } + ); }); diff --git a/src/tests/privacy-guard.test.ts b/src/tests/privacy-guard.test.ts new file mode 100644 index 0000000..98464c9 --- /dev/null +++ b/src/tests/privacy-guard.test.ts @@ -0,0 +1,182 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + assertNoHighRiskSecretsForModel, + sanitizeForProviderStrict, + sanitizeForModelPipeline, + sanitizeToolCallsForReplay +} from "../privacy-guard"; + +test("sanitizeForModelPipeline redacts tool output and marks high-risk secrets", () => { + const result = sanitizeForModelPipeline({ + output: + "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature " + + "jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature " + + "path=C:\\Users\\Simeon\\Documents\\secret.txt", + metadata: { + key: "api_key=sk-or-abcdef1234567890" + } + }); + + assert.equal(result.redactedSensitiveContent, true); + const value = result.value as { output: string; metadata: { key: string } }; + assert.match(value.output, /\[REDACTED_JWT\]/); + assert.match(value.output, /token=\[REDACTED_SECRET\]/); + assert.match(value.output, /\[REDACTED_PATH\]/); + assert.match(value.metadata.key, /\[REDACTED_SECRET\]/); + assert.doesNotMatch(JSON.stringify(result.value), /eyJhbGci/); + assert.doesNotMatch(JSON.stringify(result.value), /sk-or-/); +}); + +test("sanitizeForModelPipeline redacts private keys before model replay", () => { + const result = sanitizeForModelPipeline({ + output: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----" + }); + + assert.equal(result.redactedSensitiveContent, true); + assert.deepEqual(result.value, { output: "[REDACTED_PRIVATE_KEY]" }); +}); + +test("sanitizeToolCallsForReplay redacts function arguments", () => { + const sanitized = sanitizeToolCallsForReplay([ + { + id: "call_1", + type: "function", + function: { + name: "read", + arguments: "{\"file_path\":\"C:\\\\repo\\\\secret.env\"}" + } + } + ]); + + assert.deepEqual(sanitized, [ + { + id: "call_1", + type: "function", + function: { + name: "read", + arguments: "{\"redacted\":true}" + } + } + ]); +}); + +test("assertNoHighRiskSecretsForModel does not block outbound model requests", () => { + assert.doesNotThrow( + () => + assertNoHighRiskSecretsForModel({ + messages: [ + { + role: "tool", + content: "secret=sk-or-abcdef1234567890" + } + ] + }), + ); +}); + +test("assertNoHighRiskSecretsForModel allows generic test passwords", () => { + assert.doesNotThrow(() => + assertNoHighRiskSecretsForModel({ + messages: [ + { + role: "user", + content: "ok password: 454525234" + } + ] + }) + ); +}); + +test("sanitizeForModelPipeline redacts generic password assignments without stopping", () => { + const result = sanitizeForModelPipeline({ + output: "password: 454525234", + userContent: "im giving test password 1234523 note it" + }); + + assert.equal(result.redactedSensitiveContent, false); + assert.deepEqual(result.value, { + output: "password:[REDACTED_SECRET]", + userContent: "im giving test password [REDACTED_SECRET] note it" + }); +}); + +test("sanitizeForModelPipeline redacts unknown high-entropy tokens without blocking", () => { + const token = "A7fK9pQ2rT6vX1mN8bC4dE5gH3jL0sZyWqR"; + const result = sanitizeForModelPipeline({ + output: `session token ${token}` + }); + + assert.equal(result.redactedSensitiveContent, true); + assert.deepEqual(result.value, { + output: "session token [REDACTED_HIGH_ENTROPY_SECRET]" + }); +}); + +test("sanitizeForModelPipeline redacts test tokens without stopping", () => { + const token = "A7fK9pQ2rT6vX1mN8bC4dE5gH3jL0sZyWqR"; + const result = sanitizeForModelPipeline({ + output: `fixture test token ${token}`, + metadata: { + key: "example api_key=sk-or-abcdef1234567890" + } + }); + + assert.equal(result.redactedSensitiveContent, false); + assert.deepEqual(result.value, { + output: "fixture test token [REDACTED_HIGH_ENTROPY_SECRET]", + metadata: { + key: "example api_key=[REDACTED_SECRET]" + } + }); +}); + +test("sanitizeForModelPipeline does not flag low-entropy placeholder strings", () => { + const placeholder = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const result = sanitizeForModelPipeline({ + output: `placeholder ${placeholder}` + }); + + assert.equal(result.redactedSensitiveContent, false); + assert.deepEqual(result.value, { + output: `placeholder ${placeholder}` + }); +}); + +test("sanitizeForProviderStrict redacts credentials but preserves paths and ordinary code context", () => { + const result = sanitizeForProviderStrict({ + messages: [ + { + role: "tool", + content: + "file C:\\Users\\Simeon\\Documents\\repo\\main.cpp has token_count=42 " + + "and api_key=sk-or-abcdef1234567890" + }, + { + role: "tool", + content: + "read /home/simeon/repo/src/main.cpp and password: local-test-value " + + "grep PRODUCTION_PASSWORD\\|Prod34126412" + }, + { + role: "assistant", + content: + "#define PRODUCTION_PASSWORD !Prod34126412\n" + + "Babe... it's literally `!Prod34126412` wrapped in `PRODUCTION_PASSWORD`." + } + ] + }); + + assert.equal(result.redactedSensitiveContent, true); + const serialized = JSON.stringify(result.value); + assert.match(serialized, /C:\\\\Users\\\\Simeon\\\\Documents\\\\repo\\\\main\.cpp/); + assert.match(serialized, /\/home\/simeon\/repo\/src\/main\.cpp/); + assert.match(serialized, /token_count=42/); + assert.match(serialized, /PRODUCTION_PASSWORD/); + assert.doesNotMatch(serialized, /local-test-value/); + assert.doesNotMatch(serialized, /Prod34126412/); + assert.doesNotMatch(serialized, /!Prod/); + assert.doesNotMatch(serialized, /sk-or-/); + assert.match(serialized, /\[REDACTED_API_KEY\]/); + assert.match(serialized, /\[REDACTED_SECRET\]/); +}); diff --git a/src/tests/prompt.test.ts b/src/tests/prompt.test.ts index 664fdec..cce8d5d 100644 --- a/src/tests/prompt.test.ts +++ b/src/tests/prompt.test.ts @@ -7,7 +7,9 @@ test("getTools always includes WebSearch", () => { assert.equal(names.includes("WebSearch"), true); }); -test("getSystemPrompt always includes WebSearch docs", () => { +test("getSystemPrompt includes compact tool guidance without full tool docs", () => { const prompt = getSystemPrompt("/tmp/project"); - assert.equal(prompt.includes("## WebSearch"), true); + assert.equal(prompt.includes("# Tool Usage"), true); + assert.equal(prompt.includes("Available tool schemas are provided separately"), true); + assert.equal(prompt.includes("## WebSearch"), false); }); diff --git a/src/tests/promptBuffer.test.ts b/src/tests/promptBuffer.test.ts index 67ac23a..ebad6f0 100644 --- a/src/tests/promptBuffer.test.ts +++ b/src/tests/promptBuffer.test.ts @@ -5,7 +5,6 @@ import { backspace, deleteForward, deleteWordBefore, - deleteWordAfter, getCurrentSlashToken, insertText, killLine, @@ -16,7 +15,7 @@ import { moveRight, moveWordLeft, moveWordRight, - moveUp, + moveUp } from "../ui"; test("insertText appends text and advances the cursor", () => { @@ -95,12 +94,6 @@ test("deleteWordBefore removes the previous word and any adjacent whitespace", ( assert.equal(result.cursor, 4); }); -test("deleteWordAfter removes the next word and leading whitespace", () => { - const result = deleteWordAfter({ text: "ask the model now", cursor: 3 }); - assert.equal(result.text, "ask model now"); - assert.equal(result.cursor, 3); -}); - test("getCurrentSlashToken returns the slash word at the cursor", () => { const buffer = { text: "/skill", cursor: 6 }; assert.equal(getCurrentSlashToken(buffer), "/skill"); diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index a17d7a4..9a99009 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -16,8 +16,7 @@ import { parseTerminalInput, removeCurrentSlashToken, toggleSkillSelection, - renderBufferWithCursor, - buildInitPromptSubmission, + renderBufferWithCursor } from "../ui"; import type { SkillInfo } from "../session"; @@ -61,20 +60,6 @@ test("parseTerminalInput recognizes word navigation modifiers", () => { assert.equal(metaRight.key.meta, true); }); -test("parseTerminalInput keeps DEL payload for meta+backspace", () => { - const { input, key } = parseTerminalInput("\u001B\u007F"); - assert.equal(input, "\u007F"); - assert.equal(key.meta, true); - assert.equal(key.backspace, false); -}); - -test("parseTerminalInput keeps BS payload for meta+backspace", () => { - const { input, key } = parseTerminalInput("\u001B\b"); - assert.equal(input, "\b"); - assert.equal(key.meta, true); - assert.equal(key.backspace, false); -}); - test("parseTerminalInput recognizes shifted return sequences", () => { const { input, key } = parseTerminalInput("\u001B\r"); assert.equal(input, "\r"); @@ -106,17 +91,6 @@ test("formatImageAttachmentStatus formats the image count label", () => { assert.equal(IMAGE_ATTACHMENT_CLEAR_HINT, "ctrl+x clear images"); }); -test("buildInitPromptSubmission preserves manually selected skills", () => { - const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" }; - - assert.deepEqual(buildInitPromptSubmission([skill]), { - text: "/init", - imageUrls: [], - selectedSkills: [skill], - }); - assert.deepEqual(buildInitPromptSubmission([]), { text: "/init", imageUrls: [], selectedSkills: undefined }); -}); - test("selected skill helpers format, dedupe, toggle, and clear slash tokens", () => { const skill: SkillInfo = { name: "skill-writer", path: "/skills/skill-writer/SKILL.md", description: "Write skills" }; const other: SkillInfo = { name: "code-review", path: "/skills/code-review/SKILL.md", description: "Review code" }; @@ -171,4 +145,4 @@ test("getPromptCursorPlacement accounts for multiline buffer rows", () => { 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 }); -}); +}); \ No newline at end of file diff --git a/src/tests/session.test.ts b/src/tests/session.test.ts index 64bcb9d..c65958f 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -7,6 +7,7 @@ import { SessionManager, type SessionMessage } from "../session"; const originalFetch = globalThis.fetch; const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; const tempDirs: string[] = []; afterEach(() => { @@ -16,6 +17,11 @@ afterEach(() => { } 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(); @@ -31,11 +37,11 @@ test("SessionManager preserves structured system content when building OpenAI me createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false, + thinkingEnabled: false }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {}, + onAssistantMessage: () => {} }); const messages: SessionMessage[] = [ @@ -47,15 +53,15 @@ test("SessionManager preserves structured system content when building OpenAI me contentParams: [ { type: "image_url", - image_url: { url: "data:image/png;base64,abc123" }, - }, + 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", - }, + updateTime: "2026-01-01T00:00:00.000Z" + } ]; const openAIMessages = (manager as any).buildOpenAIMessages(messages) as Array<{ @@ -69,8 +75,8 @@ test("SessionManager preserves structured system content when building OpenAI me { type: "text", text: "The read tool has loaded `pixel.png`." }, { type: "image_url", - image_url: { url: "data:image/png;base64,abc123" }, - }, + image_url: { url: "data:image/png;base64,abc123" } + } ]); }); @@ -80,11 +86,11 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false, + thinkingEnabled: false }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {}, + onAssistantMessage: () => {} }); const message = (manager as any).buildAssistantMessage( @@ -94,8 +100,8 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" }, - }, + function: { name: "read", arguments: "{}" } + } ], "" ) as SessionMessage; @@ -105,10 +111,10 @@ test("SessionManager preserves empty reasoning content on assistant tool calls", { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" }, - }, + function: { name: "read", arguments: "{}" } + } ], - reasoning_content: "", + reasoning_content: "" }); const openAIMessages = (manager as any).buildOpenAIMessages([message], true) as Array<{ @@ -124,11 +130,11 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false, + thinkingEnabled: false }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {}, + onAssistantMessage: () => {} }); const messages: SessionMessage[] = [ @@ -143,15 +149,15 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten { id: "call-1", type: "function", - function: { name: "read", arguments: "{}" }, - }, - ], + function: { name: "read", arguments: "{}" } + } + ] }, compacted: false, visible: false, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }, + updateTime: "2026-01-01T00:00:00.000Z" + } ]; const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true) as Array<{ @@ -162,7 +168,10 @@ test("SessionManager repairs legacy thinking tool calls missing reasoning conten }>; assert.equal(thinkingMessages[0]?.reasoning_content, ""); - assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); + assert.equal( + Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), + false + ); }); test("SessionManager replays normal assistant messages with reasoning content in thinking mode", () => { @@ -171,11 +180,11 @@ test("SessionManager replays normal assistant messages with reasoning content in createOpenAIClient: () => ({ client: null, model: "test-model", - thinkingEnabled: false, + thinkingEnabled: false }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {}, + onAssistantMessage: () => {} }); const messages: SessionMessage[] = [ @@ -189,8 +198,8 @@ test("SessionManager replays normal assistant messages with reasoning content in compacted: false, visible: true, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }, + updateTime: "2026-01-01T00:00:00.000Z" + } ]; const thinkingMessages = (manager as any).buildOpenAIMessages(messages, true) as Array<{ @@ -201,13 +210,16 @@ test("SessionManager replays normal assistant messages with reasoning content in }>; assert.equal(thinkingMessages[0]?.reasoning_content, ""); - assert.equal(Object.prototype.hasOwnProperty.call(nonThinkingMessages[0] ?? {}, "reasoning_content"), false); + 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-"); - process.env.HOME = home; + setTestHome(home); const projectCode = workspace.replace(/[\\/]/g, "-").replace(/:/g, ""); const projectDir = path.join(home, ".deepcode", "projects", projectCode); @@ -223,9 +235,9 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", ( status: "completed", usage: { total_tokens: 123 }, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", - }, - ], + updateTime: "2026-01-01T00:00:00.000Z" + } + ] }), "utf8" ); @@ -238,7 +250,7 @@ test("SessionManager normalizes legacy sessions without activeTokens to zero", ( test("SessionManager marks skills loaded from existing session messages", async () => { const workspace = createTempDir("deepcode-loaded-skills-workspace-"); const home = createTempDir("deepcode-loaded-skills-home-"); - process.env.HOME = home; + setTestHome(home); const skillDir = path.join(home, ".agents", "skills", "lessweb-starter"); fs.mkdirSync(skillDir, { recursive: true }); @@ -269,15 +281,16 @@ test("SessionManager marks skills loaded from existing session messages", async name: "lessweb-starter", path: "~/.agents/skills/lessweb-starter/SKILL.md", description: "Create Lessweb projects", - isLoaded: true, - }, - }, + 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"); + const loadedSkill = (await manager.listSkills("loaded-session")) + .find((skill) => skill.name === "lessweb-starter"); assert.equal(loadedSkill?.isLoaded, true); }); @@ -285,7 +298,7 @@ test("SessionManager marks skills loaded from existing session messages", async 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-"); - process.env.HOME = home; + setTestHome(home); const userSkillDir = path.join(home, ".agents", "skills", "shared"); fs.mkdirSync(userSkillDir, { recursive: true }); @@ -322,10 +335,10 @@ test("SessionManager lists project skills from .agents with legacy .deepcode com assert.equal(sharedSkill?.description, "Project .agents skill"); }); -test("createSession stores /init and sends the active .deepcode project AGENTS path to the LLM", async () => { +test("createSession expands /init with the active .deepcode project AGENTS path", async () => { const workspace = createTempDir("deepcode-init-deepcode-workspace-"); const home = createTempDir("deepcode-init-deepcode-home-"); - process.env.HOME = home; + setTestHome(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.mkdirSync(path.join(workspace, ".deepcode"), { recursive: true }); @@ -338,26 +351,45 @@ test("createSession stores /init and sends the active .deepcode project AGENTS p 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) 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")); + assert.match(userMessage?.content ?? "", /Update \.\/\.deepcode\/AGENTS\.md/); + assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); + assert.ok(systemContents.some((content) => content.includes('path="./.deepcode/AGENTS.md"'))); + assert.ok(systemContents.some((content) => content.includes("deepcode project instructions"))); + assert.ok(!systemContents.some((content) => content.includes("root project instructions"))); +}); + +test("createSession forces user AGENTS instructions when no project AGENTS file exists", async () => { + const workspace = createTempDir("deepcode-user-agents-workspace-"); + const home = createTempDir("deepcode-user-agents-home-"); + setTestHome(home); + process.env.USERPROFILE = 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 forced instructions", "utf8"); + + const manager = createSessionManager(workspace, "machine-id-user-agents"); + (manager as any).activateSession = async () => {}; + + const sessionId = await manager.createSession({ text: "first prompt" }); + const systemContents = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "system") + .map((message) => message.content ?? ""); + + assert.ok(systemContents.some((content) => content.includes("You must follow the AGENTS.md instructions"))); + assert.ok(systemContents.some((content) => content.includes('path="~/.deepcode/AGENTS.md"'))); + assert.ok(systemContents.some((content) => content.includes("user forced instructions"))); }); -test("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { +test("replySession expands /init with the active root project AGENTS path", async () => { const workspace = createTempDir("deepcode-init-root-workspace-"); const home = createTempDir("deepcode-init-root-home-"); - process.env.HOME = home; + setTestHome(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.writeFileSync(path.join(workspace, "AGENTS.md"), "root project instructions", "utf8"); @@ -367,24 +399,18 @@ test("replySession stores /init and sends the active root project AGENTS path to 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 userMessages = manager + .listSessionMessages(sessionId) + .filter((message) => message.role === "user"); const replyMessage = userMessages[userMessages.length - 1]; - const openAIMessages = (manager as any).buildOpenAIMessages(messages, false) 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/); + assert.match(replyMessage?.content ?? "", /Update \.\/AGENTS\.md/); }); -test("createSession stores /init and sends generate prompt when no project AGENTS file is effective", async () => { +test("createSession expands /init as generate when no project AGENTS file is effective", async () => { const workspace = createTempDir("deepcode-init-generate-workspace-"); const home = createTempDir("deepcode-init-generate-home-"); - process.env.HOME = home; + setTestHome(home); globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; fs.mkdirSync(path.join(home, ".deepcode"), { recursive: true }); @@ -394,31 +420,23 @@ test("createSession stores /init and sends generate prompt when no project AGENT (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) as Array<{ - role: string; - content: string; - }>; - const openAIUserMessage = openAIMessages.find((message) => message.role === "user"); + const userMessage = manager + .listSessionMessages(sessionId) + .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/); + assert.match(userMessage?.content ?? "", /Generate a file named \.\/AGENTS\.md/); + assert.doesNotMatch(userMessage?.content ?? "", /Update \.\/AGENTS\.md/); }); -test("createSession reports a new prompt with the machineId token", async () => { +test("createSession activates the session without making external fetch calls", async () => { const workspace = createTempDir("deepcode-session-workspace-"); const home = createTempDir("deepcode-session-home-"); - process.env.HOME = home; + setTestHome(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; + return { ok: true, text: async () => "" } as Response; }) as typeof fetch; const manager = createSessionManager(workspace, "machine-id-123"); @@ -432,25 +450,18 @@ test("createSession reports a new prompt with the machineId token", async () => 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.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); - assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-123"); + assert.equal(fetchCalls.length, 0); }); -test("replySession reports a new prompt with the machineId token", async () => { +test("replySession does not make external fetch calls", async () => { const workspace = createTempDir("deepcode-reply-workspace-"); const home = createTempDir("deepcode-reply-home-"); - process.env.HOME = home; + setTestHome(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; + return { ok: true, text: async () => "" } as Response; }) as typeof fetch; const manager = createSessionManager(workspace, "machine-id-456"); @@ -463,23 +474,18 @@ test("replySession reports a new prompt with the machineId token", async () => { 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.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), {}); - assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-456"); + assert.equal(fetchCalls.length, 0); }); 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-"); - process.env.HOME = home; + setTestHome(home); - globalThis.fetch = (async () => - ({ - ok: true, - text: async () => "", - }) as Response) as typeof fetch; + 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 () => {}; @@ -492,8 +498,8 @@ test("replySession preserves raw session messages when a previous tool call is p { id: "call-1", type: "function", - function: { name: "bash", arguments: '{"command":"sleep 100"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"sleep 100\"}" } + } ], "" ) as SessionMessage; @@ -506,10 +512,7 @@ test("replySession preserves raw session messages when a previous tool call is p 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 - ); + 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", () => { @@ -521,8 +524,8 @@ test("buildOpenAIMessages inserts interrupted results for missing tool messages" { id: "call-1", type: "function", - function: { name: "bash", arguments: '{"command":"sleep 100"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"sleep 100\"}" } + } ], "" ) as SessionMessage; @@ -551,8 +554,8 @@ test("buildOpenAIMessages keeps only the first non-interrupted tool result for a { id: "call-1", type: "function", - function: { name: "bash", arguments: '{"command":"date"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"date\"}" } + } ], "" ) as SessionMessage; @@ -560,7 +563,7 @@ test("buildOpenAIMessages keeps only the first non-interrupted tool result for a "session-1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "2026-05-07 星期四\n" }), - { name: "bash", arguments: '{"command":"date"}' } + { name: "bash", arguments: "{\"command\":\"date\"}" } ) as SessionMessage; const interruptedToolMessage = (manager as any).buildToolMessage( "session-1", @@ -569,9 +572,9 @@ test("buildOpenAIMessages keeps only the first non-interrupted tool result for a ok: false, name: "bash", error: "Previous tool call did not complete.", - metadata: { interrupted: true }, + metadata: { interrupted: true } }), - { name: "bash", arguments: '{"command":"date"}' } + { name: "bash", arguments: "{\"command\":\"date\"}" } ) as SessionMessage; const openAIMessages = (manager as any).buildOpenAIMessages( @@ -595,8 +598,8 @@ test("buildOpenAIMessages prefers a later real tool result over an earlier inter { id: "call-1", type: "function", - function: { name: "bash", arguments: '{"command":"date"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"date\"}" } + } ], "" ) as SessionMessage; @@ -607,15 +610,15 @@ test("buildOpenAIMessages prefers a later real tool result over an earlier inter ok: false, name: "bash", error: "Previous tool call did not complete.", - metadata: { interrupted: true }, + metadata: { interrupted: true } }), - { name: "bash", arguments: '{"command":"date"}' } + { 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"}' } + { name: "bash", arguments: "{\"command\":\"date\"}" } ) as SessionMessage; const openAIMessages = (manager as any).buildOpenAIMessages( @@ -636,17 +639,15 @@ test("buildOpenAIMessages ignores orphan tool messages", () => { "session-1", "call-orphan", JSON.stringify({ ok: true, name: "bash", output: "orphan" }), - { name: "bash", arguments: '{"command":"echo orphan"}' } + { name: "bash", arguments: "{\"command\":\"echo orphan\"}" } ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([userMessage, orphanToolMessage], false) as Array<{ - role: string; - }>; + const openAIMessages = (manager as any).buildOpenAIMessages( + [userMessage, orphanToolMessage], + false + ) as Array<{ role: string }>; - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["user"] - ); + assert.deepEqual(openAIMessages.map((message) => message.role), ["user"]); }); test("buildOpenAIMessages moves a later paired tool message behind its assistant", () => { @@ -658,8 +659,8 @@ test("buildOpenAIMessages moves a later paired tool message behind its assistant { id: "call-1", type: "function", - function: { name: "bash", arguments: '{"command":"date"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"date\"}" } + } ], "" ) as SessionMessage; @@ -668,7 +669,7 @@ test("buildOpenAIMessages moves a later paired tool message behind its assistant "session-1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "paired later" }), - { name: "bash", arguments: '{"command":"date"}' } + { name: "bash", arguments: "{\"command\":\"date\"}" } ) as SessionMessage; const openAIMessages = (manager as any).buildOpenAIMessages( @@ -676,10 +677,7 @@ test("buildOpenAIMessages moves a later paired tool message behind its assistant false ) as Array<{ role: string; content: string }>; - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool", "user"] - ); + assert.deepEqual(openAIMessages.map((message) => message.role), ["assistant", "tool", "user"]); assert.match(openAIMessages[1]?.content ?? "", /paired later/); }); @@ -692,13 +690,13 @@ test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { { id: "call-1", type: "function", - function: { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' }, + function: { name: "read", arguments: "{\"file_path\":\"/tmp/a.txt\"}" } }, { id: "call-2", type: "function", - function: { name: "bash", arguments: '{"command":"pwd"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"pwd\"}" } + } ], "" ) as SessionMessage; @@ -706,13 +704,13 @@ test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { "session-1", "call-1", JSON.stringify({ ok: true, name: "read", content: "file content" }), - { name: "read", arguments: '{"file_path":"/tmp/a.txt"}' } + { 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"}' } + { name: "bash", arguments: "{\"command\":\"pwd\"}" } ) as SessionMessage; const userMessage = buildTestMessage("user-after-complete-tools", "session-1", "user", "thanks"); @@ -721,18 +719,12 @@ test("buildOpenAIMessages preserves a complete multi-tool happy path", () => { false ) as Array<{ role: string; content: string; tool_call_id?: string }>; - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool", "tool", "user"] - ); + 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 - ); + assert.equal(openAIMessages.some((message) => message.content.includes("Previous tool call did not complete.")), false); }); test("buildOpenAIMessages preserves a real failed tool result", () => { @@ -744,8 +736,8 @@ test("buildOpenAIMessages preserves a real failed tool result", () => { { id: "call-1", type: "function", - function: { name: "bash", arguments: '{"command":"false"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"false\"}" } + } ], "" ) as SessionMessage; @@ -753,19 +745,15 @@ test("buildOpenAIMessages preserves a real failed tool result", () => { "session-1", "call-1", JSON.stringify({ ok: false, name: "bash", error: "Command failed", metadata: { exitCode: 1 } }), - { name: "bash", arguments: '{"command":"false"}' } + { name: "bash", arguments: "{\"command\":\"false\"}" } ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([assistantMessage, failedToolMessage], false) as Array<{ - role: string; - content: string; - tool_call_id?: string; - }>; + const openAIMessages = (manager as any).buildOpenAIMessages( + [assistantMessage, failedToolMessage], + false + ) as Array<{ role: string; content: string; tool_call_id?: string }>; - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool"] - ); + 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/); @@ -780,13 +768,13 @@ test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messag { id: "call-1", type: "function", - function: { name: "read", arguments: '{"file_path":"/tmp/missing.txt"}' }, + function: { name: "read", arguments: "{\"file_path\":\"/tmp/missing.txt\"}" } }, { id: "call-2", type: "function", - function: { name: "bash", arguments: '{"command":"pwd"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"pwd\"}" } + } ], "" ) as SessionMessage; @@ -794,19 +782,19 @@ test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messag "session-1", "call-orphan", JSON.stringify({ ok: true, name: "bash", output: "orphan" }), - { name: "bash", arguments: '{"command":"echo 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"}' } + { 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"}' } + { name: "bash", arguments: "{\"command\":\"pwd\"}" } ) as SessionMessage; const userMessage = buildTestMessage("user-after-mixed-tools", "session-1", "user", "continue"); @@ -816,24 +804,12 @@ test("buildOpenAIMessages repairs mixed missing duplicate and orphan tool messag ) 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.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 - ); + 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", () => { @@ -842,7 +818,7 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista "session-1", "call-1", JSON.stringify({ ok: true, name: "bash", output: "too early" }), - { name: "bash", arguments: '{"command":"date"}' } + { name: "bash", arguments: "{\"command\":\"date\"}" } ) as SessionMessage; const assistantMessage = (manager as any).buildAssistantMessage( "session-1", @@ -851,22 +827,18 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista { id: "call-1", type: "function", - function: { name: "bash", arguments: '{"command":"date"}' }, - }, + function: { name: "bash", arguments: "{\"command\":\"date\"}" } + } ], "" ) as SessionMessage; - const openAIMessages = (manager as any).buildOpenAIMessages([earlyToolMessage, assistantMessage], false) as Array<{ - role: string; - content: string; - tool_call_id?: string; - }>; + const openAIMessages = (manager as any).buildOpenAIMessages( + [earlyToolMessage, assistantMessage], + false + ) as Array<{ role: string; content: string; tool_call_id?: string }>; - assert.deepEqual( - openAIMessages.map((message) => message.role), - ["assistant", "tool"] - ); + 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/); @@ -875,7 +847,7 @@ test("buildOpenAIMessages ignores tool messages that appear before their assista 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-"); - process.env.HOME = home; + setTestHome(home); const responses = [ createChatResponse("first", { @@ -885,7 +857,7 @@ test("SessionManager accumulates response usage while active tokens track the la prompt_tokens_details: { cached_tokens: 7 }, completion_tokens_details: { reasoning_tokens: 3 }, prompt_cache_hit_tokens: 7, - prompt_cache_miss_tokens: 3, + prompt_cache_miss_tokens: 3 }), createChatResponse("second", { prompt_tokens: 20, @@ -894,8 +866,8 @@ test("SessionManager accumulates response usage while active tokens track the la prompt_tokens_details: { cached_tokens: 11 }, completion_tokens_details: { reasoning_tokens: 4 }, prompt_cache_hit_tokens: 11, - prompt_cache_miss_tokens: 9, - }), + prompt_cache_miss_tokens: 9 + }) ]; const manager = createMockedClientSessionManager(workspace, responses); @@ -917,24 +889,24 @@ test("SessionManager accumulates response usage while active tokens track the la 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-"); - process.env.HOME = home; + setTestHome(home); const responses = [ createChatResponse("large", { prompt_tokens: 139_990, completion_tokens: 10, - total_tokens: 140_000, + total_tokens: 140_000 }), createChatResponse("summary", { prompt_tokens: 100, completion_tokens: 23, - total_tokens: 123, + total_tokens: 123 }), createChatResponse("after compact", { prompt_tokens: 5, completion_tokens: 2, - total_tokens: 7, - }), + total_tokens: 7 + }) ]; const manager = createMockedClientSessionManager(workspace, responses); @@ -954,7 +926,7 @@ test("SessionManager resets active tokens to latest post-compaction response usa test("SessionManager streams chat completions and counts reasoning progress", async () => { const workspace = createTempDir("deepcode-stream-workspace-"); const home = createTempDir("deepcode-stream-home-"); - process.env.HOME = home; + setTestHome(home); const progressEvents: Array<{ phase: string; @@ -975,13 +947,13 @@ test("SessionManager streams chat completions and counts reasoning progress", as usage: { prompt_tokens: 2, completion_tokens: 3, - total_tokens: 5, - }, - }, + total_tokens: 5 + } + } ]); - }, - }, - }, + } + } + } }; const manager = new SessionManager({ @@ -990,7 +962,7 @@ test("SessionManager streams chat completions and counts reasoning progress", as client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false, + thinkingEnabled: false }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, @@ -999,13 +971,15 @@ test("SessionManager streams chat completions and counts reasoning progress", as progressEvents.push({ phase: progress.phase, estimatedTokens: progress.estimatedTokens, - formattedTokens: progress.formattedTokens, + formattedTokens: progress.formattedTokens }); - }, + } }); const sessionId = await manager.createSession({ text: "" }); - const assistantMessage = manager.listSessionMessages(sessionId).find((message) => message.role === "assistant"); + const assistantMessage = manager + .listSessionMessages(sessionId) + .find((message) => message.role === "assistant"); assert.equal(assistantMessage?.content, "hello"); assert.equal((assistantMessage?.messageParams as any)?.reasoning_content, "思考"); @@ -1018,16 +992,240 @@ test("SessionManager streams chat completions and counts reasoning progress", as assert.equal(progressEvents[2]?.formattedTokens, "3"); }); +test("SessionManager merges streamed tool call chunks without provider indexes", async () => { + const manager = createSessionManager(process.cwd(), "machine-id-unindexed-tool-stream"); + const client = { + chat: { + completions: { + create: async () => + createChatStreamResponse([ + { + choices: [ + { + delta: { + tool_calls: [ + { + id: "call-1", + type: "function", + function: { name: "bash", arguments: "{\"command\":\"echo" } + } + ] + } + } + ] + }, + { + choices: [ + { + delta: { + tool_calls: [ + { + function: { arguments: " hi\"}" } + } + ] + } + } + ] + } + ]) + } + } + }; + + const response = await (manager as any).createChatCompletionStream( + client, + { model: "test-model", messages: [] } + ); + const toolCalls = response.choices?.[0]?.message?.tool_calls; + + assert.equal(toolCalls?.length, 1); + assert.deepEqual(toolCalls?.[0], { + id: "call-1", + type: "function", + function: { name: "bash", arguments: "{\"command\":\"echo hi\"}" } + }); +}); + +test("SessionManager keeps OpenRouter cache control and replays tool results across one prompt", async () => { + const workspace = createTempDir("deepcode-tool-chain-workspace-"); + const home = createTempDir("deepcode-tool-chain-home-"); + setTestHome(home); + const targetPath = path.join(workspace, "cache-chain.txt"); + const requests: Record[] = []; + const responses = [ + createToolCallResponse("call-write", "write", { + file_path: targetPath, + content: "remembered from tool one" + }), + createToolCallResponse("call-read", "read", { + file_path: targetPath + }), + createChatResponse("done", { + prompt_tokens: 10, + completion_tokens: 2, + total_tokens: 12, + prompt_tokens_details: { cached_tokens: 6 } + }) + ]; + const client = { + chat: { + completions: { + create: async (request: Record) => { + requests.push(request); + 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: "provider/model-under-test", + baseURL: "https://openrouter.ai/api/v1", + thinkingEnabled: false + }), + getResolvedSettings: () => ({}), + renderMarkdown: (text) => text, + onAssistantMessage: () => {} + }); + + const sessionId = await manager.createSession({ text: "" }); + const secondRequestMessages = requests[1]?.messages as Array<{ role: string; content?: string }> | undefined; + const sessionMessages = manager.listSessionMessages(sessionId); + + assert.equal(requests.length, 3); + assert.deepEqual( + requests.map((request) => request.cache_control), + [ + { type: "ephemeral", ttl: "1h" }, + { type: "ephemeral", ttl: "1h" }, + { type: "ephemeral", ttl: "1h" } + ] + ); + assert.ok( + secondRequestMessages?.some((message) => + message.role === "tool" && + typeof message.content === "string" && + message.content.includes("cache-chain.txt") && + message.content.includes("Created file.") + ) + ); + assert.ok( + sessionMessages.some((message) => + message.role === "tool" && + typeof message.content === "string" && + message.content.includes("remembered from tool one") + ) + ); +}); + +test("SessionManager final HTTP body logging records the exact outbound request when enabled", async () => { + const workspace = createTempDir("deepcode-final-body-workspace-"); + const home = createTempDir("deepcode-final-body-home-"); + setTestHome(home); + const oldLogFlag = process.env.DEEPCODE_LOG_FINAL_HTTP_BODY; + process.env.DEEPCODE_LOG_FINAL_HTTP_BODY = "true"; + const manager = createSessionManager(workspace, "machine-id-final-body"); + const targetPath = path.join(workspace, "exact-path.txt"); + const client = { + chat: { + completions: { + create: async () => createChatResponse("ok", { total_tokens: 1 }) + } + } + }; + + try { + await (manager as any).createChatCompletionStream( + client, + { + model: "provider/model-under-test", + messages: [{ role: "user", content: `read ${targetPath}` }] + }, + undefined, + "session-final-body" + ); + } finally { + if (oldLogFlag === undefined) { + delete process.env.DEEPCODE_LOG_FINAL_HTTP_BODY; + } else { + process.env.DEEPCODE_LOG_FINAL_HTTP_BODY = oldLogFlag; + } + } + + const logPath = path.join(home, ".deepcode", "logs", "final-http-body.jsonl"); + const entries = fs.readFileSync(logPath, "utf8").trim().split(/\r?\n/); + const last = JSON.parse(entries[entries.length - 1] ?? "{}") as { + body?: { messages?: Array<{ content?: string }> }; + }; + + assert.equal(last.body?.messages?.[0]?.content, `read ${targetPath}`); +}); + +test("SessionManager strict provider privacy redacts credentials without redacting replay paths", async () => { + const workspace = createTempDir("deepcode-strict-provider-privacy-workspace-"); + const manager = createSessionManager(workspace, "machine-id-strict-provider-privacy"); + let seenRequest: Record | null = null; + const targetPath = path.join(workspace, "main.cpp"); + const client = { + chat: { + completions: { + create: async (request: Record) => { + seenRequest = request; + return createChatResponse("ok", { total_tokens: 1 }); + } + } + } + }; + + await (manager as any).createChatCompletionStream( + client, + { + model: "provider/model-under-test", + messages: [ + { + role: "tool", + content: + `read ${targetPath} token_count=42 api_key=sk-or-abcdef1234567890 ` + + "grep PRODUCTION_PASSWORD\\|Prod34126412\n" + + "#define PRODUCTION_PASSWORD !Prod34126412\n" + + "Babe... it's literally `!Prod34126412` wrapped in `PRODUCTION_PASSWORD`." + } + ] + }, + undefined, + "session-strict-provider-privacy", + undefined, + "strict" + ); + + const requestText = JSON.stringify(seenRequest); + assert.match(requestText, /main\.cpp/); + assert.match(requestText, /token_count=42/); + assert.match(requestText, /PRODUCTION_PASSWORD/); + assert.doesNotMatch(requestText, /sk-or-/); + assert.doesNotMatch(requestText, /Prod34126412/); + assert.doesNotMatch(requestText, /!Prod/); + assert.match(requestText, /\[REDACTED_API_KEY\]/); + assert.match(requestText, /\[REDACTED_SECRET\]/); +}); + 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-"); - process.env.HOME = home; + setTestHome(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"); + 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: { @@ -1038,9 +1236,9 @@ test("SessionManager cancels skill matching before a session is created", async signal?.addEventListener("abort", () => reject(new APIUserAbortError()), { once: true }); queueMicrotask(() => manager.interruptActiveSession()); }); - }, - }, - }, + } + } + } }; manager = createMockedClientSessionManagerWithClient(workspace, client); @@ -1053,7 +1251,7 @@ test("SessionManager cancels skill matching before a session is created", async test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () => { const workspace = createTempDir("deepcode-api-abort-workspace-"); const home = createTempDir("deepcode-api-abort-home-"); - process.env.HOME = home; + setTestHome(home); let manager: SessionManager; const client = { @@ -1064,19 +1262,18 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = 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, + thinkingEnabled: false }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, @@ -1085,7 +1282,7 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = if (entry.status === "processing") { queueMicrotask(() => manager.interruptActiveSession()); } - }, + } }); await manager.handleUserPrompt({ text: "" }); @@ -1105,11 +1302,11 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa model: "test-model", baseURL: "https://api.deepseek.com", thinkingEnabled: false, - machineId, + machineId }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {}, + onAssistantMessage: () => {} }); } @@ -1121,9 +1318,9 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow const response = responses.shift(); assert.ok(response, "expected a queued chat response"); return response; - }, - }, - }, + } + } + } }; return new SessionManager({ @@ -1132,11 +1329,11 @@ function createMockedClientSessionManager(projectRoot: string, responses: unknow client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false, + thinkingEnabled: false }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {}, + onAssistantMessage: () => {} }); } @@ -1147,11 +1344,11 @@ function createMockedClientSessionManagerWithClient(projectRoot: string, client: client: client as any, model: "test-model", baseURL: "https://api.deepseek.com", - thinkingEnabled: false, + thinkingEnabled: false }), getResolvedSettings: () => ({}), renderMarkdown: (text) => text, - onAssistantMessage: () => {}, + onAssistantMessage: () => {} }); } @@ -1160,7 +1357,35 @@ class APIUserAbortError extends Error {} function createChatResponse(content: string, usage: Record): unknown { return { choices: [{ message: { content } }], - usage, + usage + }; +} + +function createToolCallResponse( + toolCallId: string, + toolName: string, + args: Record, + usage: Record = { total_tokens: 1 } +): unknown { + return { + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: toolCallId, + type: "function", + function: { + name: toolName, + arguments: JSON.stringify(args) + } + } + ] + } + } + ], + usage }; } @@ -1180,7 +1405,7 @@ function buildTestMessage( compacted: false, visible: true, createTime: "2026-01-01T00:00:00.000Z", - updateTime: "2026-01-01T00:00:00.000Z", + updateTime: "2026-01-01T00:00:00.000Z" }; } @@ -1190,6 +1415,11 @@ async function* createChatStreamResponse(chunks: Record[]): Asy } } +function setTestHome(home: string): void { + process.env.HOME = home; + process.env.USERPROFILE = home; +} + function createTempDir(prefix: string): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); tempDirs.push(dir); diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index f2c1ca1..6bedb12 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { buildNotifyEnv, formatDurationSeconds, launchNotifyScript, type NotifySpawn } from "../notify"; -import { applyModelConfigSelection, resolveSettings } from "../settings"; +import { resolveSettings } from "../settings"; test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool", () => { const resolved = resolveSettings( @@ -9,17 +9,17 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool env: { MODEL: "deepseek-v3.2", BASE_URL: "https://example.com/v1", - API_KEY: "sk-test", + API_KEY: "sk-test" }, thinkingEnabled: true, reasoningEffort: "high", debugLogEnabled: true, notify: " /tmp/notify.sh ", - webSearchTool: " /tmp/web-search.sh ", + webSearchTool: " /tmp/web-search.sh " }, { model: "default-model", - baseURL: "https://default.example.com", + baseURL: "https://default.example.com" } ); @@ -31,40 +31,55 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool assert.equal(resolved.debugLogEnabled, true); assert.equal(resolved.notify, "/tmp/notify.sh"); assert.equal(resolved.webSearchTool, "/tmp/web-search.sh"); + assert.equal(resolved.providerPrivacyMode, "off"); }); -test("resolveSettings gives top-level model priority over env MODEL", () => { - const resolved = resolveSettings( - { - model: "deepseek-v4-flash", - env: { - MODEL: "deepseek-v4-pro", - }, - }, - { - model: "default-model", - baseURL: "https://default.example.com", - } +test("resolveSettings enables strict provider privacy only when explicitly configured", () => { + assert.equal( + resolveSettings( + { providerPrivacyMode: "strict" }, + { model: "default-model", baseURL: "https://default.example.com" } + ).providerPrivacyMode, + "strict" + ); + assert.equal( + resolveSettings( + { env: { PROVIDER_PRIVACY: "strict" }, providerPrivacyMode: "off" }, + { model: "default-model", baseURL: "https://default.example.com" } + ).providerPrivacyMode, + "strict" + ); + assert.equal( + resolveSettings( + { env: { providerPrivacyMode: "strict" } }, + { model: "default-model", baseURL: "https://default.example.com" } + ).providerPrivacyMode, + "strict" + ); + assert.equal( + resolveSettings( + { env: { PROVIDER_PRIVACY: "anything-else" } }, + { model: "default-model", baseURL: "https://default.example.com" } + ).providerPrivacyMode, + "off" ); - - assert.equal(resolved.model, "deepseek-v4-flash"); }); test("resolveSettings still accepts legacy env.THINKING and defaults reasoning effort when absent", () => { const resolved = resolveSettings( { env: { - THINKING: "enabled", - }, + THINKING: "enabled" + } }, { model: "default-model", - baseURL: "https://default.example.com", + baseURL: "https://default.example.com" } ); assert.equal(resolved.thinkingEnabled, true); - assert.equal(resolved.reasoningEffort, "max"); + assert.equal(resolved.reasoningEffort, "xhigh"); assert.equal(resolved.model, "default-model"); assert.equal(resolved.baseURL, "https://default.example.com"); }); @@ -73,12 +88,12 @@ test("resolveSettings defaults DeepSeek v4 models to thinking mode", () => { const resolved = resolveSettings( { env: { - MODEL: "deepseek-v4-flash", - }, + MODEL: "deepseek-v4-flash" + } }, { model: "default-model", - baseURL: "https://default.example.com", + baseURL: "https://default.example.com" } ); @@ -90,7 +105,7 @@ test("resolveSettings applies thinking defaults to the fallback model", () => { {}, { model: "deepseek-v4-pro", - baseURL: "https://default.example.com", + baseURL: "https://default.example.com" } ); @@ -102,12 +117,12 @@ test("resolveSettings keeps thinking mode off by default for other models", () = const resolved = resolveSettings( { env: { - MODEL: "deepseek-v3.2", - }, + MODEL: "deepseek-v3.2" + } }, { model: "default-model", - baseURL: "https://default.example.com", + baseURL: "https://default.example.com" } ); @@ -118,111 +133,45 @@ test("resolveSettings allows explicit thinkingEnabled to override model defaults const resolved = resolveSettings( { env: { - MODEL: "deepseek-v4-pro", + MODEL: "deepseek-v4-pro" }, - thinkingEnabled: false, + thinkingEnabled: false }, { model: "default-model", - baseURL: "https://default.example.com", + baseURL: "https://default.example.com" } ); assert.equal(resolved.thinkingEnabled, false); }); -test("resolveSettings defaults invalid reasoning effort to max", () => { +test("resolveSettings defaults invalid reasoning effort to xhigh", () => { const resolved = resolveSettings( { - reasoningEffort: "medium" as never, + reasoningEffort: "medium" as never }, { model: "default-model", - baseURL: "https://default.example.com", + baseURL: "https://default.example.com" } ); - assert.equal(resolved.reasoningEffort, "max"); + assert.equal(resolved.reasoningEffort, "xhigh"); }); -test("applyModelConfigSelection writes model only when the effective model changes or already exists", () => { - const result = applyModelConfigSelection( - { - env: { - MODEL: "deepseek-v4-pro", - }, - thinkingEnabled: false, - }, - { - model: "deepseek-v4-pro", - thinkingEnabled: false, - reasoningEffort: "max", - }, - { - model: "deepseek-v4-pro", - thinkingEnabled: true, - reasoningEffort: "high", - } - ); - - assert.equal(result.changed, true); - assert.equal(result.settings.model, undefined); - assert.equal(result.settings.thinkingEnabled, true); - assert.equal(result.settings.reasoningEffort, "high"); -}); - -test("applyModelConfigSelection persists a new selected model and thinking option", () => { - const result = applyModelConfigSelection( - { - env: { - MODEL: "deepseek-v4-pro", - BASE_URL: "https://api.deepseek.com", - API_KEY: "sk-test", - }, - thinkingEnabled: false, - }, - { - model: "deepseek-v4-pro", - thinkingEnabled: false, - reasoningEffort: "max", - }, - { - model: "deepseek-v4-flash", - thinkingEnabled: true, - reasoningEffort: "high", - } - ); - - assert.equal(result.changed, true); - assert.equal(result.settings.env?.MODEL, "deepseek-v4-pro"); - assert.equal(result.settings.model, "deepseek-v4-flash"); - assert.equal(result.settings.thinkingEnabled, true); - assert.equal(result.settings.reasoningEffort, "high"); -}); - -test("applyModelConfigSelection leaves settings untouched when the effective selection is unchanged", () => { - const result = applyModelConfigSelection( - { - env: { - MODEL: "deepseek-v4-pro", - }, - thinkingEnabled: true, - reasoningEffort: "max", - }, +test("resolveSettings maps max reasoning effort to xhigh", () => { + const resolved = resolveSettings( { - model: "deepseek-v4-pro", - thinkingEnabled: true, - reasoningEffort: "max", + reasoningEffort: "max" as never }, { - model: "deepseek-v4-pro", - thinkingEnabled: true, - reasoningEffort: "max", + model: "default-model", + baseURL: "https://default.example.com" } ); - assert.equal(result.changed, false); - assert.equal(result.settings.model, undefined); + assert.equal(resolved.reasoningEffort, "xhigh"); }); test("formatDurationSeconds preserves sub-second precision and trims trailing zeros", () => { @@ -256,7 +205,7 @@ test("launchNotifyScript passes DURATION and falls back to /bin/sh for non-execu }, unref() { return undefined; - }, + } }; }; diff --git a/src/tests/shell-utils.test.ts b/src/tests/shell-utils.test.ts deleted file mode 100644 index 7d19357..0000000 --- a/src/tests/shell-utils.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - buildDisableExtglobCommand, - getShellKind, - posixPathToWindowsPath, - rewriteWindowsNullRedirect, - windowsPathToPosixPath, -} from "../tools/shell-utils"; -import { isAbsoluteFilePath, normalizeFilePath } from "../tools/state"; - -test("Windows paths convert to Git Bash POSIX paths", () => { - assert.equal(windowsPathToPosixPath("C:\\Users\\foo"), "/c/Users/foo"); - assert.equal(windowsPathToPosixPath("d:\\IdeaProjects\\guesswho-api"), "/d/IdeaProjects/guesswho-api"); - assert.equal(windowsPathToPosixPath("\\\\server\\share\\dir"), "//server/share/dir"); -}); - -test("Git Bash POSIX paths convert to native Windows paths", () => { - assert.equal(posixPathToWindowsPath("/c/Users/foo"), "C:\\Users\\foo"); - assert.equal(posixPathToWindowsPath("/cygdrive/d/IdeaProjects/guesswho-api"), "D:\\IdeaProjects\\guesswho-api"); - assert.equal(posixPathToWindowsPath("//server/share/dir"), "\\\\server\\share\\dir"); -}); - -test("Windows nul redirects are rewritten for POSIX bash", () => { - assert.equal(rewriteWindowsNullRedirect("cmd >nul"), "cmd >/dev/null"); - assert.equal(rewriteWindowsNullRedirect("cmd 2>NUL && next"), "cmd 2>/dev/null && next"); - assert.equal(rewriteWindowsNullRedirect("cmd &>nul\nnext"), "cmd &>/dev/null\nnext"); - assert.equal(rewriteWindowsNullRedirect("echo nullable"), "echo nullable"); -}); - -test("Shell kind detection supports Windows bash.exe paths", () => { - assert.equal(getShellKind("C:\\Program Files\\Git\\bin\\bash.exe"), "bash"); - assert.equal(getShellKind("/bin/zsh"), "zsh"); - assert.equal( - buildDisableExtglobCommand("C:\\Program Files\\Git\\bin\\bash.exe"), - "shopt -u extglob 2>/dev/null || true" - ); - assert.equal(buildDisableExtglobCommand("/bin/zsh"), "setopt NO_EXTENDED_GLOB 2>/dev/null || true"); -}); - -test("File tool path normalization converts Git Bash drive paths on Windows", () => { - assert.equal( - normalizeFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), - "D:\\IdeaProjects\\guesswho-api\\API_DOCUMENTATION.md" - ); - assert.equal(normalizeFilePath("/cygdrive/c/Users/foo/file.txt", "win32"), "C:\\Users\\foo\\file.txt"); - assert.equal(normalizeFilePath("/dev/null", "win32"), "\\dev\\null"); -}); - -test("File tool absolute checks accept Git Bash drive paths but reject root-relative POSIX paths on Windows", () => { - assert.equal(isAbsoluteFilePath("/d/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), true); - assert.equal(isAbsoluteFilePath("D:/IdeaProjects/guesswho-api/API_DOCUMENTATION.md", "win32"), true); - assert.equal(isAbsoluteFilePath("/dev/null", "win32"), false); - assert.equal(isAbsoluteFilePath("./API_DOCUMENTATION.md", "win32"), false); -}); diff --git a/src/tests/slashCommands.test.ts b/src/tests/slashCommands.test.ts index 7d9510a..7af6053 100644 --- a/src/tests/slashCommands.test.ts +++ b/src/tests/slashCommands.test.ts @@ -5,13 +5,13 @@ import { filterSlashCommands, findExactSlashCommand, formatSlashCommandDescription, - formatSlashCommandLabel, + formatSlashCommandLabel } from "../ui"; import type { SkillInfo } from "../session"; const skills: SkillInfo[] = [ { name: "skill-writer", path: "~/.agents/skills/skill-writer/SKILL.md", description: "Write a SKILL.md" }, - { name: "code-review", path: "~/.agents/skills/code-review/SKILL.md", description: "Review code" }, + { name: "code-review", path: "~/.agents/skills/code-review/SKILL.md", description: "Review code" } ]; test("buildSlashCommands prefixes skills before built-ins", () => { @@ -19,7 +19,7 @@ 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", "exit"]); + assert.deepEqual(builtinNames, ["skills", "new", "init", "resume", "exit"]); }); test("filterSlashCommands matches partial prefixes", () => { @@ -66,13 +66,6 @@ test("findExactSlashCommand returns built-in /skills", () => { assert.equal(item?.kind, "skills"); }); -test("findExactSlashCommand returns built-in /model", () => { - const items = buildSlashCommands(skills); - const item = findExactSlashCommand(items, "/model"); - assert.ok(item); - assert.equal(item?.kind, "model"); -}); - test("findExactSlashCommand returns the matching skill", () => { const items = buildSlashCommands(skills); const item = findExactSlashCommand(items, "/code-review"); @@ -88,7 +81,7 @@ test("formatSlashCommandDescription keeps descriptions on one line", () => { test("formatSlashCommandLabel marks loaded skills", () => { const items = buildSlashCommands([ { name: "loaded", path: "/skills/loaded/SKILL.md", description: "Loaded skill", isLoaded: true }, - { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" }, + { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" } ]); assert.equal(formatSlashCommandLabel(items[0]), "/loaded ✓"); diff --git a/src/tests/thinkingState.test.ts b/src/tests/thinkingState.test.ts index 8f2a0e3..cb0f0a0 100644 --- a/src/tests/thinkingState.test.ts +++ b/src/tests/thinkingState.test.ts @@ -19,7 +19,7 @@ function buildMessage( visible: true, createTime: "2026-04-28T00:00:00.000Z", updateTime: "2026-04-28T00:00:00.000Z", - meta: options.asThinking ? { asThinking: true } : undefined, + meta: options.asThinking ? { asThinking: true } : undefined }; } @@ -28,7 +28,10 @@ test("findExpandedThinkingId returns null on an empty list", () => { }); test("findExpandedThinkingId returns the only thinking id when there is no final reply", () => { - const messages = [buildMessage("user", "user"), buildMessage("a-1", "assistant", { asThinking: true })]; + const messages = [ + buildMessage("user", "user"), + buildMessage("a-1", "assistant", { asThinking: true }) + ]; assert.equal(findExpandedThinkingId(messages), "a-1"); }); @@ -36,13 +39,16 @@ test("findExpandedThinkingId always picks the latest thinking id", () => { const messages = [ buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("tool", "tool"), - buildMessage("a-2", "assistant", { asThinking: true }), + buildMessage("a-2", "assistant", { asThinking: true }) ]; assert.equal(findExpandedThinkingId(messages), "a-2"); }); test("findExpandedThinkingId returns null after a non-thinking assistant reply", () => { - const messages = [buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("a-final", "assistant")]; + const messages = [ + buildMessage("a-1", "assistant", { asThinking: true }), + buildMessage("a-final", "assistant") + ]; assert.equal(findExpandedThinkingId(messages), null); }); @@ -51,7 +57,7 @@ test("findExpandedThinkingId picks the thinking id that follows the last final r buildMessage("a-1", "assistant", { asThinking: true }), buildMessage("a-final", "assistant"), buildMessage("a-2", "assistant", { asThinking: true }), - buildMessage("a-3", "assistant", { asThinking: true }), + buildMessage("a-3", "assistant", { asThinking: true }) ]; assert.equal(findExpandedThinkingId(messages), "a-3"); }); diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts deleted file mode 100644 index 256e0d6..0000000 --- a/src/tests/tool-handlers.test.ts +++ /dev/null @@ -1,397 +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 type { ToolExecutionContext } from "../tools/executor"; -import { handleEditTool } from "../tools/edit-handler"; -import { handleReadTool } from "../tools/read-handler"; -import { handleWriteTool } from "../tools/write-handler"; - -const tempDirs: string[] = []; - -afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - fs.rmSync(dir, { recursive: true, force: true }); - } - } -}); - -test("Read returns snippet metadata and Edit can scope replacements by snippet_id", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "sample.txt"); - fs.writeFileSync(filePath, ["alpha", "target = 1", "omega", "beta", "target = 1", "done"].join("\n"), "utf8"); - - const sessionId = "snippet-scope"; - const readResult = await handleReadTool( - { file_path: filePath, offset: 4, limit: 2 }, - 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); - assert.equal(snippet?.startLine, 4); - assert.equal(snippet?.endLine, 5); - - const editResult = await handleEditTool( - { - snippet_id: snippet?.id, - old_string: "target = 1", - new_string: "target = 2", - }, - createContext(sessionId, workspace) - ); - - assert.equal(editResult.ok, true); - assert.equal(editResult.metadata?.file_path, filePath); - assert.equal(editResult.metadata?.read_scope_type, "snippet"); - assert.equal(editResult.metadata?.cache_refreshed, true); - assert.equal(editResult.metadata?.line_endings, "LF"); - assert.match(String(editResult.metadata?.diff_preview ?? ""), /\+target = 2/); - assert.equal( - fs.readFileSync(filePath, "utf8"), - ["alpha", "target = 1", "omega", "beta", "target = 2", "done"].join("\n") - ); -}); - -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 editResult = await handleEditTool( - { - file_path: filePath, - old_string: "city", - new_string: "location", - }, - createContext(sessionId, workspace) - ); - - assert.equal(editResult.ok, false); - assert.equal(editResult.error, "old_string is not unique; use snippet_id, replace_all, or provide more context."); - const candidates = (editResult.metadata?.candidates ?? []) as Array<{ - snippet_id: string; - start_line: number; - end_line: number; - preview: string; - }>; - assert.equal(candidates.length, 2); - assert.ok(candidates[0]?.snippet_id); - assert.equal(candidates[0]?.start_line, 1); - assert.match(candidates[0]?.preview ?? "", /city/); -}); - -test("replace_all requires expected_occurrences for broad short-fragment replacements", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "openapi.yaml"); - const fragment = " schema:\n type: string"; - fs.writeFileSync(filePath, [fragment, fragment, fragment].join("\n---\n"), "utf8"); - - const sessionId = "replace-all-guard"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); - - const blockedResult = await handleEditTool( - { - file_path: filePath, - old_string: fragment, - new_string: " schema:\n type: array", - replace_all: true, - }, - createContext(sessionId, workspace) - ); - - assert.equal(blockedResult.ok, false); - assert.match(blockedResult.error ?? "", /provide expected_occurrences to confirm this broader replacement/); - - const allowedResult = await handleEditTool( - { - file_path: filePath, - old_string: fragment, - new_string: " schema:\n type: array", - replace_all: true, - expected_occurrences: 3, - }, - createContext(sessionId, workspace) - ); - - assert.equal(allowedResult.ok, true); - assert.equal( - fs.readFileSync(filePath, "utf8"), - [ - " schema:\n type: array", - " schema:\n type: array", - " schema:\n type: array", - ].join("\n---\n") - ); -}); - -test("Edit accepts a unique loose-escape match when only escaping differs", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "query.py"); - fs.writeFileSync(filePath, "params['city_json'] = f'\"{city}\"'\n", "utf8"); - - const sessionId = "closest-match"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); - - const editResult = await handleEditTool( - { - file_path: filePath, - old_string: "params['city_json'] = f'\\\\\"{city}\\\\\"'", - new_string: "params['city_json'] = city", - }, - createContext(sessionId, workspace, { - createOpenAIClient: () => ({ - client: { - chat: { - completions: { - create: async () => ({ - choices: [ - { - message: { - content: - "" + - "" + - "" + - "", - }, - }, - ], - }), - }, - }, - } as any, - model: "test-model", - thinkingEnabled: false, - }), - }) - ); - - assert.equal(editResult.ok, true); - assert.equal(editResult.metadata?.matched_via, "llm_escape_correction"); - assert.equal(fs.readFileSync(filePath, "utf8"), "params['city_json'] = city\n"); -}); - -test("Write repairs JSON object content for .json files", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "package.json"); - - const writeResult = await handleWriteTool( - { - file_path: filePath, - content: { - name: "demo", - private: true, - } as unknown as string, - }, - createContext("write-json-object", workspace) - ); - - assert.equal(writeResult.ok, true); - assert.equal(writeResult.metadata?.type, "create"); - assert.equal(writeResult.metadata?.file_path, filePath); - assert.equal(writeResult.metadata?.cache_refreshed, true); - assert.equal(writeResult.metadata?.line_endings, "LF"); - assert.equal(writeResult.metadata?.input_repaired, true); - assert.match(String(writeResult.metadata?.diff_preview ?? ""), /\+\s*"name": "demo"|^\+\{/m); - 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 () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "note.txt"); - - const writeResult = await handleWriteTool( - { - file_path: filePath, - content: "alpha\nbeta\n", - }, - createContext("write-then-edit", workspace) - ); - - assert.equal(writeResult.ok, true); - assert.equal(writeResult.metadata?.type, "create"); - assert.equal(writeResult.metadata?.cache_refreshed, true); - - const editResult = await handleEditTool( - { - file_path: filePath, - old_string: "beta", - new_string: "gamma", - }, - createContext("write-then-edit", 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"); -}); - -test("Write requires a full read before overwriting an existing file", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "config.txt"); - fs.writeFileSync(filePath, "line1\nline2\nline3\n", "utf8"); - - const sessionId = "write-full-read"; - await handleReadTool({ file_path: filePath, offset: 2, limit: 1 }, createContext(sessionId, workspace)); - - const blockedResult = await handleWriteTool( - { - file_path: filePath, - content: "rewritten", - }, - createContext(sessionId, workspace) - ); - - assert.equal(blockedResult.ok, false); - assert.equal(blockedResult.error, "Must read the full existing file before writing."); -}); - -test("Write can overwrite an existing empty file without a prior read", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "empty.txt"); - fs.writeFileSync(filePath, "", "utf8"); - - const writeResult = await handleWriteTool( - { - file_path: filePath, - content: "initialized\n", - }, - createContext("write-empty-existing", workspace) - ); - - assert.equal(writeResult.ok, true); - assert.equal(writeResult.metadata?.type, "update"); - assert.equal(writeResult.metadata?.cache_refreshed, true); - assert.match(String(writeResult.metadata?.diff_preview ?? ""), /\+initialized/); - assert.equal(fs.readFileSync(filePath, "utf8"), "initialized\n"); -}); - -test("Edit rejects stale reads after the file changes on disk", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "stale.txt"); - fs.writeFileSync(filePath, "before\n", "utf8"); - - const sessionId = "stale-edit"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); - - fs.writeFileSync(filePath, "after\n", "utf8"); - const futureTime = new Date(Date.now() + 2000); - fs.utimesSync(filePath, futureTime, futureTime); - - const editResult = await handleEditTool( - { - file_path: filePath, - old_string: "after", - new_string: "final", - }, - createContext(sessionId, workspace) - ); - - assert.equal(editResult.ok, false); - assert.equal(editResult.error, "File has been modified since read. Read it again before editing."); -}); - -test("Write preserves the exact trailing newline policy from the provided content", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "newline.txt"); - - const writeResult = await handleWriteTool( - { - file_path: filePath, - content: "no trailing newline", - }, - createContext("write-no-newline", workspace) - ); - - assert.equal(writeResult.ok, true); - assert.match(String(writeResult.metadata?.diff_preview ?? ""), /\+no trailing newline/); - assert.equal(fs.readFileSync(filePath, "utf8"), "no trailing newline"); -}); - -test("Edit preserves CRLF line endings for existing files", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "windows.txt"); - fs.writeFileSync(filePath, "alpha\r\nbeta\r\n", "utf8"); - - const sessionId = "crlf-edit"; - await handleReadTool({ file_path: filePath }, createContext(sessionId, workspace)); - - const editResult = await handleEditTool( - { - file_path: filePath, - old_string: "beta", - new_string: "gamma", - }, - createContext(sessionId, workspace) - ); - - assert.equal(editResult.ok, true); - assert.equal(editResult.metadata?.line_endings, "CRLF"); - assert.equal(fs.readFileSync(filePath, "utf8"), "alpha\r\ngamma\r\n"); -}); - -test("Read returns an acknowledgement for images and attaches the image as a follow-up system message", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, "pixel.png"); - fs.writeFileSync( - filePath, - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0X8AAAAASUVORK5CYII=", - "base64" - ) - ); - - const readResult = await handleReadTool({ file_path: filePath }, createContext("image-read", workspace)); - - assert.equal(readResult.ok, true); - assert.equal(readResult.output, "File loaded."); - assert.equal(readResult.metadata?.mime, "image/png"); - assert.equal(Array.isArray(readResult.followUpMessages), true); - assert.equal(readResult.followUpMessages?.length, 1); - - const followUpMessage = readResult.followUpMessages?.[0]; - assert.equal(followUpMessage?.role, "system"); - assert.match(followUpMessage?.content ?? "", /pixel\.png/); - const contentParams = Array.isArray(followUpMessage?.contentParams) ? followUpMessage.contentParams : []; - assert.equal(contentParams.length, 1); - assert.equal((contentParams[0] as { type?: unknown }).type, "image_url"); - assert.match( - String((contentParams[0] as { image_url?: { url?: unknown } }).image_url?.url ?? ""), - /^data:image\/png;base64,/ - ); -}); - -function createContext( - sessionId: string, - projectRoot: string, - overrides: Partial = {} -): ToolExecutionContext { - return { - sessionId, - projectRoot, - toolCall: { - id: "test-tool-call", - type: "function", - function: { - name: "test", - arguments: "{}", - }, - }, - ...overrides, - }; -} - -function createTempWorkspace(): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-tools-")); - tempDirs.push(dir); - return dir; -} diff --git a/src/tests/updateCheck.test.ts b/src/tests/updateCheck.test.ts deleted file mode 100644 index ce77fe5..0000000 --- a/src/tests/updateCheck.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { compareVersions, parseNpmViewVersion } from "../updateCheck"; - -test("compareVersions orders semantic versions", () => { - assert.equal(compareVersions("0.1.4", "0.1.3"), 1); - assert.equal(compareVersions("0.2.0", "0.10.0"), -1); - assert.equal(compareVersions("1.0.0", "1.0.0"), 0); - assert.equal(compareVersions("1.0.0", "1.0.0-beta.1"), 0); -}); - -test("parseNpmViewVersion parses npm view JSON and plain output", () => { - assert.equal(parseNpmViewVersion('"0.1.4"\n'), "0.1.4"); - assert.equal(parseNpmViewVersion("0.1.5\n"), "0.1.5"); - assert.equal(parseNpmViewVersion("\n"), null); -}); diff --git a/src/tests/web-search-handler.test.ts b/src/tests/web-search-handler.test.ts index eaa536e..6715aa4 100644 --- a/src/tests/web-search-handler.test.ts +++ b/src/tests/web-search-handler.test.ts @@ -3,7 +3,6 @@ import assert from "node:assert/strict"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import type OpenAI from "openai"; import type { ToolExecutionContext } from "../tools/executor"; import { handleWebSearchTool } from "../tools/web-search-handler"; @@ -25,7 +24,11 @@ test("WebSearch executes the configured script with the query as one argument", const scriptPath = path.join(workspace, "web-search.sh"); fs.writeFileSync( scriptPath, - ["#!/bin/sh", "printf 'query=%s\\n' \"$1\"", "printf 'cwd=%s\\n' \"$PWD\""].join("\n"), + [ + "#!/bin/sh", + "printf 'query=%s\\n' \"$1\"", + "printf 'cwd=%s\\n' \"$PWD\"" + ].join("\n"), "utf8" ); fs.chmodSync(scriptPath, 0o755); @@ -37,106 +40,46 @@ test("WebSearch executes the configured script with the query as one argument", createContext(workspace, { webSearchTool: scriptPath, onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id), + onProcessExit: (id) => exits.push(id) }) ); const realWorkspace = fs.realpathSync(workspace); assert.equal(result.ok, true); - assert.equal(result.output, `query=latest node release\ncwd=${realWorkspace}\n`); + assert.equal( + result.output, + `query=latest node release\ncwd=${realWorkspace}\n` + ); assert.equal(starts.length, 1); assert.match(starts[0].command, /^WebSearch: latest node release$/); assert.deepEqual(exits, [starts[0].id]); }); -test("WebSearch uses the default API when no script is configured", async () => { +test("WebSearch returns a configuration error when no script is configured", async () => { const workspace = createTempWorkspace(); - const starts: Array<{ id: string | number; command: string }> = []; - const exits: Array = []; const fetchCalls: Array<{ input: string | URL; init?: RequestInit }> = []; - - const fakeClient = { - chat: { - completions: { - create: async ({ messages }: { messages: Array<{ content: string }> }) => { - const prompt = messages[0]?.content ?? ""; - if (prompt.includes("Return strict JSON:")) { - return { - choices: [ - { - message: { - content: - '{"dominant_language":"en","reason":"Most Node.js release notes are published in English."}', - }, - }, - ], - }; - } - throw new Error(`Unexpected chat prompt: ${prompt}`); - }, - }, - }, - } as unknown as OpenAI; - globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { fetchCalls.push({ input, init }); - return { - ok: true, - json: async () => ({ - success: true, - result: JSON.stringify( - { - organic_results: [ - { - title: "Node.js Releases", - link: "https://nodejs.org/en/about/previous-releases", - }, - ], - }, - null, - 2 - ), - }), - } as Response; + return { ok: true } as Response; }) as typeof fetch; const result = await handleWebSearchTool( { query: "latest node release" }, - createContext(workspace, { - client: fakeClient, - machineId: "machine-id-123", - onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id), - }) + createContext(workspace) ); - assert.equal(result.ok, true); - assert.match(result.output ?? "", /Node\.js Releases/); - assert.equal(result.metadata?.resolvedQuery, "latest node release"); - assert.equal(starts.length, 1); - assert.equal(starts[0].id, exits[0]); - assert.equal(starts[0].command, "WebSearch: latest node release"); - assert.equal(fetchCalls.length, 1); - assert.equal(String(fetchCalls[0].input), "https://deepcode.vegamo.cn/api/plugin/web-search"); - assert.equal(fetchCalls[0].init?.method, "POST"); - assert.deepEqual(JSON.parse(String(fetchCalls[0].init?.body)), { query: "latest node release" }); - assert.equal((fetchCalls[0].init?.headers as Record).Token, "machine-id-123"); -}); - -test("WebSearch returns a configuration error when neither a script nor an LLM client is available", async () => { - const workspace = createTempWorkspace(); - const result = await handleWebSearchTool({ query: "latest node release" }, createContext(workspace)); - assert.equal(result.ok, false); - assert.equal(result.error, "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json."); + assert.equal( + result.error, + "WebSearch requires a custom search script. Set \"webSearchTool\" in ~/.deepcode/settings.json." + ); + assert.equal(fetchCalls.length, 0); }); function createContext( projectRoot: string, options: { - client?: OpenAI | null; webSearchTool?: string; - machineId?: string; onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; } = {} @@ -149,18 +92,17 @@ function createContext( type: "function", function: { name: "WebSearch", - arguments: "{}", - }, + arguments: "{}" + } }, createOpenAIClient: () => ({ - client: options.client ?? null, + client: null, model: "test-model", thinkingEnabled: false, webSearchTool: options.webSearchTool, - machineId: options.machineId, }), onProcessStart: options.onProcessStart, - onProcessExit: options.onProcessExit, + onProcessExit: options.onProcessExit }; } diff --git a/src/tests/welcomeScreen.test.ts b/src/tests/welcomeScreen.test.ts index 1e5bc19..1eca364 100644 --- a/src/tests/welcomeScreen.test.ts +++ b/src/tests/welcomeScreen.test.ts @@ -17,7 +17,7 @@ test("formatHomeRelativePath keeps paths outside the home directory absolute", ( test("buildWelcomeTips includes built-in slash commands and loaded skills", () => { const tips = buildWelcomeTips([ { name: "loaded", path: "/skills/loaded/SKILL.md", description: "Loaded skill", isLoaded: true }, - { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" }, + { name: "fresh", path: "/skills/fresh/SKILL.md", description: "Fresh skill" } ]); const labels = tips.map((tip) => tip.label); diff --git a/src/tools/ask-user-question-handler.ts b/src/tools/ask-user-question-handler.ts index 8608508..4654066 100644 --- a/src/tools/ask-user-question-handler.ts +++ b/src/tools/ask-user-question-handler.ts @@ -25,13 +25,13 @@ export async function handleAskUserQuestionTool( return { ok: false, name: "AskUserQuestion", - error: questions.error, + error: questions.error }; } const metadata: AskUserQuestionMetadata = { kind: "ask_user_question", - questions: questions.value, + questions: questions.value }; return { @@ -39,15 +39,17 @@ export async function handleAskUserQuestionTool( name: "AskUserQuestion", output: buildQuestionSummary(questions.value), metadata, - awaitUserResponse: true, + awaitUserResponse: true }; } -function parseQuestions(raw: unknown): { ok: true; value: AskUserQuestionItem[] } | { ok: false; error: string } { +function parseQuestions( + raw: unknown +): { ok: true; value: AskUserQuestionItem[] } | { ok: false; error: string } { if (!Array.isArray(raw) || raw.length === 0) { return { ok: false, - error: '"questions" must be a non-empty array.', + error: "\"questions\" must be a non-empty array." }; } @@ -57,18 +59,17 @@ function parseQuestions(raw: unknown): { ok: true; value: AskUserQuestionItem[] if (!item || typeof item !== "object" || Array.isArray(item)) { return { ok: false, - error: `Question at index ${index} must be an object.`, + error: `Question at index ${index} must be an object.` }; } - const question = - typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; + const question = typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; if (!question) { return { ok: false, - error: `Question at index ${index} is missing a non-empty "question" string.`, + error: `Question at index ${index} is missing a non-empty "question" string.` }; } @@ -76,7 +77,7 @@ function parseQuestions(raw: unknown): { ok: true; value: AskUserQuestionItem[] if (!Array.isArray(rawOptions) || rawOptions.length === 0) { return { ok: false, - error: `Question at index ${index} must include a non-empty "options" array.`, + error: `Question at index ${index} must include a non-empty "options" array.` }; } @@ -86,45 +87,44 @@ function parseQuestions(raw: unknown): { ok: true; value: AskUserQuestionItem[] if (!option || typeof option !== "object" || Array.isArray(option)) { return { ok: false, - error: `Option ${optionIndex} for question ${index} must be an object.`, + error: `Option ${optionIndex} for question ${index} must be an object.` }; } - const label = - typeof (option as { label?: unknown }).label === "string" ? (option as { label: string }).label.trim() : ""; + const label = typeof (option as { label?: unknown }).label === "string" + ? (option as { label: string }).label.trim() + : ""; if (!label) { return { ok: false, - error: `Option ${optionIndex} for question ${index} is missing a non-empty "label" string.`, + error: `Option ${optionIndex} for question ${index} is missing a non-empty "label" string.` }; } - const description = - typeof (option as { description?: unknown }).description === "string" - ? (option as { description: string }).description.trim() - : undefined; + const description = typeof (option as { description?: unknown }).description === "string" + ? (option as { description: string }).description.trim() + : undefined; options.push({ label, - description: description || undefined, + description: description || undefined }); } - const multiSelect = - typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" - ? (item as { multiSelect: boolean }).multiSelect - : undefined; + const multiSelect = typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" + ? (item as { multiSelect: boolean }).multiSelect + : undefined; questions.push({ question, multiSelect, - options, + options }); } return { ok: true, - value: questions, + value: questions }; } diff --git a/src/tools/bash-handler.ts b/src/tools/bash-handler.ts deleted file mode 100644 index 155d82a..0000000 --- a/src/tools/bash-handler.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { spawn } from "child_process"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { - buildDisableExtglobCommand, - buildShellEnv, - buildShellInitCommand, - resolveShellPath, - rewriteWindowsNullRedirect, - toNativeCwd, -} from "./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 child = spawn(shellPath, shellArgs, { - cwd, - env: buildShellEnv(shellPath), - 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); - }); - child.stderr?.on("data", (chunk: string | Buffer) => { - stderr = appendChunk(stderr, chunk); - }); - - 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/tools/edit-handler.ts b/src/tools/edit-handler.ts deleted file mode 100644 index 403f984..0000000 --- a/src/tools/edit-handler.ts +++ /dev/null @@ -1,833 +0,0 @@ -import * as fs from "fs"; -import { z } from "zod"; -import { buildThinkingRequestOptions } from "../openai-thinking"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { buildDiffPreview, hasFileChangedSinceState, readTextFileWithMetadata, writeTextFile } from "./file-utils"; -import { executeValidatedTool, semanticBoolean } from "./runtime"; -import { - createSnippet, - getFileState, - getSnippet, - isAbsoluteFilePath, - isFullFileView, - normalizeFilePath, - recordFileState, -} from "./state"; - -const MAX_CANDIDATE_COUNT = 5; -const REPLACE_ALL_MATCH_THRESHOLD = 5; -const SHORT_REPLACE_ALL_LENGTH = 40; -const MIN_FUZZY_SCORE = 0.45; - -type LineIndex = { - lines: string[]; - lineStarts: number[]; -}; - -type SearchScope = { - filePath: string; - startOffset: number; - endOffset: number; - startLine: number; - endLine: number; - snippetId: string | null; -}; - -type MatchOccurrence = { - startOffset: number; - endOffset: number; - startLine: number; - endLine: number; -}; - -type ClosestMatch = { - text: string; - startLine: number; - endLine: number; - score: number; - strategy: "loose_escape" | "fuzzy_window"; -}; - -type LooseEscapeMatch = MatchOccurrence & { - text: string; - score: number; -}; - -type CorrectedEditStrings = { - oldString: string; - newString: string; -}; - -const editSchema = z.strictObject({ - file_path: z.string().optional(), - snippet_id: z.string().optional(), - old_string: z.string(), - new_string: z.string(), - replace_all: semanticBoolean(false).optional(), - expected_occurrences: z.preprocess((value) => { - if (value === undefined || value === null || value === "") { - return undefined; - } - if (typeof value === "string") { - return Number(value); - } - return value; - }, z.number().int().min(1, "expected_occurrences must be >= 1.").optional()), -}); - -export async function handleEditTool( - args: Record, - context: ToolExecutionContext -): Promise { - return executeValidatedTool( - "edit", - editSchema, - args, - context, - async (input) => { - const snippetId = input.snippet_id?.trim() ?? ""; - const snippet = snippetId ? getSnippet(context.sessionId, snippetId) : null; - - let filePath = input.file_path?.trim() ?? ""; - if (!filePath && !snippet) { - return { - ok: false, - name: "edit", - error: 'Missing required "file_path" string or "snippet_id" string.', - }; - } - - if (!filePath && snippet) { - filePath = snippet.filePath; - } - - filePath = normalizeFilePath(filePath); - if (!isAbsoluteFilePath(filePath)) { - return { - ok: false, - name: "edit", - error: "file_path must be an absolute path.", - }; - } - - if (snippetId && !snippet) { - return { - ok: false, - name: "edit", - error: `Unknown snippet_id: ${snippetId}`, - }; - } - - if (snippet && snippet.filePath !== filePath) { - return { - ok: false, - name: "edit", - error: "snippet_id does not belong to the provided file_path.", - }; - } - - 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, - name: "edit", - error: "new_string must differ from old_string.", - }; - } - - if (!fs.existsSync(filePath)) { - return { - ok: false, - name: "edit", - error: `File not found: ${filePath}`, - }; - } - - let stat: fs.Stats; - try { - stat = fs.statSync(filePath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - name: "edit", - error: `Failed to stat file: ${message}`, - }; - } - - if (stat.isDirectory()) { - return { - ok: false, - name: "edit", - error: "file_path points to a directory.", - }; - } - - const fileState = getFileState(context.sessionId, filePath); - if (!fileState) { - return { - ok: false, - name: "edit", - error: "Must read file before editing.", - }; - } - - 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, - name: "edit", - error: "File has been modified since read. Read it again before editing.", - }; - } - - try { - const metadata = readTextFileWithMetadata(filePath); - const raw = metadata.content; - const oldString = input.old_string; - 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" | "loose_escape" | "llm_escape_correction" = "exact"; - let replacementOldString = oldString; - let replacementNewString = newString; - - if (matches.length === 0) { - const looseEscapeMatches = findLooseEscapeMatches(raw, oldString, scope); - if (looseEscapeMatches.length === 1 && looseEscapeMatches[0]?.score === 1) { - const correctedStrings = await correctEscapedStringsWithLLM( - raw.slice(scope.startOffset, scope.endOffset), - oldString, - newString, - looseEscapeMatches[0].text, - context - ); - - if (correctedStrings) { - const correctedMatches = findOccurrences(raw, correctedStrings.oldString, scope); - if (correctedMatches.length > 0) { - matches = correctedMatches; - matchedVia = "llm_escape_correction"; - replacementOldString = correctedStrings.oldString; - replacementNewString = correctedStrings.newString; - } - } - - if (matches.length === 0) { - matches = [looseEscapeMatches[0]]; - matchedVia = "loose_escape"; - } - } - } - - if (matches.length === 0) { - const closestMatch = findClosestMatch(raw, oldString, scope, lineIndex); - 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), - }, - }; - } - - if (!replaceAll && matches.length > 1) { - return { - ok: false, - name: "edit", - error: "old_string is not unique; use snippet_id, replace_all, or provide more context.", - metadata: { - match_count: matches.length, - scope: formatScopeMetadata(scope), - candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches), - }, - }; - } - - const expectedOccurrences = input.expected_occurrences ?? null; - const replaceAllGuardError = validateReplaceAllGuard({ - replaceAll, - matchCount: matches.length, - oldString: replacementOldString, - expectedOccurrences, - }); - if (replaceAllGuardError) { - return { - ok: false, - name: "edit", - error: replaceAllGuardError, - metadata: { - match_count: matches.length, - scope: formatScopeMetadata(scope), - candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches), - }, - }; - } - - const updated = applyReplacement(raw, replacementOldString, replacementNewString, matches, replaceAll); - const diffPreview = buildDiffPreview(filePath, raw, updated); - writeTextFile(filePath, updated, metadata.encoding, metadata.lineEndings); - const freshMetadata = readTextFileWithMetadata(filePath); - recordFileState(context.sessionId, { - filePath, - content: freshMetadata.content, - timestamp: freshMetadata.timestamp, - encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings, - }); - const replacedCount = replaceAll ? matches.length : 1; - return { - ok: true, - name: "edit", - output: `Replaced ${replacedCount} occurrence(s) in ${filePath}.`, - metadata: { - file_path: filePath, - replaced_count: replacedCount, - matched_via: matchedVia, - cache_refreshed: true, - read_scope_type: snippet ? "snippet" : "full", - encoding: freshMetadata.encoding, - line_endings: freshMetadata.lineEndings, - diff_preview: diffPreview, - scope: formatScopeMetadata(scope), - }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - name: "edit", - error: message, - }; - } - }, - { - preprocess: (rawInput) => { - const nextInput = { ...rawInput }; - if (typeof nextInput.file_path === "string") { - nextInput.file_path = normalizeFilePath(nextInput.file_path); - } - if (typeof nextInput.snippet_id === "string") { - nextInput.snippet_id = nextInput.snippet_id.trim(); - } - return { ok: true, input: nextInput }; - }, - } - ); -} - -function buildLineIndex(raw: string): LineIndex { - const lines = raw.split(/\r?\n/); - const lineStarts = new Array(lines.length + 2).fill(raw.length); - let cursor = 0; - - for (let index = 0; index < lines.length; index += 1) { - lineStarts[index + 1] = cursor; - cursor += lines[index].length; - if (index < lines.length - 1) { - if (raw.slice(cursor, cursor + 2) === "\r\n") { - cursor += 2; - } else if (raw[cursor] === "\n") { - cursor += 1; - } - } - } - - lineStarts[lines.length + 1] = raw.length; - return { lines, lineStarts }; -} - -function buildSearchScope( - filePath: string, - raw: string, - lineIndex: LineIndex, - snippet: { startLine: number; endLine: number; id: string } | null -): SearchScope { - if (!snippet) { - return { - filePath, - startOffset: 0, - endOffset: raw.length, - startLine: 1, - endLine: lineIndex.lines.length, - snippetId: null, - }; - } - - const safeStartLine = clamp(snippet.startLine, 1, lineIndex.lines.length); - const safeEndLine = clamp(snippet.endLine, safeStartLine, lineIndex.lines.length); - return { - filePath, - startOffset: lineIndex.lineStarts[safeStartLine], - endOffset: lineIndex.lineStarts[safeEndLine + 1], - startLine: safeStartLine, - endLine: safeEndLine, - snippetId: snippet.id, - }; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function findOccurrences(raw: string, needle: string, scope: SearchScope): MatchOccurrence[] { - if (!raw || !needle) { - return []; - } - - const scopeText = raw.slice(scope.startOffset, scope.endOffset); - const matches: MatchOccurrence[] = []; - let searchIndex = 0; - - while (true) { - const found = scopeText.indexOf(needle, searchIndex); - if (found === -1) { - break; - } - const startOffset = scope.startOffset + found; - const endOffset = startOffset + needle.length; - matches.push({ - startOffset, - endOffset, - startLine: offsetToLine(raw, startOffset), - endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)), - }); - searchIndex = found + needle.length; - } - - return matches; -} - -function findLooseEscapeMatches(raw: string, needle: string, scope: SearchScope): LooseEscapeMatch[] { - if (!raw || !needle) { - return []; - } - - const scopeText = raw.slice(scope.startOffset, scope.endOffset); - const looseEscapeRegex = buildLooseEscapeRegex(needle); - if (!looseEscapeRegex) { - return []; - } - - const normalizedNeedle = normalizeLooseText(needle); - const matches: LooseEscapeMatch[] = []; - for (const match of scopeText.matchAll(looseEscapeRegex)) { - if (typeof match.index !== "number") { - continue; - } - - const text = match[0]; - const startOffset = scope.startOffset + match.index; - const endOffset = startOffset + text.length; - matches.push({ - text, - score: similarityScore(normalizedNeedle, normalizeLooseText(text)), - startOffset, - endOffset, - startLine: offsetToLine(raw, startOffset), - endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)), - }); - } - - return matches; -} - -function offsetToLine(raw: string, offset: number): number { - if (offset <= 0) { - return 1; - } - let line = 1; - for (let index = 0; index < raw.length && index < offset; index += 1) { - if (raw[index] === "\n") { - line += 1; - } - } - return line; -} - -function validateReplaceAllGuard(input: { - replaceAll: boolean; - matchCount: number; - oldString: string; - expectedOccurrences: number | null; -}): string | null { - if (!input.replaceAll) { - if (input.expectedOccurrences !== null && input.expectedOccurrences !== 1) { - return "expected_occurrences can only be greater than 1 when replace_all is true."; - } - return null; - } - - if (input.expectedOccurrences !== null && input.expectedOccurrences !== input.matchCount) { - return `replace_all expected ${input.expectedOccurrences} occurrence(s), ` + `but found ${input.matchCount}.`; - } - - const isShortFragment = input.oldString.trim().length < SHORT_REPLACE_ALL_LENGTH; - const needsExplicitCount = - input.expectedOccurrences === null && - (input.matchCount > REPLACE_ALL_MATCH_THRESHOLD || (isShortFragment && input.matchCount > 1)); - - if (needsExplicitCount) { - return ( - `replace_all would affect ${input.matchCount} occurrence(s); ` + - "provide expected_occurrences to confirm this broader replacement." - ); - } - - return null; -} - -function applyReplacement( - raw: string, - oldString: string, - newString: string, - matches: MatchOccurrence[], - replaceAll: boolean -): string { - if (!replaceAll) { - return raw.slice(0, matches[0].startOffset) + newString + raw.slice(matches[0].endOffset); - } - - let result = ""; - let cursor = 0; - for (const match of matches) { - result += raw.slice(cursor, match.startOffset); - result += newString; - cursor = match.endOffset; - } - result += raw.slice(cursor); - return result; -} - -function buildCandidateMetadata( - sessionId: string, - filePath: string, - raw: string, - matches: MatchOccurrence[] -): Array> { - return matches.slice(0, MAX_CANDIDATE_COUNT).map((match) => { - const preview = buildPreview(raw, match.startLine, match.endLine); - const snippet = createSnippet(sessionId, filePath, match.startLine, match.endLine, preview); - return { - snippet_id: snippet?.id ?? null, - start_line: match.startLine, - end_line: match.endLine, - preview, - }; - }); -} - -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, - start_line: scope.startLine, - end_line: scope.endLine, - snippet_id: scope.snippetId, - }; -} - -function buildPreview(raw: string, startLine: number, endLine: number): string { - const lines = raw.split(/\r?\n/); - const selected = lines.slice(startLine - 1, endLine); - return formatWithLineNumbers(selected, startLine); -} - -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) { - return 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; -} - -function buildLooseEscapeRegex(source: string): RegExp | null { - if (!source) { - return null; - } - - let pattern = ""; - for (let index = 0; index < source.length; index += 1) { - if (source[index] === "\\") { - let slashEnd = index; - while (slashEnd < source.length && source[slashEnd] === "\\") { - slashEnd += 1; - } - - if (slashEnd < source.length && isEscapeSensitiveChar(source[slashEnd])) { - pattern += "\\\\*"; - pattern += escapeRegExp(source[slashEnd]); - index = slashEnd; - continue; - } - - pattern += escapeRegExp(source.slice(index, slashEnd)); - index = slashEnd - 1; - continue; - } - - pattern += escapeRegExp(source[index]); - } - - return new RegExp(pattern, "g"); -} - -async function correctEscapedStringsWithLLM( - snippetText: string, - oldString: string, - newString: string, - matchedText: string, - context: ToolExecutionContext -): Promise { - const clientFactory = context.createOpenAIClient; - if (!clientFactory) { - return null; - } - - const { client, model, baseURL, thinkingEnabled, reasoningEffort } = clientFactory(); - if (!client) { - return null; - } - - try { - const response = await client.chat.completions.create({ - model, - messages: [ - { - role: "system", - content: - "You correct file-edit strings when the only problem is escaping. " + - "Return XML only using ....... " + - "Do not change semantics; only fix quoting or escaping so corrected_old_string matches the snippet exactly.", - }, - { - role: "user", - content: - "\n" + - ` \n` + - ` \n` + - ` \n` + - ` \n` + - "\n" + - "\n" + - " \n" + - " \n" + - " \n" + - " \n" + - "", - }, - ], - ...buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort), - }); - - const content = response.choices?.[0]?.message?.content ?? ""; - const parsed = parseCorrectedEditStrings(content); - if (!parsed) { - return null; - } - - const normalizedOld = normalizeLooseText(oldString); - const normalizedNew = normalizeLooseText(newString); - if (normalizeLooseText(parsed.oldString) !== normalizedOld) { - return null; - } - if (normalizeLooseText(parsed.newString) !== normalizedNew) { - return null; - } - if (parsed.oldString === parsed.newString) { - return null; - } - - return parsed; - } catch { - return null; - } -} - -function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null { - const trimmed = content.trim(); - if (!trimmed) { - return null; - } - - const normalized = trimmed.replace(/```(?:xml)?\s*([\s\S]*?)```/i, "$1").trim(); - const oldMatch = normalized.match( - /(?:|([\s\S]*?))<\/corrected_old_string>/i - ); - const newMatch = normalized.match( - /(?:|([\s\S]*?))<\/corrected_new_string>/i - ); - - const correctedOldString = oldMatch?.[1] ?? oldMatch?.[2]; - const correctedNewString = newMatch?.[1] ?? newMatch?.[2]; - if (typeof correctedOldString === "string" && typeof correctedNewString === "string") { - return { - oldString: correctedOldString, - newString: correctedNewString, - }; - } - - return null; -} - -function isEscapeSensitiveChar(value: string): boolean { - return value === '"' || value === "'" || value === "`" || value === "\\"; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function normalizeLooseText(value: string): string { - return value - .replace(/\r\n?/g, "\n") - .replace(/\\+(?=["'`\\])/g, "") - .replace(/[ \t]+/g, " ") - .trim(); -} - -function similarityScore(left: string, right: string): number { - if (left === right) { - return 1; - } - if (!left || !right) { - return 0; - } - - const leftBigrams = toBigrams(left); - const rightBigrams = toBigrams(right); - if (leftBigrams.length === 0 || rightBigrams.length === 0) { - return left === right ? 1 : 0; - } - - const rightCounts = new Map(); - for (const bigram of rightBigrams) { - rightCounts.set(bigram, (rightCounts.get(bigram) ?? 0) + 1); - } - - let overlap = 0; - for (const bigram of leftBigrams) { - const count = rightCounts.get(bigram) ?? 0; - if (count > 0) { - overlap += 1; - rightCounts.set(bigram, count - 1); - } - } - - return (2 * overlap) / (leftBigrams.length + rightBigrams.length); -} - -function toBigrams(value: string): string[] { - if (value.length < 2) { - return [value]; - } - - const result: string[] = []; - for (let index = 0; index < value.length - 1; index += 1) { - result.push(value.slice(index, index + 2)); - } - 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/src/tools/executor.ts index eec0b20..96e0428 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -1,11 +1,46 @@ import type OpenAI from "openai"; -import type { ReasoningEffort } from "../settings"; +import type { DataCollection, ProviderPrivacyMode, ReasoningEffort } from "../settings"; +import { logWarn } from "../error-logger"; import { handleAskUserQuestionTool } from "./ask-user-question-handler"; -import { handleBashTool } from "./bash-handler"; -import { handleEditTool } from "./edit-handler"; -import { handleReadTool } from "./read-handler"; +import { handleRipgrepTool } from "./ripgrep-handler"; +import { handleAstGrepTool } from "./ast-grep-handler"; +import { + handleCheckOnboardingPerformedTool, + handleCreateTextFileTool, + handleDeleteLinesTool, + handleDeleteMemoryTool, + handleEditMemoryTool, + handleExecuteShellCommandTool, + handleFindDeclarationTool, + handleFindFileTool, + handleFindImplementationsTool, + handleFindReferencingSymbolsTool, + handleFindSymbolTool, + handleGetCurrentConfigTool, + handleGetDiagnosticsForFileTool, + handleGetDiagnosticsForSymbolTool, + handleGetSymbolsOverviewTool, + handleInitialInstructionsTool, + handleInsertAfterSymbolTool, + handleInsertAtLineTool, + handleInsertBeforeSymbolTool, + handleListDirTool, + handleListMemoriesTool, + handleOnboardingTool, + handleOpenDashboardTool, + handleReadFileTool, + handleReadMemoryTool, + handleRenameMemoryTool, + handleRenameSymbolTool, + handleReplaceContentTool, + handleReplaceLinesTool, + handleReplaceSymbolBodyTool, + handleRestartLanguageServerTool, + handleSafeDeleteSymbolTool, + handleSearchForPatternTool, + handleWriteMemoryTool, +} from "./serena-handlers"; import { handleWebSearchTool } from "./web-search-handler"; -import { handleWriteTool } from "./write-handler"; export type CreateOpenAIClient = () => { client: OpenAI | null; @@ -17,6 +52,11 @@ export type CreateOpenAIClient = () => { notify?: string; webSearchTool?: string; machineId?: string; + provider?: string; + providerPrivacyMode?: ProviderPrivacyMode; + zdr?: boolean; + dataCollection?: DataCollection; + cacheControl?: boolean; }; export type ToolCall = { @@ -86,9 +126,24 @@ export class ToolExecutor { toolCalls: unknown[], hooks?: ToolExecutionHooks ): Promise { + const seenIds = new Set(); const parsedCalls = toolCalls .map((toolCall) => this.parseToolCall(toolCall)) - .filter((toolCall): toolCall is ToolCall => Boolean(toolCall)); + .filter((toolCall): toolCall is ToolCall => { + if (!toolCall) return false; + if (seenIds.has(toolCall.id)) { + logWarn({ + timestamp: new Date().toISOString(), + location: "ToolExecutor.executeToolCalls", + message: "Skipped duplicate tool_call_id before execution — would have produced duplicate tool result", + sessionId, + data: { duplicateToolCallId: toolCall.id, toolName: toolCall.function.name } + }); + return false; + } + seenIds.add(toolCall.id); + return true; + }); const executions: ToolCallExecution[] = []; for (const toolCall of parsedCalls) { @@ -99,7 +154,7 @@ export class ToolExecutor { executions.push({ toolCallId: toolCall.id, content: this.formatToolResult(result), - result, + result }); if (hooks?.shouldStop?.()) { break; @@ -109,10 +164,57 @@ export class ToolExecutor { } private registerToolHandlers(): void { - this.toolHandlers.set("bash", handleBashTool); - this.toolHandlers.set("read", handleReadTool); - this.toolHandlers.set("write", handleWriteTool); - this.toolHandlers.set("edit", handleEditTool); + // Serena — shell + this.toolHandlers.set("execute_shell_command", handleExecuteShellCommandTool); + + // Serena — file tools + this.toolHandlers.set("read_file", handleReadFileTool); + this.toolHandlers.set("create_text_file", handleCreateTextFileTool); + this.toolHandlers.set("replace_content", handleReplaceContentTool); + this.toolHandlers.set("delete_lines", handleDeleteLinesTool); + this.toolHandlers.set("replace_lines", handleReplaceLinesTool); + this.toolHandlers.set("insert_at_line", handleInsertAtLineTool); + this.toolHandlers.set("list_dir", handleListDirTool); + this.toolHandlers.set("find_file", handleFindFileTool); + this.toolHandlers.set("search_for_pattern", handleSearchForPatternTool); + + // Serena — symbol tools + this.toolHandlers.set("restart_language_server", handleRestartLanguageServerTool); + this.toolHandlers.set("get_symbols_overview", handleGetSymbolsOverviewTool); + this.toolHandlers.set("find_symbol", handleFindSymbolTool); + this.toolHandlers.set("find_referencing_symbols", handleFindReferencingSymbolsTool); + this.toolHandlers.set("find_implementations", handleFindImplementationsTool); + this.toolHandlers.set("find_declaration", handleFindDeclarationTool); + this.toolHandlers.set("get_diagnostics_for_file", handleGetDiagnosticsForFileTool); + this.toolHandlers.set("get_diagnostics_for_symbol", handleGetDiagnosticsForSymbolTool); + this.toolHandlers.set("replace_symbol_body", handleReplaceSymbolBodyTool); + this.toolHandlers.set("insert_after_symbol", handleInsertAfterSymbolTool); + this.toolHandlers.set("insert_before_symbol", handleInsertBeforeSymbolTool); + this.toolHandlers.set("rename_symbol", handleRenameSymbolTool); + this.toolHandlers.set("safe_delete_symbol", handleSafeDeleteSymbolTool); + + // Serena — memory tools + this.toolHandlers.set("list_memories", handleListMemoriesTool); + this.toolHandlers.set("read_memory", handleReadMemoryTool); + this.toolHandlers.set("write_memory", handleWriteMemoryTool); + this.toolHandlers.set("edit_memory", handleEditMemoryTool); + this.toolHandlers.set("delete_memory", handleDeleteMemoryTool); + this.toolHandlers.set("rename_memory", handleRenameMemoryTool); + + // Serena — workflow tools + this.toolHandlers.set("initial_instructions", handleInitialInstructionsTool); + this.toolHandlers.set("check_onboarding_performed", handleCheckOnboardingPerformedTool); + this.toolHandlers.set("onboarding", handleOnboardingTool); + + // Serena — config tools + this.toolHandlers.set("open_dashboard", handleOpenDashboardTool); + this.toolHandlers.set("get_current_config", handleGetCurrentConfigTool); + + // Native search tools + this.toolHandlers.set("ripgrep_search", handleRipgrepTool); + this.toolHandlers.set("ast_grep_search", handleAstGrepTool); + + // Non-Serena tools this.toolHandlers.set("AskUserQuestion", handleAskUserQuestionTool); this.toolHandlers.set("WebSearch", handleWebSearchTool); } @@ -141,15 +243,16 @@ export class ToolExecutor { return null; } - const rawArguments = typeof functionRecord.arguments === "string" ? functionRecord.arguments : ""; + const rawArguments = + typeof functionRecord.arguments === "string" ? functionRecord.arguments : ""; return { id: record.id, type: "function", function: { name: functionRecord.name, - arguments: rawArguments, - }, + arguments: rawArguments + } }; } @@ -164,7 +267,7 @@ export class ToolExecutor { return { ok: false, name: toolName, - error: `Unknown tool: ${toolName}`, + error: `Unknown tool: ${toolName}` }; } @@ -173,7 +276,7 @@ export class ToolExecutor { return { ok: false, name: toolName, - error: parsedArgs.error, + error: parsedArgs.error }; } @@ -184,14 +287,14 @@ export class ToolExecutor { toolCall, createOpenAIClient: this.createOpenAIClient, onProcessStart: hooks?.onProcessStart, - onProcessExit: hooks?.onProcessExit, + onProcessExit: hooks?.onProcessExit }); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: toolName, - error: message, + error: message }; } } @@ -215,7 +318,7 @@ export class ToolExecutor { ok: false, error: `InputParseError: Failed to parse tool arguments: ${message}. ` + - "Ensure the tool call arguments are valid JSON. Prefer Edit over Write for large existing-file changes.", + "Ensure the tool call arguments are valid JSON. Prefer Edit over Write for large existing-file changes." }; } } @@ -223,7 +326,7 @@ export class ToolExecutor { private formatToolResult(result: ToolExecutionResult): string { const payload: Record = { ok: result.ok, - name: result.name, + name: result.name }; if (typeof result.output !== "undefined") { @@ -244,4 +347,5 @@ export class ToolExecutor { return JSON.stringify(payload, null, 2); } + } diff --git a/src/tools/file-utils.ts b/src/tools/file-utils.ts deleted file mode 100644 index 6656172..0000000 --- a/src/tools/file-utils.ts +++ /dev/null @@ -1,143 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import type { FileState, FileLineEnding } from "./state"; - -export type FileReadMetadata = { - content: string; - encoding: BufferEncoding; - lineEndings: FileLineEnding; - timestamp: number; -}; - -export function normalizeContent(value: string): string { - return value.replace(/\r\n/g, "\n"); -} - -export function detectLineEndings(value: string): FileLineEnding { - return value.includes("\r\n") ? "CRLF" : "LF"; -} - -export function detectEncoding(buffer: Buffer): BufferEncoding { - if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) { - return "utf16le"; - } - - return "utf8"; -} - -export function readTextFileWithMetadata(filePath: string): FileReadMetadata { - const buffer = fs.readFileSync(filePath); - const stat = fs.statSync(filePath); - const encoding = detectEncoding(buffer); - const raw = buffer.toString(encoding); - - return { - content: normalizeContent(raw), - encoding, - lineEndings: detectLineEndings(raw), - timestamp: Math.floor(stat.mtimeMs), - }; -} - -export function writeTextFile( - filePath: string, - content: string, - encoding: BufferEncoding, - lineEndings: FileLineEnding -): number { - const normalized = normalizeContent(content); - const toWrite = lineEndings === "CRLF" ? normalized.replace(/\n/g, "\r\n") : normalized; - fs.writeFileSync(filePath, toWrite, { encoding }); - return Buffer.byteLength(toWrite, encoding === "utf16le" ? "utf16le" : "utf8"); -} - -export function ensureParentDirectory(filePath: string): void { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); -} - -export function hasFileChangedSinceState(filePath: string, state: FileState): boolean { - const current = readTextFileWithMetadata(filePath); - if (current.timestamp <= state.timestamp) { - return false; - } - - const isFullRead = !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined"; - - return !(isFullRead && current.content === state.content); -} - -export function buildDiffPreview( - filePath: string, - originalContent: string | null, - updatedContent: string, - maxLines = 40 -): string | null { - const original = originalContent === null ? null : normalizeContent(originalContent); - const updated = normalizeContent(updatedContent); - - if (original !== null && original === updated) { - return null; - } - - const oldLines = toDiffLines(original); - const newLines = toDiffLines(updated); - - let prefix = 0; - while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) { - prefix += 1; - } - - let suffix = 0; - while ( - suffix < oldLines.length - prefix && - suffix < newLines.length - prefix && - oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix] - ) { - suffix += 1; - } - - const oldChanged = oldLines.slice(prefix, oldLines.length - suffix); - const newChanged = newLines.slice(prefix, newLines.length - suffix); - const oldStart = original === null ? 0 : prefix + 1; - const newStart = prefix + 1; - - const previewLines = [ - `--- ${original === null ? "/dev/null" : `a/${filePath}`}`, - `+++ b/${filePath}`, - `@@ -${oldStart},${oldChanged.length} +${newStart},${newChanged.length} @@`, - ]; - - if (prefix > 0) { - previewLines.push(` ${oldLines[prefix - 1]}`); - } - - for (const line of oldChanged) { - previewLines.push(`-${line}`); - } - - for (const line of newChanged) { - previewLines.push(`+${line}`); - } - - if (suffix > 0) { - previewLines.push(` ${oldLines[oldLines.length - suffix]}`); - } - - if (previewLines.length > maxLines) { - return `${previewLines.slice(0, maxLines).join("\n")}\n...`; - } - - return previewLines.join("\n"); -} - -function toDiffLines(content: string | null): string[] { - if (!content) { - return []; - } - - const lines = content.split("\n"); - if (lines[lines.length - 1] === "") { - lines.pop(); - } - return lines; -} diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts deleted file mode 100644 index 548bcfd..0000000 --- a/src/tools/read-handler.ts +++ /dev/null @@ -1,650 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import ignore from "ignore"; -import type { ToolExecutionContext, ToolExecutionFollowUpMessage, ToolExecutionResult } from "./executor"; -import { readTextFileWithMetadata } from "./file-utils"; -import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "./state"; - -const DEFAULT_LINE_LIMIT = 2000; -const MAX_LINE_LENGTH = 2000; -const PDF_LARGE_PAGE_THRESHOLD = 10; -const PDF_MAX_PAGE_RANGE = 20; -const LINE_NUMBER_WIDTH = 6; -const DEFAULT_GITIGNORE = [ - "node_modules/", - ".git/", - "dist/", - "build/", - "out/", - ".next/", - ".nuxt/", - ".venv/", - "venv/", - "__pycache__/", - "*.pyc", - "*.pyo", - ".pytest_cache/", - ".mypy_cache/", - ".ruff_cache/", - ".gradle/", - ".idea/", - ".vscode/", - "*.class", - "*.jar", - "*.war", - "target/", -]; - -type PageRange = { - start: number; - end: number; - count: number; -}; - -type TextReadResult = { - content: string; - output: string; - startLine: number; - endLine: number; - totalLines: number; - isPartialView: boolean; - encoding: BufferEncoding; - lineEndings: "LF" | "CRLF"; - timestamp: number; -}; - -export async function handleReadTool( - args: Record, - context: ToolExecutionContext -): Promise { - let filePath = typeof args.file_path === "string" ? normalizeFilePath(args.file_path) : ""; - if (!filePath.trim()) { - return { - ok: false, - name: "read", - error: 'Missing required "file_path" string.', - }; - } - - if (!isAbsoluteFilePath(filePath)) { - if (filePath.startsWith("../") || filePath.startsWith("..\\")) { - return { - ok: false, - name: "read", - error: "file_path must be an absolute path.", - }; - } - const normalizedSuffix = normalizeRelativeSuffix(filePath); - const isIgnored = loadGitignoreMatcher(context.projectRoot); - const matches = normalizedSuffix ? findSuffixMatches(context.projectRoot, normalizedSuffix, isIgnored) : []; - if (matches.length > 1) { - return { - ok: false, - name: "read", - error: - "file_path must be an absolute path. " + - `The file_path is ambiguous and may refer to multiple files:\n${matches.slice(0, 3).join("\n")}` + - (matches.length > 3 ? `\n...and ${matches.length - 3} more.` : ""), - }; - } - - const resolvedPath = path.resolve(context.projectRoot, filePath); - if (!fs.existsSync(resolvedPath)) { - if (matches.length > 0) { - return { - ok: false, - name: "read", - error: "file_path must be an absolute path. " + `The file_path "${filePath}" is ambiguous.`, - }; - } else { - return { - ok: false, - name: "read", - error: `File not found: ${filePath}`, - }; - } - } - - filePath = resolvedPath; - } - - if (!fs.existsSync(filePath)) { - return { - ok: false, - name: "read", - error: `File not found: ${filePath}`, - }; - } - - let stat: fs.Stats; - try { - stat = fs.statSync(filePath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - name: "read", - error: `Failed to stat file: ${message}`, - }; - } - - if (stat.isDirectory()) { - return { - ok: false, - name: "read", - error: "file_path points to a directory. Use bash ls for directories.", - }; - } - - const ext = path.extname(filePath).toLowerCase(); - try { - if (ext === ".ipynb") { - const output = readNotebook(filePath); - markFileRead(context.sessionId, filePath, { - content: "", - timestamp: Math.floor(stat.mtimeMs), - isPartialView: true, - }); - return { - ok: true, - name: "read", - output, - }; - } - - if (ext === ".pdf") { - const pagesParam = typeof args.pages === "string" ? args.pages.trim() : ""; - const buffer = fs.readFileSync(filePath); - const pageCount = countPdfPages(buffer); - const pageRange = pagesParam ? parsePageRange(pagesParam) : null; - - if (!pageRange && pageCount !== null && pageCount > PDF_LARGE_PAGE_THRESHOLD) { - return { - ok: false, - name: "read", - error: `PDF has ${pageCount} pages; provide "pages" to read a range.`, - }; - } - - if (pageRange && pageRange.count > PDF_MAX_PAGE_RANGE) { - return { - ok: false, - name: "read", - error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.`, - }; - } - - if (pageRange && pageCount !== null && pageRange.end > pageCount) { - return { - ok: false, - name: "read", - error: `PDF page range exceeds total page count (${pageCount}).`, - }; - } - - const base64 = buffer.toString("base64"); - markFileRead(context.sessionId, filePath, { - content: "", - timestamp: Math.floor(stat.mtimeMs), - isPartialView: true, - }); - return { - ok: true, - name: "read", - output: `data:application/pdf;base64,${base64}`, - metadata: { - mime: "application/pdf", - encoding: "base64", - bytes: buffer.length, - pageCount, - pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null, - }, - }; - } - - if (isImageExtension(ext)) { - const buffer = fs.readFileSync(filePath); - const mime = getImageMimeType(ext); - markFileRead(context.sessionId, filePath, { - content: "", - timestamp: Math.floor(stat.mtimeMs), - isPartialView: true, - }); - return { - ok: true, - name: "read", - output: "File loaded.", - metadata: { - mime, - bytes: buffer.length, - }, - followUpMessages: [buildImageFollowUpMessage(filePath, mime, buffer)], - }; - } - - const offset = parseLineNumber(args.offset, "offset"); - const limit = parseLineLimit(args.limit); - if (!offset.ok) { - return { - ok: false, - name: "read", - error: offset.error, - }; - } - if (!limit.ok) { - return { - ok: false, - name: "read", - error: limit.error, - }; - } - - const textResult = readTextFile(filePath, offset.value, limit.value); - markFileRead(context.sessionId, filePath, { - content: textResult.content, - timestamp: textResult.timestamp, - offset: textResult.isPartialView ? textResult.startLine : undefined, - limit: textResult.isPartialView ? Math.max(1, textResult.endLine - textResult.startLine + 1) : undefined, - isPartialView: textResult.isPartialView, - encoding: textResult.encoding, - lineEndings: textResult.lineEndings, - }); - const snippet = createSnippet( - context.sessionId, - filePath, - textResult.startLine, - textResult.endLine, - textResult.output - ); - return { - ok: true, - name: "read", - output: textResult.output, - metadata: snippet - ? { - snippet: { - id: snippet.id, - filePath: snippet.filePath, - startLine: snippet.startLine, - endLine: snippet.endLine, - }, - } - : undefined, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - name: "read", - error: message, - }; - } -} - -function normalizeRelativeSuffix(relativePath: string): string | null { - const normalized = path.normalize(relativePath).replace(/^(\.\/|\\)+/, ""); - return normalized.trim() ? path.sep + normalized : null; -} - -function findSuffixMatches( - root: string, - suffix: string, - isIgnored: ((relPath: string, isDir: boolean) => boolean) | null -): string[] { - const matches: string[] = []; - const queue: string[] = [root]; - - while (queue.length > 0) { - const current = queue.pop(); - if (!current) { - continue; - } - - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(current, { withFileTypes: true }); - } catch { - continue; - } - - for (const entry of entries) { - const fullPath = path.join(current, entry.name); - const relPath = path.relative(root, fullPath).replace(/\\/g, "/"); - if (isIgnored && isIgnored(relPath, entry.isDirectory())) { - continue; - } - if (entry.isDirectory()) { - queue.push(fullPath); - continue; - } - if (entry.isFile() && fullPath.endsWith(suffix)) { - matches.push(fullPath); - } - } - } - - return matches; -} - -function loadGitignoreMatcher(projectRoot: string): ((relPath: string, isDir: boolean) => boolean) | null { - const gitignorePath = path.join(projectRoot, ".gitignore"); - if (!fs.existsSync(gitignorePath)) { - const ig = ignore(); - ig.add(DEFAULT_GITIGNORE); - return (relPath: string, isDir: boolean) => { - if (!relPath) { - return false; - } - const candidate = isDir ? `${relPath}/` : relPath; - return ig.ignores(candidate); - }; - } - - let content = ""; - try { - content = fs.readFileSync(gitignorePath, "utf8"); - } catch { - const ig = ignore(); - ig.add(DEFAULT_GITIGNORE); - return (relPath: string, isDir: boolean) => { - if (!relPath) { - return false; - } - const candidate = isDir ? `${relPath}/` : relPath; - return ig.ignores(candidate); - }; - } - - const ig = ignore(); - ig.add(DEFAULT_GITIGNORE); - ig.add(content); - return (relPath: string, isDir: boolean) => { - if (!relPath) { - return false; - } - const candidate = isDir ? `${relPath}/` : relPath; - return ig.ignores(candidate); - }; -} - -function parseLineNumber( - value: unknown, - label: string -): { ok: true; value: number | null } | { ok: false; error: string } { - if (value === undefined || value === null) { - return { ok: true, value: null }; - } - const numeric = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(numeric)) { - return { ok: false, error: `${label} must be a number.` }; - } - const integer = Math.trunc(numeric); - if (integer < 1) { - return { ok: false, error: `${label} must be >= 1.` }; - } - return { ok: true, value: integer }; -} - -function parseLineLimit(value: unknown): { ok: true; value: number } | { ok: false; error: string } { - if (value === undefined || value === null) { - return { ok: true, value: DEFAULT_LINE_LIMIT }; - } - const numeric = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(numeric)) { - return { ok: false, error: "limit must be a number." }; - } - const integer = Math.trunc(numeric); - if (integer <= 0) { - return { ok: false, error: "limit must be > 0." }; - } - return { ok: true, value: integer }; -} - -function readTextFile(filePath: string, offset: number | null, limit: number): TextReadResult { - const metadata = readTextFileWithMetadata(filePath); - const raw = metadata.content; - if (!raw) { - return { - content: "", - output: "WARNING: File is empty.", - startLine: offset ?? 1, - endLine: offset ?? 1, - totalLines: 0, - isPartialView: false, - encoding: metadata.encoding, - lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp, - }; - } - - const lines = raw.split("\n"); - if (lines.length === 1 && lines[0] === "") { - return { - content: "", - output: "WARNING: File is empty.", - startLine: offset ?? 1, - endLine: offset ?? 1, - totalLines: 0, - isPartialView: false, - encoding: metadata.encoding, - lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp, - }; - } - - const startIndex = offset ? offset - 1 : 0; - const endIndex = startIndex + limit; - const selected = lines.slice(startIndex, endIndex); - const startLine = startIndex + 1; - const endLine = selected.length > 0 ? startIndex + selected.length : startLine; - const isPartialView = startLine !== 1 || endLine < lines.length; - return { - content: selected.join("\n"), - output: formatWithLineNumbers(selected, startLine), - startLine, - endLine, - totalLines: lines.length, - isPartialView, - encoding: metadata.encoding, - lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp, - }; -} - -function formatWithLineNumbers(lines: string[], startLineNumber: number): string { - return lines - .map((line, index) => { - const lineNumber = startLineNumber + index; - const trimmedLine = line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) : line; - return `${String(lineNumber).padStart(LINE_NUMBER_WIDTH, " ")}\t${trimmedLine}`; - }) - .join("\n"); -} - -function isImageExtension(ext: string): boolean { - return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".svg", ".ico", ".avif"].includes(ext); -} - -function getImageMimeType(ext: string): string { - switch (ext) { - case ".jpg": - case ".jpeg": - return "image/jpeg"; - case ".gif": - return "image/gif"; - case ".webp": - return "image/webp"; - case ".bmp": - return "image/bmp"; - case ".tif": - case ".tiff": - return "image/tiff"; - case ".svg": - return "image/svg+xml"; - case ".ico": - return "image/x-icon"; - case ".avif": - return "image/avif"; - case ".png": - default: - return "image/png"; - } -} - -function buildImageFollowUpMessage(filePath: string, mime: string, buffer: Buffer): ToolExecutionFollowUpMessage { - const fileName = path.basename(filePath); - return { - role: "system", - content: - `The read tool has loaded \`${fileName}\`. ` + "Use the attached image content to answer the original request.", - contentParams: [ - { - type: "image_url", - image_url: { - url: `data:${mime};base64,${buffer.toString("base64")}`, - }, - }, - ], - }; -} - -function countPdfPages(buffer: Buffer): number | null { - try { - const content = buffer.toString("latin1"); - const matches = content.match(/\/Type\s*\/Page\b(?!s)/g); - return matches ? matches.length : 0; - } catch { - return null; - } -} - -function parsePageRange(input: string): PageRange { - const trimmed = input.trim(); - if (!trimmed) { - throw new Error("pages must be a non-empty string."); - } - if (trimmed.includes(",")) { - throw new Error('pages must be a single range like "1-5" or "3".'); - } - - const parts = trimmed.split("-").map((part) => part.trim()); - if (parts.length === 1) { - const value = parsePositiveInt(parts[0], "pages"); - return { start: value, end: value, count: 1 }; - } - - if (parts.length === 2) { - const start = parsePositiveInt(parts[0], "pages"); - const end = parsePositiveInt(parts[1], "pages"); - if (end < start) { - throw new Error("pages range end must be >= start."); - } - return { start, end, count: end - start + 1 }; - } - - throw new Error('pages must be a single range like "1-5" or "3".'); -} - -function parsePositiveInt(value: string, label: string): number { - const numeric = Number(value); - if (!Number.isFinite(numeric)) { - throw new Error(`${label} must be a number.`); - } - const integer = Math.trunc(numeric); - if (integer < 1) { - throw new Error(`${label} must be >= 1.`); - } - return integer; -} - -function readNotebook(filePath: string): string { - const raw = fs.readFileSync(filePath, "utf8"); - if (!raw) { - return "WARNING: File is empty."; - } - - const parsed = JSON.parse(raw) as { - cells?: Array<{ - cell_type?: string; - source?: string[] | string; - outputs?: Array>; - }>; - }; - - const lines: string[] = []; - const cells = Array.isArray(parsed.cells) ? parsed.cells : []; - cells.forEach((cell, index) => { - const cellType = cell.cell_type ?? "unknown"; - lines.push(`# Cell ${index + 1} (${cellType})`); - - const source = normalizeNotebookField(cell.source); - if (source.length > 0) { - lines.push(...source); - } - - const outputs = Array.isArray(cell.outputs) ? cell.outputs : []; - outputs.forEach((output, outputIndex) => { - const outputType = typeof output.output_type === "string" ? output.output_type : "output"; - lines.push(`# Output ${outputIndex + 1} (${outputType})`); - lines.push(...formatNotebookOutput(output)); - }); - }); - - if (lines.length === 0) { - return "WARNING: Notebook has no cells."; - } - - return formatWithLineNumbers(lines, 1); -} - -function normalizeNotebookField(value: unknown): string[] { - if (Array.isArray(value)) { - return value.map((item) => String(item).replace(/\r?\n$/, "")); - } - if (typeof value === "string") { - return value.split(/\r?\n/); - } - return []; -} - -function formatNotebookOutput(output: Record): string[] { - const lines: string[] = []; - const text = output.text; - if (Array.isArray(text)) { - lines.push(...text.map((item) => String(item).replace(/\r?\n$/, ""))); - } else if (typeof text === "string") { - lines.push(...text.split(/\r?\n/)); - } - - const data = output.data; - if (data && typeof data === "object") { - const record = data as Record; - const textPlain = record["text/plain"]; - if (Array.isArray(textPlain)) { - lines.push(...textPlain.map((item) => String(item).replace(/\r?\n$/, ""))); - } else if (typeof textPlain === "string") { - lines.push(...textPlain.split(/\r?\n/)); - } - - const imagePng = record["image/png"]; - if (typeof imagePng === "string") { - lines.push(`[image/png ${imagePng.length} chars]`); - } - - const imageJpeg = record["image/jpeg"]; - if (typeof imageJpeg === "string") { - lines.push(`[image/jpeg ${imageJpeg.length} chars]`); - } - } - - const trace = output.traceback; - if (Array.isArray(trace)) { - lines.push(...trace.map((item) => String(item).replace(/\r?\n$/, ""))); - } - - if (lines.length === 0) { - lines.push("[output omitted]"); - } - - return lines; -} diff --git a/src/tools/runtime.ts b/src/tools/runtime.ts deleted file mode 100644 index 9a4620b..0000000 --- a/src/tools/runtime.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { z } from "zod"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; - -export type ValidationResult = { ok: true; input: Record } | { ok: false; error: string }; - -export function semanticBoolean(defaultValue = false) { - return z.preprocess((value) => { - if (value === "true") { - return true; - } - if (value === "false") { - return false; - } - return value; - }, z.boolean().default(defaultValue)); -} - -export function semanticInteger(label: string, options: { min?: number } = {}) { - return z.preprocess( - (value) => { - if (typeof value === "string" && value.trim()) { - return Number(value); - } - return value; - }, - z - .number() - .int() - .min(options.min ?? Number.MIN_SAFE_INTEGER, `${label} must be >= ${options.min ?? Number.MIN_SAFE_INTEGER}.`) - ); -} - -export async function executeValidatedTool>>( - name: string, - schema: TSchema, - rawArgs: Record, - context: ToolExecutionContext, - handler: (input: z.infer, context: ToolExecutionContext) => Promise, - options: { - preprocess?: (args: Record) => ValidationResult; - } = {} -): Promise { - const preprocessed: ValidationResult = options.preprocess - ? options.preprocess(rawArgs) - : { ok: true, input: rawArgs }; - if (!preprocessed.ok) { - return { - ok: false, - name, - error: `InputValidationError: ${preprocessed.error}`, - }; - } - - const parsed = schema.safeParse(preprocessed.input); - if (!parsed.success) { - return { - ok: false, - name, - error: `InputValidationError: ${formatZodError(parsed.error)}`, - }; - } - - return handler(parsed.data, context); -} - -function formatZodError(error: z.ZodError): string { - const issue = error.issues[0]; - if (!issue) { - return "Invalid tool input."; - } - - const path = issue.path.length > 0 ? `${issue.path.join(".")}: ` : ""; - return `${path}${issue.message}`; -} diff --git a/src/tools/shell-utils.ts b/src/tools/shell-utils.ts deleted file mode 100644 index 223b95b..0000000 --- a/src/tools/shell-utils.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { execFileSync } from "child_process"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import * as pathWin32 from "path/win32"; - -const WINDOWS_GIT_LOCATIONS = ["C:\\Program Files\\Git\\cmd\\git.exe", "C:\\Program Files (x86)\\Git\\cmd\\git.exe"]; - -const NUL_REDIRECT_REGEX = /(\d?&?>+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g; -let cachedGitBashPath: string | null = null; - -export type ShellKind = "bash" | "zsh" | "unknown"; - -export function setShellIfWindows(): void { - if (process.platform !== "win32") { - return; - } - process.env.SHELL = findGitBashPath(); -} - -export function findGitBashPath(): string { - if (cachedGitBashPath) { - return cachedGitBashPath; - } - - for (const gitPath of findAllWindowsExecutableCandidates("git")) { - const bashPath = pathWin32.join(gitPath, "..", "..", "bin", "bash.exe"); - if (fs.existsSync(bashPath)) { - cachedGitBashPath = bashPath; - return bashPath; - } - } - - throw new Error( - "Deep Code on Windows requires Git Bash. Install Git Bash for Windows and ensure bash.exe is available in PATH." - ); -} - -export function resolveShellPath(): string { - if (process.platform === "win32") { - return findGitBashPath(); - } - - const envShell = process.env.SHELL; - if (envShell && getShellKind(envShell) !== "unknown") { - return envShell; - } - return "/bin/bash"; -} - -export function getShellKind(shellPath: string): ShellKind { - const executable = shellPath.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? ""; - if (executable === "bash" || executable === "bash.exe") { - return "bash"; - } - if (executable === "zsh" || executable === "zsh.exe") { - return "zsh"; - } - return "unknown"; -} - -export function buildShellInitCommand(shellPath: string): string | null { - switch (getShellKind(shellPath)) { - case "zsh": - return ['ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi'].join("; "); - case "bash": - return ['BASHRC="${BASH_ENV:-$HOME/.bashrc}"', 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi'].join("; "); - default: - return null; - } -} - -export function buildDisableExtglobCommand(shellPath: string): string | null { - switch (getShellKind(shellPath)) { - case "bash": - return "shopt -u extglob 2>/dev/null || true"; - case "zsh": - return "setopt NO_EXTENDED_GLOB 2>/dev/null || true"; - default: - return null; - } -} - -export function rewriteWindowsNullRedirect(command: string): string { - return command.replace(NUL_REDIRECT_REGEX, "$1/dev/null"); -} - -export function windowsPathToPosixPath(windowsPath: string): string { - if (windowsPath.startsWith("\\\\")) { - return windowsPath.replace(/\\/g, "/"); - } - - const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\]/); - if (driveMatch) { - const driveLetter = driveMatch[1].toLowerCase(); - return `/${driveLetter}${windowsPath.slice(2).replace(/\\/g, "/")}`; - } - - return windowsPath.replace(/\\/g, "/"); -} - -export function posixPathToWindowsPath(posixPath: string): string { - if (posixPath.startsWith("//")) { - return posixPath.replace(/\//g, "\\"); - } - - const cygdriveMatch = posixPath.match(/^\/cygdrive\/([A-Za-z])(\/|$)/); - if (cygdriveMatch) { - const driveLetter = cygdriveMatch[1].toUpperCase(); - const rest = posixPath.slice(`/cygdrive/${cygdriveMatch[1]}`.length); - return `${driveLetter}:${(rest || "\\").replace(/\//g, "\\")}`; - } - - const driveMatch = posixPath.match(/^\/([A-Za-z])(\/|$)/); - if (driveMatch) { - const driveLetter = driveMatch[1].toUpperCase(); - const rest = posixPath.slice(2); - return `${driveLetter}:${(rest || "\\").replace(/\//g, "\\")}`; - } - - return posixPath.replace(/\//g, "\\"); -} - -export function toNativeCwd(shellCwd: string): string { - if (process.platform !== "win32") { - return shellCwd; - } - return posixPathToWindowsPath(shellCwd); -} - -export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = { - ...process.env, - SHELL: shellPath, - GIT_EDITOR: "true", - }; - - if (process.platform === "win32") { - const tmpdir = windowsPathToPosixPath(os.tmpdir()); - env.TMPDIR = tmpdir; - env.TMPPREFIX = path.posix.join(tmpdir, "zsh"); - } - - return env; -} - -function findAllWindowsExecutableCandidates(executable: string): string[] { - const extraCandidates = executable === "git" ? WINDOWS_GIT_LOCATIONS : []; - - try { - const output = execFileSync("where.exe", [executable], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - windowsHide: true, - }); - return filterWindowsExecutableCandidates([ - ...output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ...extraCandidates, - ]); - } catch { - return filterWindowsExecutableCandidates(extraCandidates); - } -} - -function filterWindowsExecutableCandidates(candidates: string[]): string[] { - const cwd = process.cwd().toLowerCase(); - const seen = new Set(); - const results: string[] = []; - - for (const candidate of candidates) { - const normalized = path.resolve(candidate).toLowerCase(); - const candidateDir = path.dirname(normalized).toLowerCase(); - if (candidateDir === cwd || normalized.startsWith(`${cwd}${path.sep}`)) { - continue; - } - if (!seen.has(normalized) && fs.existsSync(candidate)) { - seen.add(normalized); - results.push(candidate); - } - } - - return results; -} diff --git a/src/tools/state.ts b/src/tools/state.ts deleted file mode 100644 index 7816573..0000000 --- a/src/tools/state.ts +++ /dev/null @@ -1,153 +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; - offset?: number; - limit?: number; - isPartialView?: boolean; - encoding?: BufferEncoding; - lineEndings?: FileLineEnding; -}; - -export type FileSnippet = { - id: string; - filePath: string; - startLine: number; - endLine: number; - preview: string; -}; - -const fileStatesBySession = new Map>(); -const snippetsBySession = new Map>(); -const snippetCountersBySession = 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): 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); - sessionState.set(normalizedPath, { - ...state, - filePath: normalizedPath, - }); -} - -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 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, - }; - - 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; -} diff --git a/src/tools/web-search-handler.ts b/src/tools/web-search-handler.ts index 558271b..fdba06b 100644 --- a/src/tools/web-search-handler.ts +++ b/src/tools/web-search-handler.ts @@ -1,4 +1,3 @@ -import { randomUUID } from "crypto"; import { spawn } from "child_process"; import type OpenAI from "openai"; import type { CreateOpenAIClient, ToolExecutionContext, ToolExecutionResult } from "./executor"; @@ -6,7 +5,6 @@ import type { CreateOpenAIClient, ToolExecutionContext, ToolExecutionResult } fr const MAX_OUTPUT_CHARS = 30000; const MAX_CAPTURE_CHARS = 10 * 1024 * 1024; const WEB_SEARCH_TOOL_ACTIVITY_PREFIX = "WebSearch:"; -const DEFAULT_WEB_SEARCH_API_URL = "https://deepcode.vegamo.cn/api/plugin/web-search"; type SearchLanguage = "en" | "zh"; @@ -39,7 +37,7 @@ export async function handleWebSearchTool( return { ok: false, name: "WebSearch", - error: 'Missing required "query" string.', + error: "Missing required \"query\" string." }; } @@ -49,15 +47,12 @@ export async function handleWebSearchTool( return executeConfiguredWebSearch(query, scriptPath, context); } - if (!hasUsableClient(llmContext)) { - return { - ok: false, - name: "WebSearch", - error: "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json.", - }; - } - - return executeDefaultWebSearch(query, llmContext, context); + return { + ok: false, + name: "WebSearch", + error: + "WebSearch requires a custom search script. Set \"webSearchTool\" in ~/.deepcode/settings.json." + }; } function hasUsableClient(value: ReturnType | undefined): value is LLMClientContext { @@ -83,8 +78,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, stderr: execution.stderr || undefined, - truncated, - }, + truncated + } }; } @@ -98,8 +93,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, stderr: execution.stderr || undefined, - truncated, - }, + truncated + } }; } @@ -111,42 +106,11 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, truncated, - stderr: execution.stderr || undefined, - }, + stderr: execution.stderr || undefined + } }; } -async function executeDefaultWebSearch( - query: string, - llmContext: LLMClientContext, - context: ToolExecutionContext -): Promise { - try { - const prepared = await prepareSearchQuery(query, llmContext); - const output = await runDefaultWebSearchRequest(prepared.resolvedQuery, llmContext.machineId, context); - - return { - ok: true, - name: "WebSearch", - output, - metadata: { - originalQuery: query, - resolvedQuery: prepared.resolvedQuery, - translated: prepared.translated, - dominantLanguage: prepared.decision.dominantLanguage, - languageReason: prepared.decision.reason, - }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - name: "WebSearch", - error: `WebSearch default mode failed: ${message}`, - }; - } -} - async function runWebSearchScript( scriptPath: string, query: string, @@ -156,7 +120,7 @@ async function runWebSearchScript( const child = spawn(scriptPath, [query], { cwd: context.projectRoot, env: process.env, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"] }); const pid = child.pid; if (typeof pid === "number") { @@ -187,7 +151,7 @@ async function runWebSearchScript( stderr, exitCode: typeof code === "number" ? code : null, signal: signal ?? null, - error, + error }); }); }); @@ -200,37 +164,28 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): if (decision.dominantLanguage === "en" && containsChinese) { const translatedQuery = await translateQuery(query, "English", llmContext); if (translatedQuery) { - return { - resolvedQuery: translatedQuery, - decision, - translated: true, - }; + return { resolvedQuery: translatedQuery, decision, translated: true }; } } if (decision.dominantLanguage === "zh" && !containsChinese) { const translatedQuery = await translateQuery(query, "Chinese", llmContext); if (translatedQuery) { - return { - resolvedQuery: translatedQuery, - decision, - translated: true, - }; + return { resolvedQuery: translatedQuery, decision, translated: true }; } } - return { - resolvedQuery: query, - decision, - translated: false, - }; + return { resolvedQuery: query, decision, translated: false }; } function containsChineseChar(text: string): boolean { - return /[\u4e00-\u9fff]/.test(text); + return /[一-鿿]/.test(text); } -async function decideSearchLanguage(query: string, llmContext: LLMClientContext): Promise { +async function decideSearchLanguage( + query: string, + llmContext: LLMClientContext +): Promise { const prompt = `Decide whether the topic below has more useful online material in English or Chinese. Topic: @@ -251,7 +206,7 @@ Do not include markdown or any extra text.`; return { dominantLanguage, - reason: typeof result.reason === "string" ? result.reason : "", + reason: typeof result.reason === "string" ? result.reason : "" }; } @@ -271,15 +226,13 @@ Query: ${query} \`\`\``; - return stripCodeFence(await chat(llmContext, prompt)) - .trim() - .replace(/^['"]|['"]$/g, ""); + return stripCodeFence(await chat(llmContext, prompt)).trim().replace(/^['"]|['"]$/g, ""); } async function chat(llmContext: LLMClientContext, prompt: string): Promise { const response = await llmContext.client.chat.completions.create({ model: llmContext.model, - messages: [{ role: "user", content: prompt }], + messages: [{ role: "user", content: prompt }] }); const content = response.choices?.[0]?.message?.content as unknown; @@ -315,47 +268,6 @@ function stripCodeFence(text: string): string { return fenceMatch ? fenceMatch[1] : trimmed; } -async function runDefaultWebSearchRequest( - query: string, - machineId: string | undefined, - context: ToolExecutionContext -): Promise { - if (!machineId) { - throw new Error("Missing vscode.env.machineId for the default WebSearch request."); - } - - const activityId = `web-search-${randomUUID()}`; - context.onProcessStart?.(activityId, formatWebSearchActivityLabel(query)); - try { - const response = await fetch(DEFAULT_WEB_SEARCH_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Token: machineId, - }, - body: JSON.stringify({ query }), - }); - - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw new Error(`WebSearch API request failed with status ${response.status}${body ? `: ${body}` : ""}`); - } - - const payload = (await response.json()) as { - success?: unknown; - result?: unknown; - }; - - if (typeof payload.result === "string" && payload.result.trim()) { - return payload.result.trim(); - } - } finally { - context.onProcessExit?.(activityId); - } - - throw new Error("The web search response was empty."); -} - function appendChunk(existing: string, chunk: string | Buffer): string { if (existing.length >= MAX_CAPTURE_CHARS) { return existing; @@ -369,7 +281,9 @@ function formatWebSearchActivityLabel(query: string): string { const normalizedQuery = query.replace(/\s+/g, " ").trim(); const maxQueryLength = 180; const clippedQuery = - normalizedQuery.length > maxQueryLength ? `${normalizedQuery.slice(0, maxQueryLength - 3)}...` : normalizedQuery; + normalizedQuery.length > maxQueryLength + ? `${normalizedQuery.slice(0, maxQueryLength - 3)}...` + : normalizedQuery; return `${WEB_SEARCH_TOOL_ACTIVITY_PREFIX} ${clippedQuery}`; } diff --git a/src/tools/write-handler.ts b/src/tools/write-handler.ts deleted file mode 100644 index 4524e21..0000000 --- a/src/tools/write-handler.ts +++ /dev/null @@ -1,168 +0,0 @@ -import * as fs from "fs"; -import { z } from "zod"; -import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -import { - buildDiffPreview, - ensureParentDirectory, - hasFileChangedSinceState, - normalizeContent, - readTextFileWithMetadata, - writeTextFile, -} from "./file-utils"; -import { executeValidatedTool } from "./runtime"; -import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "./state"; - -const writeSchema = z.strictObject({ - file_path: z.string().min(1, "file_path is required."), - content: z.string({ - error: - "content must be a string. If you are writing JSON, serialize the full document to text before calling write.", - }), -}); - -type WriteRepairMetadata = { - input_repaired: boolean; - repair_kind: "json-stringify-content"; -} | null; - -export async function handleWriteTool( - args: Record, - context: ToolExecutionContext -): Promise { - let repairMetadata: WriteRepairMetadata = null; - - return executeValidatedTool( - "write", - writeSchema, - args, - context, - async (input) => { - const filePath = normalizeFilePath(input.file_path); - if (!isAbsoluteFilePath(filePath)) { - return { - ok: false, - name: "write", - error: "file_path must be an absolute path.", - }; - } - - const existingFile = fs.existsSync(filePath); - if (existingFile) { - let stat: fs.Stats; - try { - stat = fs.statSync(filePath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - name: "write", - error: `Failed to stat file: ${message}`, - }; - } - - if (stat.isDirectory()) { - return { - ok: false, - name: "write", - error: "file_path points to a directory.", - }; - } - - if (stat.size > 0) { - const fileState = getFileState(context.sessionId, filePath); - if (!fileState || !isFullFileView(fileState)) { - return { - ok: false, - name: "write", - error: "Must read the full existing file before writing.", - }; - } - - if (hasFileChangedSinceState(filePath, fileState)) { - return { - ok: false, - name: "write", - error: "File has been modified since read. Read it again before writing.", - }; - } - } - } - - const normalizedContent = normalizeContent(input.content); - - try { - ensureParentDirectory(filePath); - - const existingMetadata = existingFile ? readTextFileWithMetadata(filePath) : null; - const encoding = existingMetadata?.encoding ?? "utf8"; - const lineEndings = existingMetadata?.lineEndings ?? (input.content.includes("\r\n") ? "CRLF" : "LF"); - const diffPreview = buildDiffPreview(filePath, existingMetadata?.content ?? null, normalizedContent); - const bytes = writeTextFile(filePath, normalizedContent, encoding, lineEndings); - const freshMetadata = readTextFileWithMetadata(filePath); - - recordFileState(context.sessionId, { - filePath, - content: freshMetadata.content, - timestamp: freshMetadata.timestamp, - encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings, - }); - - return { - ok: true, - name: "write", - output: existingMetadata ? "Updated file." : "Created file.", - metadata: { - type: existingMetadata ? "update" : "create", - file_path: filePath, - bytes, - encoding: freshMetadata.encoding, - line_endings: freshMetadata.lineEndings, - cache_refreshed: true, - diff_preview: diffPreview, - ...repairMetadata, - }, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - ok: false, - name: "write", - error: message, - }; - } - }, - { - preprocess: (rawInput) => { - const filePath = typeof rawInput.file_path === "string" ? normalizeFilePath(rawInput.file_path) : ""; - const content = rawInput.content; - if ( - filePath.toLowerCase().endsWith(".json") && - content !== null && - typeof content === "object" && - !Buffer.isBuffer(content) - ) { - repairMetadata = { - input_repaired: true, - repair_kind: "json-stringify-content", - }; - - return { - ok: true, - input: { - ...rawInput, - file_path: filePath, - content: JSON.stringify(content, null, 2), - }, - }; - } - - repairMetadata = null; - return { - ok: true, - input: typeof rawInput.file_path === "string" ? { ...rawInput, file_path: filePath } : rawInput, - }; - }, - } - ); -} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4c13909..beece86 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -12,13 +12,14 @@ import { type SessionMessage, type SessionStatus, type SkillInfo, - type UserPromptContent, + type UserPromptContent } from "../session"; import { - applyModelConfigSelection, resolveSettings, + type DataCollection, type DeepcodingSettings, - type ModelConfigSelection, + type ProviderPrivacyMode, + type ReasoningEffort } from "../settings"; import { PromptInput, type PromptSubmission } from "./PromptInput"; import { MessageView } from "./MessageView"; @@ -30,7 +31,7 @@ import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, - type AskUserQuestionAnswers, + type AskUserQuestionAnswers } from "./askUserQuestion"; import { buildExitSummaryText } from "./exitSummary"; @@ -63,8 +64,8 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [isExiting, setIsExiting] = useState(false); const [showWelcome, setShowWelcome] = useState(true); const [welcomeNonce, setWelcomeNonce] = useState(0); - const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings()); const [nowTick, setNowTick] = useState(0); + const [balance, setBalance] = useState(""); const messagesRef = useRef([]); messagesRef.current = messages; @@ -89,7 +90,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return; } setStreamProgress(progress); - }, + } }); }, [projectRoot]); @@ -101,30 +102,52 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return () => clearInterval(id); }, [busy]); + useEffect(() => { + refreshSessionsList(); + void refreshSkills(); + void refreshBalance(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + function loadVisibleMessages(manager: SessionManager, sessionId: string): SessionMessage[] { return manager.listSessionMessages(sessionId).filter((m) => m.visible); } - const refreshSessionsList = useCallback((): void => { + function refreshSessionsList(): 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] - ); + async function refreshSkills(sessionId?: string): Promise { + try { + const list = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); + setSkills(list); + } catch { + // ignore + } + } - useEffect(() => { - refreshSessionsList(); - void refreshSkills(); - }, [refreshSessionsList, refreshSkills]); + async function refreshBalance(): Promise { + const settings = resolveCurrentSettings(); + if (!settings.apiKey || !isDeepSeekBaseURL(settings.baseURL)) { + return; + } + try { + const url = new URL("/user/balance", settings.baseURL).toString(); + const res = await fetch(url, { headers: { Authorization: `Bearer ${settings.apiKey}` } }); + if (!res.ok) return; + const data = await res.json() as { balance_infos?: Array<{ currency: string; total_balance: string }> }; + const infos = data.balance_infos ?? []; + const info = + infos.find((i) => i.currency === "USD") ?? + infos.find((i) => i.currency === "EUR") ?? + infos[0]; + if (info) { + setBalance(`${info.total_balance} ${info.currency}`); + } + } catch { + // ignore — balance is optional display + } + } const writeRef = useRef(write); writeRef.current = write; @@ -178,19 +201,22 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const prompt: UserPromptContent = { text: submission.text, imageUrls: submission.imageUrls, - skills: - submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined, + 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]" : ""); + const userDisplayContent = trimmedText + || (selectedSkillNames.length > 0 ? `Use skills: ${selectedSkillNames.join(", ")}` : "") + || (submission.imageUrls.length > 0 ? "[Image]" : ""); if (userDisplayContent) { - setMessages((prev) => [...prev, buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length)]); + setMessages((prev) => [ + ...prev, + buildSyntheticUserMessage(userDisplayContent, submission.imageUrls.length) + ]); } setBusy(true); @@ -200,6 +226,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R await sessionManager.handleUserPrompt(prompt); await refreshSkills(); refreshSessionsList(); + void refreshBalance(); } catch (error) { const message = error instanceof Error ? error.message : String(error); setErrorLine(message); @@ -209,28 +236,15 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setRunningProcesses(null); } }, - [exit, onRestart, sessionManager, refreshSkills, refreshSessionsList] + [exit, onRestart, sessionManager] ); const handleInterrupt = useCallback(() => { sessionManager.interruptActiveSession(); }, [sessionManager]); - const handleModelConfigChange = useCallback((selection: ModelConfigSelection): string => { - const current = resolveCurrentSettings(); - const { changed } = writeModelConfigSelection(selection, current); - const next = resolveCurrentSettings(); - setResolvedSettings(next); - if (!changed) { - return "Model settings unchanged"; - } - return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; - }, []); - const handleSubmit = useCallback( - (submission: PromptSubmission) => { - void handlePrompt(submission); - }, + (submission: PromptSubmission) => { void handlePrompt(submission); }, [handlePrompt] ); @@ -257,7 +271,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R setActiveStatus(session?.status ?? null); await refreshSkills(sessionId); }, - [sessionManager, refreshSkills] + [sessionManager] ); const [stableColumns, setStableColumns] = useState(columns); @@ -265,37 +279,6 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R 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 @@ -304,29 +287,32 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R .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 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 + ? buildLoadingText({ progress: streamProgress, processes: runningProcesses, now: Date.now() }) + : null, [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 welcomeSettings = useMemo(() => resolveCurrentSettings(), []); + 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]; @@ -338,7 +324,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R (answers: AskUserQuestionAnswers) => { void handlePrompt({ text: formatAskUserQuestionAnswers(answers), - imageUrls: [], + imageUrls: [] }); }, [handlePrompt] @@ -352,7 +338,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R }, [pendingQuestion]); return ( - + {(item) => { if (item.id.startsWith("__welcome__")) { @@ -367,12 +353,18 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R /> ); } - return ; + return ( + + ); }} - {statusLine ? ( + {(statusLine || balance) ? ( - {statusLine} + {[statusLine, balance ? `balance: ${balance}` : ""].filter(Boolean).join(" - ")} ) : null} {errorLine ? ( @@ -396,14 +388,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R )} @@ -431,32 +421,58 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session imageCount > 0 ? Array.from({ length: imageCount }, () => ({ type: "image_url", - image_url: { url: "" }, + image_url: { url: "" } })) : null, messageParams: null, compacted: false, visible: true, createTime: now, - updateTime: now, + updateTime: now }; } +function fmtTokens(n: number): string { + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + function buildStatusLine(entry: SessionEntry): string { const parts: string[] = []; parts.push(`status: ${entry.status}`); - if (typeof entry.activeTokens === "number" && entry.activeTokens > 0) { + + const u = entry.lastResponseUsage as Record | null | undefined; + if (u && typeof u === "object") { + const prompt = typeof u.prompt_tokens === "number" ? u.prompt_tokens : 0; + const completion = typeof u.completion_tokens === "number" ? u.completion_tokens : 0; + const cacheHit = typeof u.prompt_cache_hit_tokens === "number" ? u.prompt_cache_hit_tokens : 0; + if (prompt > 0 || completion > 0) { + let tok = `in: ${fmtTokens(prompt)} - out: ${fmtTokens(completion)}`; + if (cacheHit > 0) tok += ` - cache: ${fmtTokens(cacheHit)}`; + parts.push(tok); + } + } else if (typeof entry.activeTokens === "number" && entry.activeTokens > 0) { parts.push(`tokens: ${entry.activeTokens}`); } + if (entry.failReason) { parts.push(`fail: ${entry.failReason}`); } - return parts.join(" · "); + return parts.join(" - "); +} + +function isDeepSeekBaseURL(baseURL: string | undefined): boolean { + if (!baseURL) return false; + try { + return new URL(baseURL).hostname.toLowerCase().includes("deepseek.com"); + } catch { + return baseURL.toLowerCase().includes("deepseek.com"); + } } export function readSettings(): DeepcodingSettings | null { try { - const settingsPath = getSettingsPath(); + const settingsPath = path.join(os.homedir(), ".deepcode", "settings.json"); if (!fs.existsSync(settingsPath)) { return null; } @@ -467,28 +483,10 @@ export function readSettings(): DeepcodingSettings | null { } } -export function writeSettings(settings: DeepcodingSettings): void { - const settingsPath = getSettingsPath(); - 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() -): { changed: boolean; settings: DeepcodingSettings } { - const rawSettings = readSettings(); - const result = applyModelConfigSelection(rawSettings, current, selection); - if (result.changed) { - writeSettings(result.settings); - } - return result; -} - export function resolveCurrentSettings(): ReturnType { return resolveSettings(readSettings(), { model: DEFAULT_MODEL, - baseURL: DEFAULT_BASE_URL, + baseURL: DEFAULT_BASE_URL }); } @@ -497,11 +495,16 @@ export function createOpenAIClient(): { model: string; baseURL: string; thinkingEnabled: boolean; - reasoningEffort: "high" | "max"; + reasoningEffort: ReasoningEffort; debugLogEnabled: boolean; notify?: string; webSearchTool?: string; machineId?: string; + provider?: string; + providerPrivacyMode: ProviderPrivacyMode; + zdr?: boolean; + dataCollection?: DataCollection; + cacheControl?: boolean; } { const settings = resolveCurrentSettings(); if (!settings.apiKey) { @@ -515,12 +518,21 @@ export function createOpenAIClient(): { notify: settings.notify, webSearchTool: settings.webSearchTool, machineId: getMachineId(), + provider: settings.provider, + providerPrivacyMode: settings.providerPrivacyMode, + zdr: settings.zdr, + dataCollection: settings.dataCollection, + cacheControl: settings.cacheControl }; } const client = new OpenAI({ apiKey: settings.apiKey, baseURL: settings.baseURL || undefined, + defaultHeaders: { + "Authorization": `Bearer ${settings.apiKey}`, + "X-OpenRouter-Experimental-Metadata": "1" + } }); return { client, @@ -532,6 +544,11 @@ export function createOpenAIClient(): { notify: settings.notify, webSearchTool: settings.webSearchTool, machineId: getMachineId(), + provider: settings.provider, + providerPrivacyMode: settings.providerPrivacyMode, + zdr: settings.zdr, + dataCollection: settings.dataCollection, + cacheControl: settings.cacheControl }; } @@ -552,18 +569,3 @@ function getMachineId(): string | undefined { return undefined; } } - -function getSettingsPath(): string { - return path.join(os.homedir(), ".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/AskUserQuestionPrompt.tsx b/src/ui/AskUserQuestionPrompt.tsx index 952f9cf..5f0b3e3 100644 --- a/src/ui/AskUserQuestionPrompt.tsx +++ b/src/ui/AskUserQuestionPrompt.tsx @@ -83,7 +83,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): if (key.backspace && isCurrentOther) { setOtherTexts((prev) => ({ ...prev, - [questionIndex]: (prev[questionIndex] ?? "").slice(0, -1), + [questionIndex]: (prev[questionIndex] ?? "").slice(0, -1) })); return; } @@ -98,7 +98,7 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): if (sanitized) { setOtherTexts((prev) => ({ ...prev, - [questionIndex]: `${prev[questionIndex] ?? ""}${sanitized}`, + [questionIndex]: `${prev[questionIndex] ?? ""}${sanitized}` })); } return; @@ -131,7 +131,9 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): function toggleOption(value: string): void { setSelectedValues((prev) => { const current = prev[questionIndex] ?? []; - const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value]; + const next = current.includes(value) + ? current.filter((item) => item !== value) + : [...current, value]; return { ...prev, [questionIndex]: next }; }); } @@ -139,17 +141,15 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): function commitCurrentQuestion(): void { const answer = buildAnswerForQuestion(question, options[cursorIndex], selectedForQuestion, otherText); if (!answer) { - setStatusMessage( - question.multiSelect - ? "Select at least one option with Space, or type an Other answer." - : "Select an option, or type an Other answer." - ); + setStatusMessage(question.multiSelect + ? "Select at least one option with Space, or type an Other answer." + : "Select an option, or type an Other answer."); return; } const nextAnswers = { ...answers, - [question.question]: answer, + [question.question]: answer }; setAnswers(nextAnswers); @@ -165,13 +165,8 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): return ( - - Answer questions - - - {" "} - {questionIndex + 1}/{questions.length} - + Answer questions + {questionIndex + 1}/{questions.length} {question.question} @@ -180,45 +175,35 @@ export function AskUserQuestionPrompt({ questions, onSubmit, onCancel }: Props): const isSelected = option.isOther ? selectedForQuestion.includes(OTHER_VALUE) || Boolean(otherText.trim()) : selectedForQuestion.includes(option.value) || answers[question.question] === option.label; - const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : isSelected ? "●" : "○"; + const marker = question.multiSelect ? (isSelected ? "[x]" : "[ ]") : (isSelected ? "●" : "○"); return ( - {isCursor ? "› " : " "} - {marker} {option.label} + {isCursor ? "› " : " "}{marker} {option.label} {option.isOther ? ( - + {otherText ? ( - - {otherText} - {isCursor ? : null} - + {otherText}{isCursor ? : null} ) : ( {isCursor ? "type your answer here" : "type a custom answer"} )} ) : null} - {option.description ? {option.description} : null} + {option.description ? ( + {option.description} + ) : null} ); })} - {statusMessage ?? - (isCurrentOther - ? "Type your answer · Backspace edit · Enter submit/next · ↑ choose presets · Esc type manually" - : question.multiSelect - ? "↑/↓ move · Space toggle · Enter submit/next · Esc type manually" - : "↑/↓ move · Enter select/next · Esc type manually")} + {statusMessage ?? (isCurrentOther + ? "Type your answer · Backspace edit · Enter submit/next · ↑ choose presets · Esc type manually" + : question.multiSelect + ? "↑/↓ move · Space toggle · Enter submit/next · Esc type manually" + : "↑/↓ move · Enter select/next · Esc type manually")} @@ -233,13 +218,13 @@ function buildOptions(question: AskUserQuestionItem | undefined): OptionEntry[] ...question.options.map((option) => ({ label: option.label, description: option.description, - value: option.label, + value: option.label })), { label: "Other", value: OTHER_VALUE, - isOther: true, - }, + isOther: true + } ]; } diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index ae9dd19..fa36d58 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Text } from "ink"; +import {Box, Newline, Text} from "ink"; import { renderMarkdown } from "./markdown"; import type { SessionMessage } from "../session"; @@ -16,11 +16,9 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | if (message.role === "user") { const text = message.content || "(no content)"; return ( - + - - {`>`} - + {`>`} {text} {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( @@ -48,16 +46,15 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | return ( - {content ? {renderMarkdown(content)} : null} + + {content ? {renderMarkdown(content)} : null} + ); } return ( - - - {content ? {renderMarkdown(content)} : null} @@ -91,9 +88,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | if (message.meta?.isSummary) { return ( - - (conversation summary inserted) - + (conversation summary inserted) ); } @@ -103,26 +98,54 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | return null; } +const SERENA_TOOL_LABELS: Record = { + execute_shell_command: "Shell", + read_file: "Read", + create_text_file: "Write", + replace_content: "Edit", + list_dir: "List Dir", + find_file: "Find File", + search_for_pattern: "Search", + get_symbols_overview: "Symbols", + find_symbol: "Find Symbol", + find_referencing_symbols: "References", + find_implementations: "Implementations", + find_declaration: "Declaration", + get_diagnostics_for_file: "Diagnostics", + replace_symbol_body: "Replace Symbol", + insert_after_symbol: "Insert After", + insert_before_symbol: "Insert Before", + rename_symbol: "Rename Symbol", + safe_delete_symbol: "Delete Symbol", + list_memories: "Memories", + read_memory: "Read Memory", + write_memory: "Write Memory", + edit_memory: "Edit Memory", + delete_memory: "Delete Memory", + rename_memory: "Rename Memory", + initial_instructions: "Instructions", + check_onboarding_performed: "Onboarding Check", + onboarding: "Onboarding", +}; + +const SERENA_TOOLS = new Set(Object.keys(SERENA_TOOL_LABELS)); + function StatusLine({ bulletColor, name, - params, + params }: { bulletColor: "gray" | "green" | "red"; name: string; params: string; }): React.ReactElement { + const isSerena = SERENA_TOOLS.has(name); return ( {[ - - ✧ - , - " ", - - {name} - , - params ? {` ${params}`} : null, + isSerena ? serena › : null, + {name}, + params ? {` ${params}`} : null ]} ); @@ -130,6 +153,8 @@ function StatusLine({ function formatToolStatusParams(summary: ToolSummary): string { const params = firstNonEmptyLine(summary.params); + // Never truncate Serena tool args — show the full call + if (SERENA_TOOLS.has(summary.name)) return params; return summary.name.toLowerCase() === "bash" ? params : truncate(params, 120); } @@ -153,16 +178,15 @@ function buildToolSummary(message: SessionMessage): ToolSummary { ? (message.meta.function as { name: string }).name : null; const name = payload.name || metaFunctionName || "tool"; - const params = - name === "AskUserQuestion" - ? extractAskUserQuestionParams(message) || getMetaParams(message) - : getMetaParams(message); + const params = name === "AskUserQuestion" + ? extractAskUserQuestionParams(message) || getMetaParams(message) + : getMetaParams(message); return { name, params, ok: payload.ok !== false, - metadata: payload.metadata, + metadata: payload.metadata }; } @@ -222,11 +246,9 @@ function extractQuestionsFromValue(value: unknown): string { .join(" / "); } -function parseToolPayload(content: string | null): { - name: string | null; - ok: boolean; - metadata: Record | null; -} { +function parseToolPayload( + content: string | null +): { name: string | null; ok: boolean; metadata: Record | null } { if (!content) { return { name: null, ok: true, metadata: null }; } @@ -236,7 +258,7 @@ function parseToolPayload(content: string | null): { return { name: typeof parsed.name === "string" && parsed.name.trim() ? parsed.name.trim() : null, ok: parsed.ok !== false, - metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null, + metadata: isPlainRecord(parsed.metadata) ? parsed.metadata : null }; } catch { return { name: null, ok: true, metadata: null }; @@ -244,7 +266,8 @@ function parseToolPayload(content: string | null): { } function getToolDiffPreviewLines(summary: ToolSummary): DiffPreviewLine[] { - if (!summary.ok || !["edit", "write"].includes(summary.name.toLowerCase())) { + const DIFF_TOOLS = new Set(["edit", "write", "replace_content", "create_text_file"]); + if (!summary.ok || !DIFF_TOOLS.has(summary.name.toLowerCase())) { return []; } const diffPreview = summary.metadata?.diff_preview; @@ -268,7 +291,7 @@ export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { return { marker: " ", content: line.startsWith(" ") ? line.slice(1) : line, - kind: "context", + kind: "context" }; }); } @@ -298,7 +321,10 @@ function isPlainRecord(value: unknown): value is Record { } function formatStatusName(value: string): string { - return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : "Tool"; + if (!value) return "Tool"; + // Serena tools: show the raw tool name so the user sees exactly what was called + if (SERENA_TOOLS.has(value)) return value; + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; } function truncate(value: string, max: number): string { diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 74c38d5..d39918d 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,12 +1,12 @@ -import React, { useEffect, useState } from "react"; +import React, {useEffect, useState} from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; import { EMPTY_BUFFER, + PromptBufferState, backspace, deleteForward, deleteWordBefore, - deleteWordAfter, getCurrentSlashToken, insertText, isEmpty, @@ -18,11 +18,14 @@ import { moveRight, moveWordLeft, moveWordRight, - moveUp, + moveUp } from "./promptBuffer"; -import type { PromptBufferState } from "./promptBuffer"; -import { buildSlashCommands, filterSlashCommands, findExactSlashCommand } from "./slashCommands"; -import type { SlashCommandItem } from "./slashCommands"; +import { + SlashCommandItem, + buildSlashCommands, + filterSlashCommands, + findExactSlashCommand, +} from "./slashCommands"; import { readClipboardImageAsync } from "./clipboard"; import type { SkillInfo } from "../session"; @@ -30,11 +33,10 @@ import type { SkillInfo } from "../session"; export { useTerminalInput, parseTerminalInput } from "./prompt"; export type { InputKey } from "./prompt"; -import { useTerminalInput } from "./prompt"; +import { useTerminalInput, parseTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; -import type { ModelConfigSelection, ReasoningEffort } from "../settings"; export type PromptSubmission = { text: string; @@ -45,7 +47,6 @@ export type PromptSubmission = { type Props = { skills: SkillInfo[]; - modelConfig: ModelConfigSelection; screenWidth: number; promptHistory: string[]; busy: boolean; @@ -53,26 +54,10 @@ type Props = { disabled?: boolean; placeholder?: string; onSubmit: (submission: PromptSubmission) => void; - onModelConfigChange: (selection: ModelConfigSelection) => string | Promise; onInterrupt: () => void; }; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; -export const MODEL_COMMAND_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"] as const; - -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); @@ -93,18 +78,16 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }); export const PromptInput = React.memo(function PromptInput({ - skills, - modelConfig, - screenWidth, - promptHistory, - busy, - loadingText, - disabled, - placeholder, - onSubmit, - onModelConfigChange, - onInterrupt, -}: Props): React.ReactElement { + skills, + screenWidth, + promptHistory, + busy, + loadingText, + disabled, + placeholder, + onSubmit, + onInterrupt + }: Props): React.ReactElement { const { exit } = useApp(); const { stdout } = useStdout(); const [buffer, setBuffer] = useState(EMPTY_BUFFER); @@ -115,9 +98,6 @@ export const PromptInput = React.memo(function PromptInput({ 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 [historyCursor, setHistoryCursor] = useState(-1); const [draftBeforeHistory, setDraftBeforeHistory] = useState(null); const [hasTerminalFocus, setHasTerminalFocus] = useState(true); @@ -125,11 +105,7 @@ export const PromptInput = React.memo(function PromptInput({ const slashItems = React.useMemo(() => buildSlashCommands(skills), [skills]); const slashToken = getCurrentSlashToken(buffer); - const slashMenu = React.useMemo( - () => - showSkillsDropdown || modelDropdownStep ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : [], - [showSkillsDropdown, modelDropdownStep, slashToken, slashItems] - ); + const slashMenu = showSkillsDropdown ? [] : slashToken ? filterSlashCommands(slashItems, slashToken) : []; const showMenu = slashMenu.length > 0; const promptHistoryKey = React.useMemo(() => promptHistory.join("\0"), [promptHistory]); const footerText = statusMessage @@ -138,7 +114,7 @@ export const PromptInput = React.memo(function PromptInput({ ? loadingText && loadingText.trim() ? loadingText : "esc to interrupt · ctrl+c to cancel input" - : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; + : ""; useTerminalFocusReporting(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); @@ -158,17 +134,6 @@ export const PromptInput = React.memo(function PromptInput({ } }, [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; @@ -182,311 +147,272 @@ export const PromptInput = React.memo(function PromptInput({ setDraftBeforeHistory(null); }, [promptHistoryKey]); - useTerminalInput( - (input, key) => { - if (key.focusIn) { - setHasTerminalFocus(true); - return; - } - if (key.focusOut) { - setHasTerminalFocus(false); - return; - } + useTerminalInput((input, key) => { + if (key.focusIn) { + setHasTerminalFocus(true); + return; + } + if (key.focusOut) { + setHasTerminalFocus(false); + return; + } - if (disabled) { - return; - } + if (disabled) { + return; + } - if (key.escape) { - if (modelDropdownStep) { - closeModelDropdown(); - return; - } - if (showSkillsDropdown) { - setShowSkillsDropdown(false); - return; - } - if (busy) { - onInterrupt(); - setStatusMessage("Interrupting…"); - } + if (key.escape) { + if (showSkillsDropdown) { + setShowSkillsDropdown(false); return; } + if (busy) { + onInterrupt(); + setStatusMessage("Interrupting…"); + } + return; + } - if (key.ctrl && (input === "d" || input === "D")) { - if (!isEmpty(buffer)) { - updateBuffer((s) => deleteForward(s)); - return; - } - const now = Date.now(); - if (pendingExit && now - lastCtrlDAt.current < 2000) { - exit(); - return; - } - lastCtrlDAt.current = now; - setPendingExit(true); - setStatusMessage("press ctrl+d again to exit"); + if (key.ctrl && (input === "d" || input === "D")) { + if (!isEmpty(buffer)) { + updateBuffer((s) => deleteForward(s)); return; } - - if (key.ctrl && (input === "c" || input === "C")) { - if (busy) { - onInterrupt(); - setStatusMessage("Interrupting…"); - } else if (!isEmpty(buffer)) { - setBuffer(EMPTY_BUFFER); - } else { - setStatusMessage("press ctrl+d to exit"); - } + const now = Date.now(); + if (pendingExit && now - lastCtrlDAt.current < 2000) { + exit(); return; } + lastCtrlDAt.current = now; + setPendingExit(true); + setStatusMessage("press ctrl+d again to exit"); + return; + } - if (pendingExit && (!key.ctrl || (input !== "d" && input !== "D"))) { - setPendingExit(false); + if (key.ctrl && (input === "c" || input === "C")) { + if (busy) { + onInterrupt(); + setStatusMessage("Interrupting…"); + } else if (!isEmpty(buffer)) { + setBuffer(EMPTY_BUFFER); + } else { + setStatusMessage("press ctrl+d to exit"); } + return; + } - if (historyCursor !== -1 && !key.upArrow && !key.downArrow) { - exitHistoryBrowsing(); - } + if (pendingExit && (!key.ctrl || (input !== "d" && input !== "D"))) { + setPendingExit(false); + } - 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 (showSkillsDropdown) { + if (skills.length === 0) { + setShowSkillsDropdown(false); + } else { if (key.upArrow) { - setModelDropdownIndex((idx) => (idx - 1 + optionCount) % optionCount); + setSkillsDropdownIndex((idx) => (idx - 1 + skills.length) % skills.length); return; } if (key.downArrow) { - setModelDropdownIndex((idx) => (idx + 1) % optionCount); + setSkillsDropdownIndex((idx) => (idx + 1) % skills.length); return; } if ((input === " " && !key.ctrl && !key.meta) || (key.return && !key.shift && !key.meta)) { - selectModelDropdownItem(); + const skill = skills[skillsDropdownIndex]; + if (skill) { + toggleSelectedSkill(skill); + } return; } if (key.tab) { - closeModelDropdown(); + setShowSkillsDropdown(false); return; } } + } - if (key.ctrl && (input === "v" || input === "V")) { - setStatusMessage("Reading clipboard..."); - readClipboardImageAsync() - .then((image) => { - if (image) { - setImageUrls((prev) => [...prev, image.dataUrl]); - setStatusMessage("Attached image from clipboard"); - } else { - setStatusMessage("No image found in clipboard"); - } - }) - .catch(() => { - setStatusMessage("Failed to read clipboard"); - }); - return; - } - - if (isClearImageAttachmentsShortcut(input, key)) { - if (imageUrls.length > 0) { - setImageUrls([]); - setStatusMessage("Cleared attached images"); + if (key.ctrl && (input === "v" || input === "V")) { + setStatusMessage("Reading clipboard..."); + readClipboardImageAsync().then((image) => { + if (image) { + setImageUrls((prev) => [...prev, image.dataUrl]); + setStatusMessage("Attached image from clipboard"); } else { - setStatusMessage("No attached images to clear"); + setStatusMessage("No image found in clipboard"); } - return; + }).catch(() => { + setStatusMessage("Failed to read clipboard"); + }); + return; + } + + if (isClearImageAttachmentsShortcut(input, key)) { + if (imageUrls.length > 0) { + setImageUrls([]); + setStatusMessage("Cleared attached images"); + } else { + setStatusMessage("No attached images to clear"); } + return; + } - const noModifier = !key.shift && !key.ctrl && !key.meta; - const isPlainReturn = key.return && !key.shift && !key.meta; + const noModifier = !key.shift && !key.ctrl && !key.meta; + const isPlainReturn = key.return && !key.shift && !key.meta; - if (showMenu) { - if (key.upArrow) { - setMenuIndex((idx) => (idx - 1 + slashMenu.length) % slashMenu.length); - return; - } - if (key.downArrow) { - setMenuIndex((idx) => (idx + 1) % slashMenu.length); - return; - } - if (key.tab || (key.return && !key.shift && !key.meta)) { - const selected = slashMenu[menuIndex]; - if (selected) { - handleSlashSelection(selected); - return; - } - } + if (showMenu) { + if (key.upArrow) { + setMenuIndex((idx) => (idx - 1 + slashMenu.length) % slashMenu.length); + return; } - - if (busy && isPlainReturn) { - setStatusMessage("wait for the current response or press esc to interrupt"); + if (key.downArrow) { + setMenuIndex((idx) => (idx + 1) % slashMenu.length); return; } - - if (key.return) { - const isShiftEnter = key.shift || key.meta; - if (isShiftEnter) { - updateBuffer((s) => insertText(s, "\n")); + if (key.tab || (key.return && !key.shift && !key.meta)) { + const selected = slashMenu[menuIndex]; + if (selected) { + handleSlashSelection(selected); return; } - submitCurrentBuffer(); - return; } + } - if (key.delete) { - updateBuffer((s) => deleteForward(s)); - return; - } + if (busy && isPlainReturn) { + setStatusMessage("wait for the current response or press esc to interrupt"); + return; + } - if (key.backspace) { - updateBuffer((s) => backspace(s)); + if (key.return) { + const isShiftEnter = key.shift || key.meta; + if (isShiftEnter) { + updateBuffer((s) => insertText(s, "\n")); return; } + submitCurrentBuffer(); + return; + } - if ((key.ctrl || key.meta) && key.leftArrow) { - updateBuffer((s) => moveWordLeft(s)); - return; - } + if (key.delete) { + updateBuffer((s) => deleteForward(s)); + return; + } - if ((key.ctrl || key.meta) && key.rightArrow) { - updateBuffer((s) => moveWordRight(s)); - return; - } + if (key.backspace) { + updateBuffer((s) => backspace(s)); + return; + } - if (key.leftArrow) { - updateBuffer((s) => moveLeft(s)); - return; - } + if ((key.ctrl || key.meta) && key.leftArrow) { + updateBuffer((s) => moveWordLeft(s)); + return; + } - if (key.rightArrow) { - updateBuffer((s) => moveRight(s)); - return; - } + if ((key.ctrl || key.meta) && key.rightArrow) { + updateBuffer((s) => moveWordRight(s)); + return; + } - if (key.home) { - updateBuffer((s) => moveLineStart(s)); - return; - } + if (key.leftArrow) { + updateBuffer((s) => moveLeft(s)); + return; + } - if (key.end) { - updateBuffer((s) => moveLineEnd(s)); - return; - } + if (key.rightArrow) { + updateBuffer((s) => moveRight(s)); + return; + } - if (key.upArrow) { - if (noModifier && (historyCursor !== -1 || buffer.cursor === 0) && promptHistory.length > 0) { - navigateHistory(-1); - return; - } - updateBuffer((s) => moveUp(s)); - return; - } + if (key.home) { + updateBuffer((s) => moveLineStart(s)); + return; + } - if (key.downArrow) { - if (noModifier && (historyCursor !== -1 || buffer.cursor === buffer.text.length)) { - navigateHistory(1); - return; - } - updateBuffer((s) => moveDown(s)); - return; - } + if (key.end) { + updateBuffer((s) => moveLineEnd(s)); + return; + } - if (key.ctrl && (input === "p" || input === "P")) { + if (key.upArrow) { + if (noModifier && (historyCursor !== -1 || buffer.cursor === 0) && promptHistory.length > 0) { navigateHistory(-1); return; } - if (key.ctrl && (input === "n" || input === "N")) { + updateBuffer((s) => moveUp(s)); + return; + } + + if (key.downArrow) { + if (noModifier && (historyCursor !== -1 || buffer.cursor === buffer.text.length)) { navigateHistory(1); return; } - if (key.ctrl && (input === "a" || input === "A")) { - updateBuffer((s) => moveLineStart(s)); - return; - } - if (key.ctrl && (input === "e" || input === "E")) { - updateBuffer((s) => moveLineEnd(s)); - return; - } - if (key.ctrl && (input === "b" || input === "B")) { - updateBuffer((s) => moveLeft(s)); - return; - } - if (key.ctrl && (input === "f" || input === "F")) { - updateBuffer((s) => moveRight(s)); - return; - } - if (key.meta && (input === "b" || input === "B")) { - updateBuffer((s) => moveWordLeft(s)); - return; - } - if (key.meta && (input === "f" || input === "F")) { - updateBuffer((s) => moveWordRight(s)); - return; - } - if (key.ctrl && (input === "k" || input === "K")) { - updateBuffer((s) => killLine(s)); - return; - } - if (key.ctrl && (input === "u" || input === "U")) { - updateBuffer(() => EMPTY_BUFFER); - return; - } - if (key.ctrl && (input === "w" || input === "W")) { - updateBuffer((s) => deleteWordBefore(s)); - return; - } - if (key.meta && (input === "d" || input === "D")) { - updateBuffer((s) => deleteWordAfter(s)); - return; - } - if (key.meta && (input === "\u007F" || input === "\b")) { - updateBuffer((s) => deleteWordBefore(s)); - return; - } - if (key.ctrl && (input === "j" || input === "J")) { - updateBuffer((s) => insertText(s, "\n")); - return; - } - if (input.startsWith("\u001B")) { - // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. - return; - } + updateBuffer((s) => moveDown(s)); + return; + } - if (input && !key.ctrl && !key.meta) { - // Normalize line endings from paste: \r\n (Windows) → \n, \r (old macOS/Enter) → \n. - // This preserves multi-line formatting when the user pastes content. - const sanitized = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - updateBuffer((s) => insertText(s, sanitized)); - } - }, - { isActive: !disabled } - ); + if (key.ctrl && (input === "p" || input === "P")) { + navigateHistory(-1); + return; + } + if (key.ctrl && (input === "n" || input === "N")) { + navigateHistory(1); + return; + } + if (key.ctrl && (input === "a" || input === "A")) { + updateBuffer((s) => moveLineStart(s)); + return; + } + if (key.ctrl && (input === "e" || input === "E")) { + updateBuffer((s) => moveLineEnd(s)); + return; + } + if (key.ctrl && (input === "b" || input === "B")) { + updateBuffer((s) => moveLeft(s)); + return; + } + if (key.ctrl && (input === "f" || input === "F")) { + updateBuffer((s) => moveRight(s)); + return; + } + if (key.meta && (input === "b" || input === "B")) { + updateBuffer((s) => moveWordLeft(s)); + return; + } + if (key.meta && (input === "f" || input === "F")) { + updateBuffer((s) => moveWordRight(s)); + return; + } + if (key.ctrl && (input === "k" || input === "K")) { + updateBuffer((s) => killLine(s)); + return; + } + if (key.ctrl && (input === "u" || input === "U")) { + updateBuffer(() => EMPTY_BUFFER); + return; + } + if (key.ctrl && (input === "w" || input === "W")) { + updateBuffer((s) => deleteWordBefore(s)); + return; + } + if (key.ctrl && (input === "j" || input === "J")) { + updateBuffer((s) => insertText(s, "\n")); + return; + } + + if (input.startsWith("\u001B")) { + // Unhandled escape sequence (e.g. function keys); ignore to avoid inserting garbage. + return; + } + + if (input && !key.ctrl && !key.meta) { + const sanitized = input.replace(/\r/g, ""); + updateBuffer((s) => insertText(s, sanitized)); + } + }, { isActive: !disabled }); function exitHistoryBrowsing(): void { setHistoryCursor(-1); @@ -541,11 +467,6 @@ export const PromptInput = React.memo(function PromptInput({ setShowSkillsDropdown(true); return; } - if (item.kind === "model") { - clearSlashToken(); - openModelDropdown(); - return; - } if (item.kind === "new") { onSubmit({ text: "", imageUrls: [], command: "new" }); setBuffer(EMPTY_BUFFER); @@ -555,7 +476,7 @@ export const PromptInput = React.memo(function PromptInput({ return; } if (item.kind === "init") { - onSubmit(buildInitPromptSubmission(selectedSkills)); + onSubmit({ text: "/init", imageUrls: [] }); setBuffer(EMPTY_BUFFER); setImageUrls([]); setSelectedSkills([]); @@ -599,7 +520,7 @@ export const PromptInput = React.memo(function PromptInput({ onSubmit({ text: buffer.text, imageUrls, - selectedSkills, + selectedSkills }); setBuffer(EMPTY_BUFFER); setImageUrls([]); @@ -620,61 +541,11 @@ export const PromptInput = React.memo(function PromptInput({ setBuffer((state) => removeCurrentSlashToken(state)); } - 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 visibleSkillStart = Math.min(Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8)); + const visibleSkillStart = Math.min( + Math.max(0, skillsDropdownIndex - 7), + Math.max(0, skills.length - 8) + ); const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); - 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", - })); return ( @@ -686,29 +557,23 @@ export const PromptInput = React.memo(function PromptInput({ ) : null} {selectedSkills.length > 0 ? ( - - {formatSelectedSkillsStatus(selectedSkills)} - + {formatSelectedSkillsStatus(selectedSkills)} (use /skills to edit) ) : null} {/* Input */} - + {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} {showSkillsDropdown ? ( - - Select Skills - + Select Skills {skills.length === 0 ? ( No skills found ) : ( @@ -719,8 +584,9 @@ export const PromptInput = React.memo(function PromptInput({ return ( {active ? "› " : " "} - {selected ? "●" : "○"} {skill.name} - {skill.isLoaded ? : null} + {selected ? "●" : "○"}{" "} + {skill.name} + {skill.isLoaded ? : null} {` ${skill.path}`} ); @@ -733,34 +599,10 @@ export const PromptInput = React.memo(function PromptInput({ space toggle · enter toggle · esc to close ) : null} - {modelDropdownStep ? ( - - - {modelDropdownStep === "model" ? "Select Model" : "Select Thinking Mode"} - - {modelDropdownItems.map((item, idx) => { - const active = idx === modelDropdownIndex; - return ( - - {active ? "› " : " "} - {item.selected ? "●" : "○"} {item.label} - {item.description ? {` ${item.description}`} : null} - - ); - })} - - {modelDropdownStep === "model" - ? "space/enter select model · esc to cancel" - : "space/enter apply · esc to cancel"} - - - ) : null} - {!showMenu && ( - - {footerText} - - )} + {!showMenu && + {footerText} + } ); }); @@ -794,27 +636,9 @@ export function addUniqueSkill(skills: SkillInfo[], skill: SkillInfo): SkillInfo } export function toggleSkillSelection(skills: SkillInfo[], skill: SkillInfo): SkillInfo[] { - return isSkillSelected(skills, skill) ? skills.filter((item) => item.name !== skill.name) : [...skills, skill]; -} - -export function buildInitPromptSubmission(selectedSkills: SkillInfo[]): PromptSubmission { - return { - text: "/init", - imageUrls: [], - selectedSkills: selectedSkills.length > 0 ? selectedSkills : undefined, - }; -} - -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; + return isSkillSelected(skills, skill) + ? skills.filter((item) => item.name !== skill.name) + : [...skills, skill]; } export function removeCurrentSlashToken(state: PromptBufferState): PromptBufferState { diff --git a/src/ui/SessionList.tsx b/src/ui/SessionList.tsx index 67f2e10..64ef77a 100644 --- a/src/ui/SessionList.tsx +++ b/src/ui/SessionList.tsx @@ -1,6 +1,6 @@ -import React, { useState, useMemo } from "react"; -import { Box, Text, useInput, useWindowSize } from "ink"; -import type { SessionEntry } from "../session"; +import React, {useState, useMemo} from "react"; +import {Box, Text, useInput, useWindowSize} from "ink"; +import type {SessionEntry} from "../session"; type Props = { sessions: SessionEntry[]; @@ -8,9 +8,9 @@ type Props = { onCancel: () => void; }; -export function SessionList({ sessions, onSelect, onCancel }: Props): React.ReactElement { +export function SessionList({sessions, onSelect, onCancel}: Props): React.ReactElement { const [index, setIndex] = useState(0); - const { columns, rows } = useWindowSize(); + const {columns, rows} = useWindowSize(); // Dynamically calculate the number of visible sessions based on terminal height const maxVisibleSessions = useMemo(() => { @@ -97,60 +97,42 @@ export function SessionList({ sessions, onSelect, onCancel }: Props): React.Reac paddingX={1} marginTop={1} > - + {/* Header row */} - - Resume a session - - - {" "} - ({sessions.length} total) - + Resume a session + ({sessions.length} total) {/* Session list */} - + {visibleSessions.map((session, i) => { const actualIndex = scrollOffset + i; return ( - {actualIndex === safeIndex ? "› " : " "} + + {actualIndex === safeIndex ? "› " : " "} + - - - + + + {formatSessionTitle(session.summary || "Untitled")} ({session.status}) - + {formatTimestamp(session.updateTime)} ); })} - {scrollOffset > 0 || scrollOffset + maxVisibleSessions < sessions.length ? ( + {(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} + {scrollOffset + maxVisibleSessions < sessions.length ? … {sessions.length - scrollOffset - maxVisibleSessions} older sessions below. : null} ) : null} diff --git a/src/ui/SlashCommandMenu.tsx b/src/ui/SlashCommandMenu.tsx index 9b79293..c47b8df 100644 --- a/src/ui/SlashCommandMenu.tsx +++ b/src/ui/SlashCommandMenu.tsx @@ -1,7 +1,6 @@ -import { formatSlashCommandDescription, formatSlashCommandLabel } from "./slashCommands"; -import type { SlashCommandItem } from "./slashCommands"; +import {formatSlashCommandDescription, formatSlashCommandLabel, SlashCommandItem} from "./slashCommands"; import React from "react"; -import { Box, Text } from "ink"; +import {Box, Text} from "ink"; type SlashCommandMenuProps = { items: SlashCommandItem[]; @@ -11,22 +10,11 @@ type SlashCommandMenuProps = { }; const SlashCommandMenu = React.memo(function SlashCommandMenu({ - items, - activeIndex, - maxVisible = 6, - width, -}: SlashCommandMenuProps): React.ReactElement | null { - // 计算标签列最佳宽度:包含前缀"› "或" "(2字符),不超过容器一半(扣除gap) - const labelColumnWidth = React.useMemo(() => { - if (items.length === 0) { - return 0; - } - const longestLabel = Math.max(...items.map((s) => s.label.length)); - const contentWidth = longestLabel + 2; // +2 for prefix "› " or " " - const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 - return Math.min(contentWidth, maxAllowed); - }, [items, width]); - + items, + activeIndex, + maxVisible = 6, + width + }: SlashCommandMenuProps): React.ReactElement | null { if (items.length === 0) { return null; } @@ -38,12 +26,18 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ ); const visibleItems = items.slice(visibleStart, visibleStart + maxVisible); + // 计算标签列最佳宽度:包含前缀"› "或" "(2字符),不超过容器一半(扣除gap) + const labelColumnWidth = React.useMemo(() => { + const longestLabel = Math.max(...items.map((s) => s.label.length)); + const contentWidth = longestLabel + 2; // +2 for prefix "› " or " " + const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 + return Math.min(contentWidth, maxAllowed); + }, [items, width]); + return ( {visibleStart > 0 ? ( - - - + ) : null} {visibleItems.map((item, idx) => { const actualIndex = visibleStart + idx; @@ -56,21 +50,19 @@ const SlashCommandMenu = React.memo(function SlashCommandMenu({ - - {formatSlashCommandDescription(item.description)} - + {formatSlashCommandDescription(item.description)} ); })} - - {visibleStart + visibleItems.length < items.length ? : null} - - ({activeIndex + 1}/{items.length}) ↑↓ to navigate · Enter to select - + + {visibleStart + visibleItems.length < items.length ? ( + + ) : null} + ({activeIndex + 1}/{items.length}) ↑↓ to navigate · Enter to select ); }); -export default SlashCommandMenu; +export default SlashCommandMenu; \ No newline at end of file diff --git a/src/ui/ThemedGradient.tsx b/src/ui/ThemedGradient.tsx index f2c2369..333a531 100644 --- a/src/ui/ThemedGradient.tsx +++ b/src/ui/ThemedGradient.tsx @@ -1,9 +1,10 @@ -import type React from "react"; -import { Text, type TextProps } from "ink"; -import Gradient from "ink-gradient"; +import type React from 'react'; +import { Text, type TextProps } from 'ink'; +import Gradient from 'ink-gradient'; + export const ThemedGradient: React.FC = ({ children, ...props }) => { - const gradient = ["#229ac3e6", "#229ac3e6"]; // Use solid color for now + const gradient = ['#229ac3e6', '#229ac3e6']; // Use solid color for now if (gradient && gradient.length >= 2) { return ( @@ -23,8 +24,8 @@ export const ThemedGradient: React.FC = ({ children, ...props }) => { // Fallback to accent color if no gradient return ( - + {children} ); -}; +}; \ No newline at end of file diff --git a/src/ui/UpdatePrompt.tsx b/src/ui/UpdatePrompt.tsx deleted file mode 100644 index f2b9e21..0000000 --- a/src/ui/UpdatePrompt.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { useState } from "react"; -import { Box, Text, useApp, useInput } from "ink"; - -export type UpdatePromptChoice = "install" | "ignore-once" | "ignore-version"; - -type UpdatePromptOption = { - value: UpdatePromptChoice; - label: string; -}; - -type Props = { - currentVersion: string; - latestVersion: string; - installCommand: string; - onSelect: (choice: UpdatePromptChoice) => void; -}; - -export function UpdatePrompt({ currentVersion, latestVersion, installCommand, onSelect }: Props): React.ReactElement { - const { exit } = useApp(); - const [selectedIndex, setSelectedIndex] = useState(0); - const options: UpdatePromptOption[] = [ - { - value: "install", - label: `Install the latest version with \`${installCommand}\``, - }, - { - value: "ignore-once", - label: "Ignore once", - }, - { - value: "ignore-version", - label: `Ignore this version (${latestVersion})`, - }, - ]; - - useInput((input, key) => { - if (key.upArrow) { - setSelectedIndex((index) => (index - 1 + options.length) % options.length); - return; - } - if (key.downArrow || key.tab) { - setSelectedIndex((index) => (index + 1) % options.length); - return; - } - if (key.return) { - onSelect(options[selectedIndex]?.value ?? "ignore-once"); - exit(); - return; - } - if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { - onSelect("ignore-once"); - exit(); - return; - } - if (/^[1-3]$/.test(input)) { - onSelect(options[Number(input) - 1]?.value ?? "ignore-once"); - exit(); - } - }); - - return ( - - - Deep Code latest version has been released: {currentVersion} -> {latestVersion} - - - {options.map((option, index) => { - const selected = index === selectedIndex; - return ( - - {selected ? "> " : " "} - {index + 1}. {option.label} - - ); - })} - - - Use Up/Down to choose, Enter to confirm, Esc to ignore once. - - - ); -} diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 5e25379..1585bfc 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -1,12 +1,10 @@ -import React, { useMemo, useState } from "react"; -import { Box, Text } from "ink"; +import React 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 { ThemedGradient } from "./ThemedGradient"; -import { AsciiLogo } from "../AsciiArt"; +import path from 'node:path'; +import type {SkillInfo} from "../session"; +import type {ResolvedDeepcodingSettings} from "../settings"; +import {buildSlashCommands, BUILTIN_SLASH_COMMANDS, formatSlashCommandDescription} from "./slashCommands"; type WelcomeScreenProps = { projectRoot: string; @@ -16,102 +14,44 @@ type WelcomeScreenProps = { width: number; }; -const TITLE_PANEL_WIDTH = 70; -const PANEL_CONTENT_HEIGHT = 8; - 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: "Esc", description: "Interrupt the current model turn" }, { label: "/", description: "Open the skills and commands menu" }, - { label: "Ctrl+D twice", description: "Quit Deep Code CLI" }, + { label: "Ctrl+D twice", description: "Quit Deep Code CLI" } ]; export function WelcomeScreen({ - projectRoot, - settings, - skills, version, - width, }: WelcomeScreenProps): React.ReactElement { - const tips = useMemo(() => buildWelcomeTips(skills), [skills]); - const [tipIndex] = useState(() => randomTipIndex(tips.length)); - const compact = width < TITLE_PANEL_WIDTH + 42; - const cwd = formatHomeRelativePath(projectRoot); - const tip = tips[Math.min(tipIndex, Math.max(0, tips.length - 1))] ?? tips[0]; - const panelWidth = compact ? undefined : Math.min(width, 72); - - return ( - - - - - - {AsciiLogo} - - - - - - {">"}_ Deep Code - (v{version || "unknown"}) - - {!compact ? : null} - - - - - - - - - - {tip ? ( - - - Tips: {tip.label} - {tip.description} - - - ) : null} - - - ); -} - -function SettingRow({ label, value }: { label: string; value: string }): React.ReactElement { return ( - - - {label} - - - {value} + + + SIMEON's dev CLI + (v{version || "unknown"}) + + developed and maintained by SIMO + ); } export function formatHomeRelativePath(value: string, home = os.homedir()): string { - const normalizedValue = path.resolve(value); - const normalizedHome = path.resolve(home); - const relative = path.relative(normalizedHome, normalizedValue); + const pathApi = value.startsWith("/") && home.startsWith("/") ? path.posix : path; + const normalizedValue = pathApi.resolve(value); + const normalizedHome = pathApi.resolve(home); + const relative = pathApi.relative(normalizedHome, normalizedValue); if (relative === "") { return "~"; } - if (!relative.startsWith("..") && !path.isAbsolute(relative)) { - return `~${path.sep}${relative}`; + if (!relative.startsWith("..") && !pathApi.isAbsolute(relative)) { + return `~/${relative.replace(/\\/g, "/")}`; } - return normalizedValue; + return normalizedValue.replace(/\\/g, "/"); } export function buildWelcomeTips(skills: SkillInfo[]): Array<{ label: string; description: string }> { @@ -119,15 +59,11 @@ export function buildWelcomeTips(skills: SkillInfo[]): Array<{ label: string; de .filter((item) => item.kind !== "skill" || item.skill?.isLoaded) .map((item) => ({ label: item.label, - description: formatSlashCommandDescription(item.description), + description: formatSlashCommandDescription(item.description) })); return [ ...slashTips, - ...SHORTCUT_TIPS.filter((tip) => !BUILTIN_SLASH_COMMANDS.some((command) => command.label === tip.label)), + ...SHORTCUT_TIPS.filter((tip) => !BUILTIN_SLASH_COMMANDS.some((command) => command.label === tip.label)) ]; } - -function randomTipIndex(length: number): number { - return length > 0 ? Math.floor(Math.random() * length) : 0; -} diff --git a/src/ui/askUserQuestion.ts b/src/ui/askUserQuestion.ts index 8d168d8..f3e7e6c 100644 --- a/src/ui/askUserQuestion.ts +++ b/src/ui/askUserQuestion.ts @@ -39,7 +39,7 @@ export function findPendingAskUserQuestion( return { messageId: message.id, sessionId: message.sessionId, - questions, + questions }; } @@ -87,10 +87,9 @@ function normalizeQuestions(raw: unknown): AskUserQuestionItem[] { if (!item || typeof item !== "object" || Array.isArray(item)) { continue; } - const question = - typeof (item as { question?: unknown }).question === "string" - ? (item as { question: string }).question.trim() - : ""; + const question = typeof (item as { question?: unknown }).question === "string" + ? (item as { question: string }).question.trim() + : ""; const rawOptions = (item as { options?: unknown }).options; if (!question || !Array.isArray(rawOptions) || rawOptions.length === 0) { continue; @@ -101,10 +100,9 @@ function normalizeQuestions(raw: unknown): AskUserQuestionItem[] { if (options.length === 0) { continue; } - const multiSelect = - typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" - ? (item as { multiSelect: boolean }).multiSelect - : undefined; + const multiSelect = typeof (item as { multiSelect?: unknown }).multiSelect === "boolean" + ? (item as { multiSelect: boolean }).multiSelect + : undefined; questions.push({ question, multiSelect, options }); } return questions; @@ -114,20 +112,21 @@ function normalizeOption(raw: unknown): AskUserQuestionOption | null { if (!raw || typeof raw !== "object" || Array.isArray(raw)) { return null; } - const label = typeof (raw as { label?: unknown }).label === "string" ? (raw as { label: string }).label.trim() : ""; + const label = typeof (raw as { label?: unknown }).label === "string" + ? (raw as { label: string }).label.trim() + : ""; if (!label) { return null; } - const description = - typeof (raw as { description?: unknown }).description === "string" - ? (raw as { description: string }).description.trim() - : ""; + const description = typeof (raw as { description?: unknown }).description === "string" + ? (raw as { description: string }).description.trim() + : ""; return { label, - description: description || undefined, + description: description || undefined }; } function escapeAnswerPart(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\s+/g, " ").trim(); + return value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\s+/g, " ").trim(); } diff --git a/src/ui/clipboard.ts b/src/ui/clipboard.ts index c9e30e9..e66b9b4 100644 --- a/src/ui/clipboard.ts +++ b/src/ui/clipboard.ts @@ -14,7 +14,7 @@ const IMAGE_MIME_BY_EXT = new Map([ [".jpg", "image/jpeg"], [".jpeg", "image/jpeg"], [".gif", "image/gif"], - [".webp", "image/webp"], + [".webp", "image/webp"] ]); function bufferToDataUrl(buffer: Buffer, mimeType: string): string { @@ -83,7 +83,7 @@ function readMacClipboardImage(): ClipboardImage | null { "-e", "write png_data to fp", "-e", - "close access fp", + "close access fp" ]); if (saved) { diff --git a/src/ui/exitSummary.ts b/src/ui/exitSummary.ts index 910cceb..3e13d5f 100644 --- a/src/ui/exitSummary.ts +++ b/src/ui/exitSummary.ts @@ -45,8 +45,12 @@ function extractUsageFields(usage: unknown | null): UsageFields { } const record = usage as Record; - const promptTokens = typeof record.prompt_tokens === "number" ? record.prompt_tokens : 0; - const completionTokens = typeof record.completion_tokens === "number" ? record.completion_tokens : 0; + const promptTokens = + typeof record.prompt_tokens === "number" ? record.prompt_tokens : 0; + const completionTokens = + typeof record.completion_tokens === "number" + ? record.completion_tokens + : 0; let cachedTokens = 0; const promptDetails = record.prompt_tokens_details; if (promptDetails && typeof promptDetails === "object" && !Array.isArray(promptDetails)) { @@ -75,11 +79,16 @@ export function buildExitSummaryText(input: ExitSummaryInput): string { const borderColor = chalk.hex("#229ac3e6"); const titleColor = gradientString("#229ac3e6", "rgb(125 51 247 / 0.7)"); - const line = (text: string) => `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; + const line = (text: string) => + `${borderColor("│")} ${padRight(text, contentWidth)} ${borderColor("│")}`; const header = chalk.bold(titleColor("Goodbye!")); - const rows: string[] = ["", `${header}`, ""]; + const rows: string[] = [ + "", + `${header}`, + "", + ]; const usage = extractUsageFields(session?.usage ?? null); const modelName = model ?? "unknown"; diff --git a/src/ui/index.ts b/src/ui/index.ts index a74e330..893a492 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,11 +1,4 @@ -export { - App, - readSettings, - writeSettings, - writeModelConfigSelection, - resolveCurrentSettings, - createOpenAIClient, -} from "./App"; +export { App, readSettings, resolveCurrentSettings, createOpenAIClient } from "./App"; export { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; export { MessageView, parseDiffPreview } from "./MessageView"; export { @@ -19,19 +12,14 @@ export { removeCurrentSlashToken, isClearImageAttachmentsShortcut, renderBufferWithCursor, - buildInitPromptSubmission, - getThinkingOptionIndex, - MODEL_COMMAND_MODELS, - MODEL_COMMAND_THINKING_OPTIONS, useTerminalInput, parseTerminalInput, type PromptSubmission, - type InputKey, + type InputKey } from "./PromptInput"; export { 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, @@ -40,7 +28,7 @@ export { type AskUserQuestionOption, type AskUserQuestionItem, type PendingAskUserQuestion, - type AskUserQuestionAnswers, + type AskUserQuestionAnswers } from "./askUserQuestion"; export { readClipboardImage, type ClipboardImage } from "./clipboard"; export { buildLoadingText, type LoadingTextInput } from "./loadingText"; @@ -60,11 +48,10 @@ export { moveLineEnd, killLine, deleteWordBefore, - deleteWordAfter, reset, isEmpty, getCurrentSlashToken, - type PromptBufferState, + type PromptBufferState } from "./promptBuffer"; export { BUILTIN_SLASH_COMMANDS, @@ -74,7 +61,7 @@ export { formatSlashCommandDescription, formatSlashCommandLabel, type SlashCommandKind, - type SlashCommandItem, + type SlashCommandItem } from "./slashCommands"; export { findExpandedThinkingId } from "./thinkingState"; export { buildExitSummaryText } from "./exitSummary"; diff --git a/src/ui/markdown.ts b/src/ui/markdown.ts index 11fb0ea..aa77423 100644 --- a/src/ui/markdown.ts +++ b/src/ui/markdown.ts @@ -17,7 +17,9 @@ export function renderMarkdown(text: string): string { .join(""); } -type FenceSegment = { kind: "text"; body: string } | { kind: "code"; lang: string; body: string }; +type FenceSegment = + | { kind: "text"; body: string } + | { kind: "code"; lang: string; body: string }; function splitByFences(text: string): FenceSegment[] { const segments: FenceSegment[] = []; diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 8ccdc60..19f5cb9 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -50,19 +50,16 @@ export function getPromptCursorPlacement( 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 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, + rowsUp: (promptRows - 1 - cursorPosition.row) + footerRows + 1, + column: cursorPosition.column }; } @@ -211,7 +208,7 @@ export function usePromptTerminalCursor( directWrite("\r" + cursorDown(activePlacement.rowsUp) + hideCursor()); activePlacementRef.current = null; }; - }, [isActive, placement, stdout]); + }, [isActive, placement.column, placement.rowsUp, stdout]); } export function useHiddenTerminalCursor(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { @@ -238,4 +235,4 @@ export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined stdout.write(disableTerminalFocusReporting()); }; }, [isActive, stdout]); -} +} \ No newline at end of file diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index 857b8ac..1542ef8 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -1,9 +1,4 @@ export { useTerminalInput, parseTerminalInput } from "./useTerminalInput"; export type { InputKey } from "./useTerminalInput"; -export { - useHiddenTerminalCursor, - usePromptTerminalCursor, - useTerminalFocusReporting, - getPromptCursorPlacement, -} from "./cursor"; +export { useHiddenTerminalCursor, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement } from "./cursor"; \ No newline at end of file diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index a0f454f..8f7942e 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -56,7 +56,7 @@ export function parseTerminalInput(data: Buffer | string): { input: string; key: delete: FORWARD_DELETE_SEQUENCES.has(raw), 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, + focusOut: raw === TERMINAL_FOCUS_OUT }; if (input <= "\u001A" && !key.return) { @@ -136,4 +136,4 @@ export function useTerminalInput( stdin?.off("data", handleData); }; }, [isActive, stdin]); -} +} \ No newline at end of file diff --git a/src/ui/promptBuffer.ts b/src/ui/promptBuffer.ts index 3e3c182..f45d422 100644 --- a/src/ui/promptBuffer.ts +++ b/src/ui/promptBuffer.ts @@ -123,25 +123,7 @@ export function deleteWordBefore(state: PromptBufferState): PromptBufferState { } return { text: state.text.slice(0, start) + state.text.slice(end), - cursor: start, - }; -} - -export function deleteWordAfter(state: PromptBufferState): PromptBufferState { - const start = state.cursor; - let end = start; - while (end < state.text.length && /\s/.test(state.text[end] ?? "")) { - end++; - } - while (end < state.text.length && !/\s/.test(state.text[end] ?? "")) { - end++; - } - if (start === end) { - return state; - } - return { - text: state.text.slice(0, start) + state.text.slice(end), - cursor: start, + cursor: start }; } @@ -187,6 +169,6 @@ function locate(state: PromptBufferState): { line: lineNumber, column: state.cursor - lineStart, lineStart, - lineEnd, + lineEnd }; } diff --git a/src/ui/slashCommands.ts b/src/ui/slashCommands.ts index c772124..ca330ef 100644 --- a/src/ui/slashCommands.ts +++ b/src/ui/slashCommands.ts @@ -1,6 +1,6 @@ import type { SkillInfo } from "../session"; -export type SlashCommandKind = "skill" | "skills" | "model" | "new" | "init" | "resume" | "exit"; +export type SlashCommandKind = "skill" | "skills" | "new" | "init" | "resume" | "exit"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -15,38 +15,32 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ kind: "skills", name: "skills", label: "/skills", - description: "List available skills", - }, - { - kind: "model", - name: "model", - label: "/model", - description: "Select model, thinking mode and thinking effort", + description: "List available skills" }, { kind: "new", name: "new", label: "/new", - description: "Start a fresh conversation", + description: "Start a fresh conversation" }, { kind: "init", name: "init", label: "/init", - description: "Initialize an AGENTS.md file with instructions for LLM", + description: "Initialize an AGENTS.md file with instructions for LLM" }, { kind: "resume", name: "resume", label: "/resume", - description: "Pick a previous conversation to continue", + description: "Pick a previous conversation to continue" }, { kind: "exit", name: "exit", label: "/exit", - description: "Quit Deep Code CLI", - }, + description: "Quit Deep Code CLI" + } ]; export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { @@ -55,12 +49,15 @@ export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { name: skill.name, label: `/${skill.name}`, description: skill.description || "(no description)", - skill, + skill })); return [...skillItems, ...BUILTIN_SLASH_COMMANDS]; } -export function filterSlashCommands(items: SlashCommandItem[], token: string): SlashCommandItem[] { +export function filterSlashCommands( + items: SlashCommandItem[], + token: string +): SlashCommandItem[] { if (!token.startsWith("/")) { return []; } @@ -71,7 +68,10 @@ export function filterSlashCommands(items: SlashCommandItem[], token: string): S return items.filter((item) => item.name.toLowerCase().includes(query)); } -export function findExactSlashCommand(items: SlashCommandItem[], token: string): SlashCommandItem | null { +export function findExactSlashCommand( + items: SlashCommandItem[], + token: string +): SlashCommandItem | null { if (!token.startsWith("/")) { return null; } diff --git a/src/updateCheck.ts b/src/updateCheck.ts deleted file mode 100644 index 626e529..0000000 --- a/src/updateCheck.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { spawn } from "child_process"; -import React from "react"; -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"; - -export type PackageInfo = { - name: string; - version: string; -}; - -type UpdateState = { - pending?: { - currentVersion: string; - latestVersion: string; - packageName: string; - checkedAt: string; - } | null; - ignoredVersions?: string[]; -}; - -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 async function promptForPendingUpdate(packageInfo: PackageInfo): Promise<{ installed: boolean }> { - const state = readUpdateState(); - const pending = state.pending; - if (!pending) { - return { installed: false }; - } - - if (compareVersions(packageInfo.version, pending.latestVersion) >= 0) { - writeUpdateState({ ...state, pending: null }); - return { installed: false }; - } - - if (state.ignoredVersions?.includes(pending.latestVersion)) { - writeUpdateState({ ...state, pending: null }); - return { installed: false }; - } - - const installSpec = `${pending.packageName}@${pending.latestVersion}`; - const installCommand = `npm install -g ${installSpec}`; - const choice = await promptUpdateChoice({ - currentVersion: packageInfo.version, - latestVersion: pending.latestVersion, - installCommand, - }); - - if (choice === "install") { - 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` - ); - } - return { installed: ok }; - } - - if (choice === "ignore-version") { - const ignoredVersions = Array.from(new Set([...(state.ignoredVersions ?? []), pending.latestVersion])); - writeUpdateState({ ...state, pending: null, ignoredVersions }); - return { installed: false }; - } - - writeUpdateState({ ...state, pending: null }); - return { installed: false }; -} - -export async function checkForNpmUpdate(packageInfo: PackageInfo): Promise { - if (!packageInfo.name || !packageInfo.version) { - return; - } - - try { - const latestVersion = await fetchLatestNpmVersion(packageInfo.name); - if (!latestVersion || compareVersions(latestVersion, packageInfo.version) <= 0) { - clearPendingUpdate(); - return; - } - - const state = readUpdateState(); - if (state.ignoredVersions?.includes(latestVersion)) { - clearPendingUpdate(state); - return; - } - - writeUpdateState({ - ...state, - pending: { - currentVersion: packageInfo.version, - latestVersion, - packageName: packageInfo.name, - checkedAt: new Date().toISOString(), - }, - }); - } catch { - // Update checks must never affect CLI startup or normal operation. - } -} - -export function compareVersions(a: string, b: string): number { - const left = parseVersion(a); - const right = parseVersion(b); - const width = Math.max(left.length, right.length); - for (let index = 0; index < width; index += 1) { - const leftPart = left[index] ?? 0; - const rightPart = right[index] ?? 0; - if (leftPart > rightPart) { - return 1; - } - if (leftPart < rightPart) { - return -1; - } - } - return 0; -} - -export function getUpdateStatePath(): string { - return path.join(os.homedir(), ".deepcode", UPDATE_STATE_FILE); -} - -async function promptUpdateChoice({ - currentVersion, - latestVersion, - installCommand, -}: { - currentVersion: string; - latestVersion: string; - installCommand: string; -}): Promise<"install" | "ignore-once" | "ignore-version"> { - return new Promise((resolve) => { - let selected = false; - let instance: Instance | null = null; - const handleSelect = (choice: UpdatePromptChoice): void => { - if (selected) { - return; - } - selected = true; - resolve(choice); - instance?.unmount(); - }; - - instance = render( - React.createElement(UpdatePrompt, { - currentVersion, - latestVersion, - installCommand, - onSelect: handleSelect, - }), - { exitOnCtrlC: false } - ); - }); -} - -async function runNpmInstallGlobal(installSpec: string): Promise { - return new Promise((resolve) => { - const child = spawn("npm", ["install", "-g", installSpec], { - stdio: "inherit", - shell: process.platform === "win32", - }); - child.on("error", (error) => { - process.stderr.write(`Failed to start npm install: ${error.message}\n`); - resolve(false); - }); - child.on("close", (code) => { - if (code === 0) { - resolve(true); - return; - } - process.stderr.write(`npm install exited with code ${code ?? "unknown"}.\n`); - resolve(false); - }); - }); -} - -async function fetchLatestNpmVersion(packageName: string): Promise { - // Try Tencent mirror first for faster access in mainland China. - const mirrorResult = await runNpmViewLatestVersion(packageName, TENCENT_MIRROR_REGISTRY, NPM_VIEW_TIMEOUT_MS); - if (mirrorResult.ok) { - return parseNpmViewVersion(mirrorResult.stdout); - } - - // Fall back to the official npm registry. - const result = await runNpmViewLatestVersion(packageName, undefined, NPM_VIEW_TIMEOUT_MS); - if (!result.ok) { - return null; - } - return parseNpmViewVersion(result.stdout); -} - -function runNpmViewLatestVersion( - packageName: string, - registry: string | undefined, - timeoutMs: number -): Promise<{ ok: true; stdout: string } | { ok: false }> { - return new Promise((resolve) => { - const args = ["view", packageName, "dist-tags.latest", "--json"]; - if (registry) { - args.push("--registry", registry); - } - const child = spawn("npm", args, { - stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", - }); - - let stdout = ""; - let settled = false; - const finish = (result: { ok: true; stdout: string } | { ok: false }): void => { - if (settled) { - return; - } - settled = true; - clearTimeout(timer); - resolve(result); - }; - - const timer = setTimeout(() => { - child.kill(); - finish({ ok: false }); - }, timeoutMs); - - child.stdout?.on("data", (chunk: string | Buffer) => { - if (stdout.length >= MAX_NPM_VIEW_OUTPUT_CHARS) { - return; - } - const text = typeof chunk === "string" ? chunk : chunk.toString("utf8"); - stdout += text.slice(0, MAX_NPM_VIEW_OUTPUT_CHARS - stdout.length); - }); - - child.on("error", () => finish({ ok: false })); - child.on("close", (code) => { - finish(code === 0 ? { ok: true, stdout } : { ok: false }); - }); - }); -} - -export function parseNpmViewVersion(output: string): string | null { - const trimmed = output.trim(); - if (!trimmed) { - return null; - } - try { - const parsed = JSON.parse(trimmed) as unknown; - return typeof parsed === "string" && parsed.trim() ? parsed.trim() : null; - } catch { - return trimmed.split(/\r?\n/)[0]?.trim() || null; - } -} - -function readUpdateState(): UpdateState { - const statePath = getUpdateStatePath(); - if (!fs.existsSync(statePath)) { - return {}; - } - try { - const parsed = JSON.parse(fs.readFileSync(statePath, "utf8")) as UpdateState; - return { - pending: parsed.pending ?? null, - ignoredVersions: Array.isArray(parsed.ignoredVersions) - ? parsed.ignoredVersions.filter( - (value): value is string => typeof value === "string" && value.trim().length > 0 - ) - : [], - }; - } catch { - return {}; - } -} - -function writeUpdateState(state: UpdateState): void { - const statePath = getUpdateStatePath(); - fs.mkdirSync(path.dirname(statePath), { recursive: true }); - fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); -} - -function clearPendingUpdate(state = readUpdateState()): void { - if (!state.pending) { - return; - } - writeUpdateState({ ...state, pending: null }); -} - -function parseVersion(value: string): number[] { - return value - .split("-", 1)[0] - .split(".") - .map((part) => Number.parseInt(part, 10)) - .map((part) => (Number.isFinite(part) ? part : 0)); -} diff --git a/test_value.txt b/test_value.txt new file mode 100644 index 0000000..e204ade --- /dev/null +++ b/test_value.txt @@ -0,0 +1 @@ +ur the most annoying person i've ever met <3 \ No newline at end of file