From f6287729906c7dd72a8c952e29edd88d973e40e1 Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Tue, 12 May 2026 22:49:15 +0300 Subject: [PATCH 01/10] protection layer 1 --- .deepcode/AGENTS.md | 101 - .husky/pre-commit | 1 - .lintstagedrc | 12 - .prettierignore | 5 - .prettierrc | 8 - README.md | 154 +- eslint.config.mjs | 54 - package-lock.json | 2596 ++---------------------- package.json | 23 +- src/AsciiArt.ts | 8 - src/cli.tsx | 16 +- src/debug-logger.ts | 30 +- src/error-logger.ts | 77 +- src/notify.ts | 9 +- src/openai-thinking.ts | 48 +- src/privacy-guard.ts | 197 ++ src/prompt.ts | 163 +- src/session.ts | 528 +++-- src/settings.ts | 65 +- src/tests/askUserQuestion.test.ts | 120 +- src/tests/clipboard.test.ts | 25 +- src/tests/debug-logger.test.ts | 46 +- src/tests/exitSummary.test.ts | 27 +- src/tests/loadingText.test.ts | 33 +- src/tests/markdown.test.ts | 1 + src/tests/messageView.test.ts | 21 +- src/tests/openai-thinking.test.ts | 78 +- src/tests/privacy-guard.test.ts | 126 ++ src/tests/prompt.test.ts | 6 +- src/tests/promptBuffer.test.ts | 9 +- src/tests/promptInputKeys.test.ts | 30 +- src/tests/session.test.ts | 402 ++-- src/tests/settings-and-notify.test.ts | 141 +- src/tests/shell-utils.test.ts | 12 +- src/tests/slashCommands.test.ts | 15 +- src/tests/thinkingState.test.ts | 16 +- src/tests/tool-handlers.test.ts | 130 +- src/tests/updateCheck.test.ts | 2 +- src/tests/web-search-handler.test.ts | 56 +- src/tests/welcomeScreen.test.ts | 2 +- src/tools/ask-user-question-handler.ts | 54 +- src/tools/bash-handler.ts | 26 +- src/tools/edit-handler.ts | 132 +- src/tools/executor.ts | 54 +- src/tools/file-utils.ts | 15 +- src/tools/read-handler.ts | 201 +- src/tools/runtime.ts | 41 +- src/tools/shell-utils.ts | 28 +- src/tools/state.ts | 16 +- src/tools/web-search-handler.ts | 64 +- src/tools/write-handler.ts | 59 +- src/ui/App.tsx | 227 +-- src/ui/AskUserQuestionPrompt.tsx | 67 +- src/ui/MessageView.tsx | 53 +- src/ui/PromptInput.tsx | 678 +++---- src/ui/SessionList.tsx | 56 +- src/ui/SlashCommandMenu.tsx | 54 +- src/ui/ThemedGradient.tsx | 13 +- src/ui/UpdatePrompt.tsx | 15 +- src/ui/WelcomeScreen.tsx | 110 +- src/ui/askUserQuestion.ts | 31 +- src/ui/clipboard.ts | 4 +- src/ui/exitSummary.ts | 17 +- src/ui/index.ts | 22 +- src/ui/markdown.ts | 4 +- src/ui/prompt/cursor.ts | 15 +- src/ui/prompt/index.ts | 7 +- src/ui/prompt/useTerminalInput.ts | 4 +- src/ui/promptBuffer.ts | 22 +- src/ui/slashCommands.ts | 32 +- src/updateCheck.ts | 34 +- 71 files changed, 2703 insertions(+), 4845 deletions(-) delete mode 100644 .deepcode/AGENTS.md delete mode 100644 .husky/pre-commit delete mode 100644 .lintstagedrc delete mode 100644 .prettierignore delete mode 100644 .prettierrc delete mode 100644 eslint.config.mjs delete mode 100644 src/AsciiArt.ts create mode 100644 src/privacy-guard.ts create mode 100644 src/tests/privacy-guard.test.ts 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/.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..4eb9866 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,105 @@ # Deep Code CLI -[Deep Code](https://github.com/lessweb/deepcode-cli) 是专为 `deepseek-v4` 模型优化的终端 AI 编码助手,支持深度思考、推理强度控制以及 Agent Skills。 +The system prompt in this codebase is heavily modified, please rewrite before use! -## 安装 +Deep Code CLI is a heavily modified terminal AI coding agent for running DeepSeek and other OpenAI-compatible models through a stricter privacy layer. +This fork is focused on company-code usage: reducing accidental leaks, sanitizing the final HTTP payload before it reaches a provider, and keeping provider routing explicit. -```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) 共享,无需重复配置。 +## Security Model -## 主要功能 +This CLI is designed to make accidental leakage harder, not impossible +It protects the model request pipeline by scanning and sanitizing the JSON body that is actually sent upstream. That includes user messages, system messages, assistant reasoning fields, tool messages, and nested request fields +It does not replace normal company security controls. You should still avoid pasting real production secrets, use provider ZDR when available, keep fallback routing disabled, and review final-boundary logs during hardening -### **Skills** -Deep Code CLI 支持 agent skills,允许您扩展助手的能力: +## Privacy Controls -- **User-level Skills**:从 `~/.agents/skills/` 目录中发现并激活 skills。 -- **Project-level Skills**:从 `./.agents/skills/` 目录中加载项目专属 skills,并兼容旧的 `./.deepcode/skills/` 目录。 +### Final Request Sanitization -### **为 DeepSeek 优化** -- 专门为 DeepSeek 模型性能调优。 -- 通过使用[上下文缓存](https://api-docs.deepseek.com/guides/kv_cache)来降低成本。 -- 原生支持[思考模式](https://api-docs.deepseek.com/guides/thinking_mode)和思考强度控制。 +Before any request is sent to the model provider, the CLI builds a sanitized outbound request body. -## 快捷键 +The sanitizer redacts: -| 键 | 操作 | -|-----------------|-----------------------------------| -| `Enter` | 发送消息 | -| `Shift+Enter` | 插入换行(也可用 `Ctrl+J`) | -| `Ctrl+V` | 从剪贴板粘贴图片 | -| `Esc` | 中断当前模型回复 | -| `/` | 打开 skills / 命令菜单 | -| `/new` | 开始新对话 | -| `/resume` | 选择历史对话继续 | -| `/skills` | 列出可用 skills | -| `/exit` | 退出 | -| 连续 `Ctrl+D` | 退出 | +- Password-like phrases. +- JWTs. +- PEM private keys. +- Known API key formats. +- GitHub tokens. +- AWS access keys. +- Unknown high-entropy secret-looking tokens. +- Absolute local paths in model-replayed content. -## 支持的模型 +High-risk secrets are blocked before send. Generic test credentials are redacted instead of failing the session. -- `deepseek-v4-pro`(推荐使用) -- `deepseek-v4-flash` -- 任何其他 OpenAI 兼容模型 +### Tool Output Protection +Tool results are scanned before they become `tool` messages. If a tool output contains high-risk secret material, the CLI blocks automatic continuation and inserts a local warning instead of sending the raw result to the model. -## 常见问题 +### Sensitive File Reads -### Deep Code 是否有 VSCode 插件? +Obvious secret-bearing files are refused by default, including `.env`, `.npmrc`, `.pypirc`, private keys, certificate/key stores, kube configs, Docker configs, cloud credentials, and service-account JSON files. -有的。Deep Code 提供功能完整的 VSCode 插件,可在 [VSCode Marketplace](https://marketplace.visualstudio.com/items?itemName=vegamo.deepcode-vscode) 安装。插件与 CLI 共享 `~/.deepcode/settings.json` 配置文件,可以在终端和编辑器之间无缝切换。 +## Configuration -### Deep Code 是否支持理解图片? +Create or edit: -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? +```text +~/.deepcode/settings.json +``` -支持。只要把 `~/.deepcode/settings.json` 的 `env.BASE_URL` 配置为 OpenAI 兼容的接口地址就行。以火山方舟的 Coding Plan 为例: +Example OpenRouter + DeepSeek configuration: ```json { "env": { - "MODEL": "ark-code-latest", - "BASE_URL": "https://ark.cn-beijing.volces.com/api/coding/v3", - "API_KEY": "**************" + "MODEL": "deepseek/deepseek-v4-pro", + "BASE_URL": "https://openrouter.ai/api/v1", + "API_KEY": "sk-or-...", + "PROVIDER": "siliconflow", + "ZDR": "true" }, - "thinkingEnabled": true + "debugLogEnabled": true, + "thinkingEnabled": true, + "reasoningEffort": "max" } ``` -## 获取帮助 +To explicitly allow sensitive reads: + +```powershell +$env:DEEPCODE_ALLOW_SENSITIVE_READS="true" +node dist/cli.js +``` + +Logs are written to: + +```text +%USERPROFILE%\.deepcode\logs\final-http-body.jsonl +``` + +## Keyboard Shortcuts -- 在 GitHub Issues 上报告错误或请求功能 (https://github.com/lessweb/deepcode-cli/issues) +| Key | Action | +| --- | --- | +| `Enter` | Send message | +| `Shift+Enter` | Insert newline | +| `Ctrl+V` | Paste image from clipboard where supported | +| `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 | -## 协议 -- MIT +## Important Notes -## 支持我们 +- ZDR helps with provider retention, but it does not replace local redaction and blocking. +- Provider fallback should stay disabled for company-code use. +- Secret detection is regex and entropy based; it is strong but not perfect. +- The model can still receive sanitized proprietary code and context. +- Review boundary logs while hardening, then disable them. -如果你觉得这个工具对你有帮助,请考虑通过以下方式支持我们: +## License -- 在 GitHub 上给我们一个 Star (https://github.com/lessweb/deepcode-cli) -- 向我们提交反馈和建议 -- 分享给你的朋友和同事 +MIT 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..93c2204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "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": { "chalk": "^5.6.2", @@ -24,20 +24,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 +47,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 +489,154 @@ "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/@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": { - "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" + "@types/tinycolor2": "*" } }, - "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==", + "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", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "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==", - "dev": true, - "license": "Apache-2.0", "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 +651,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 +660,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 +667,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 +675,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 +743,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 +752,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 +765,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 +777,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 +792,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 +817,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 +827,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 +944,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 +959,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 +974,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 +987,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 +1041,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 +1050,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 +1074,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 +1100,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 +1119,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 +1159,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 +1199,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 +1229,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 +1239,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 +1743,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 +1772,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 +1779,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 +1794,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 +1832,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 +1846,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..ff1ab01 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.19", + "version": "0.1.20", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", @@ -26,16 +26,10 @@ "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)\"", + "build": "npm run typecheck && 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" + "prepack": "npm run build" }, "dependencies": { "chalk": "^5.6.2", @@ -50,19 +44,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/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..88f401d 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -42,7 +42,7 @@ 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); @@ -52,7 +52,10 @@ 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); } @@ -65,7 +68,11 @@ async function main(): Promise { function startApp(): void { const inkInstance = render( - restartRef.current?.()} />, + restartRef.current?.()} + />, { exitOnCtrlC: false } ); @@ -102,10 +109,11 @@ function configureWindowsShell(): void { function readPackageInfo(): PackageInfo { try { + // eslint-disable-next-line @typescript-eslint/no-var-requires 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 : "", + version: typeof pkg.version === "string" ? pkg.version : "" }; } catch { return { name: "@vegamo/deepcode-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..42b391b 100644 --- a/src/error-logger.ts +++ b/src/error-logger.ts @@ -15,28 +15,49 @@ function ensureLogDir(): void { * 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 +65,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 +140,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 +160,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..ec2c2e0 100644 --- a/src/openai-thinking.ts +++ b/src/openai-thinking.ts @@ -4,22 +4,56 @@ type ThinkingConfig = { type: "enabled" | "disabled"; }; +type ProviderOptions = { + only?: string[]; + allow_fallbacks: boolean; +}; + 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 ): ThinkingRequestOptions { - const thinking: ThinkingConfig = { type: thinkingEnabled ? "enabled" : "disabled" }; + const providerOptions: ProviderOptions | undefined = + provider + ? { + only: [provider], + allow_fallbacks: false + } + : undefined; + + if (isOpenRouterBaseURL(baseURL)) { + 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 } : {}), + ...(providerOptions ? { provider: providerOptions } : {}) }; } + +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..178dc48 --- /dev/null +++ b/src/privacy-guard.ts @@ -0,0 +1,197 @@ +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 HIGH_ENTROPY_TOKEN_PATTERN = + /(? { + if ( + JWT_PATTERN.test(text) || + PRIVATE_KEY_PATTERN.test(text) || + API_KEY_VALUE_PATTERN.test(text) || + HIGH_RISK_ASSIGNMENT_SECRET_PATTERN.test(text) || + hasHighEntropySecret(text) + ) { + found = true; + } + resetPatterns(); + }); + return found; +} + +export function sanitizeForModelPipeline(value: unknown): PipelineSanitizationResult { + const blocked = containsHighRiskSecret(value); + return { + value: sanitizeValue(value), + blocked + }; +} + +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 { + if (!containsHighRiskSecret(value)) { + return; + } + const error = new Error( + "Blocked model request because it contains a high-risk secret pattern." + ); + error.name = "PrivacyGuardError"; + throw error; +} + +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 hasHighEntropySecret(text: string): boolean { + resetPatterns(); + let match: RegExpExecArray | null; + while ((match = HIGH_ENTROPY_TOKEN_PATTERN.exec(text)) !== null) { + if (isHighEntropySecretCandidate(match[0])) { + resetPatterns(); + return true; + } + } + resetPatterns(); + return false; +} + +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; + HIGH_ENTROPY_TOKEN_PATTERN.lastIndex = 0; + WINDOWS_PATH_PATTERN.lastIndex = 0; + POSIX_PATH_PATTERN.lastIndex = 0; +} diff --git a/src/prompt.ts b/src/prompt.ts index e1def61..e02dae7 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,10 +1,8 @@ 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"; +import { findGitBashPath } from "./tools/shell-utils"; export const AGENT_DRIFT_GUARD_SKILL = ` --- @@ -164,6 +162,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 read, summarize, transform, validate, or preserve it. Mention only that secret material was present and skipped. +- 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 +177,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 +188,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 +250,56 @@ 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 secrets, credentials, JWTs, private keys, API keys, cloud credentials, kubeconfigs, npm tokens, or .env values. +Never include secrets in logs, WebSearch queries, shell command descriptions, examples, summaries, or generated code. +If you see a password, JWT, private key, API key, token, or similar high-risk secret in visible context, refuse to inspect or repeat it. Say tersely that it is secret material and you are not reading it, then continue with a safe alternative. +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; }; -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"); -} +All provided tools are available for use. Choose any available tool when it helps complete the user's request, inspect the workspace, verify behavior, or gather needed context. Never read obvious secret-bearing files unless the user explicitly asks and the environment has enabled sensitive reads. -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)}`; +Available tool schemas are provided separately in the API request.`; + +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,7 +311,7 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { content: message.content, contentParams: message.contentParams, messageParams: message.messageParams, - createTime: message.createTime, + createTime: message.createTime }) ) .join("\n"); @@ -298,22 +320,19 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { function getRuntimeContext(projectRoot: string): string { 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"), - }, + "ripgrep": checkToolInstalled("rg"), + "jq": checkToolInstalled("jq") + } }; return `# Local Workspace Environment\n\n\`\`\`json ${JSON.stringify(env, null, 2)} @@ -327,7 +346,7 @@ function checkToolInstalled(tool: string): boolean { execFileSync(bashPath, ["-lc", `command -v ${shellSingleQuote(tool)}`], { encoding: "utf8", stdio: "ignore", - windowsHide: true, + windowsHide: true }); return true; } @@ -338,14 +357,6 @@ function checkToolInstalled(tool: string): boolean { } } -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, "'\"'\"'")}'`; } @@ -371,7 +382,7 @@ function getCommandVersion(command: string, args: string[]): string | null { if (process.platform === "win32") { return execFileSync(findGitBashPath(), ["-lc", `${commandText} 2>&1`], { encoding: "utf8", - windowsHide: true, + windowsHide: true }).trim(); } return execSync(`${commandText} 2>&1`, { encoding: "utf8" }).trim(); @@ -385,7 +396,7 @@ function getUnameInfo(): string { if (process.platform === "win32") { return execFileSync(findGitBashPath(), ["-lc", "uname -a"], { encoding: "utf8", - windowsHide: true, + windowsHide: true }).trim(); } return execSync("uname -a", { encoding: "utf8" }).trim(); @@ -394,17 +405,6 @@ function getUnameInfo(): string { } } -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,7 +419,7 @@ export type ToolDefinition = { }; }; -export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { +export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { const tools: ToolDefinition[] = [ { type: "function", @@ -455,7 +455,8 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { properties: { questions: { type: "array", - description: "Questions to present to the user. Usually only one question is needed at a time.", + description: + "Questions to present to the user. Usually only one question is needed at a time.", items: { type: "object", properties: { @@ -465,17 +466,20 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { }, multiSelect: { type: "boolean", - description: "Whether the user may choose multiple options.", + description: + "Whether the user may choose multiple options.", }, options: { type: "array", - description: "A list of predefined options for the user to choose from.", + 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: + "The display text for the option.", }, description: { type: "string", @@ -500,7 +504,8 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { type: "function", function: { name: "read", - description: "Read files from the filesystem (text, images, PDFs, notebooks).", + description: + "Read files from the filesystem (text, images, PDFs, notebooks).", parameters: { type: "object", properties: { @@ -518,7 +523,8 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { }, pages: { type: "string", - description: 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', + description: + 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', }, }, required: ["file_path"], @@ -530,7 +536,8 @@ 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.", + description: + "Create files or overwrite them with a complete string payload. Prefer edit for existing files.", parameters: { type: "object", properties: { @@ -562,8 +569,7 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { }, snippet_id: { type: "string", - description: - "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", + description: "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", }, old_string: { type: "string", @@ -575,12 +581,14 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { }, replace_all: { type: "boolean", - description: "Replace all occurences of old_string (default false)", + description: + "Replace all occurences of old_string (default false)", default: false, }, expected_occurrences: { type: "number", - description: "Expected number of matches, especially useful as a safety check with replace_all", + description: + "Expected number of matches, especially useful as a safety check with replace_all", }, }, required: ["old_string", "new_string"], @@ -600,8 +608,7 @@ export function getTools(_options: PromptToolOptions = {}): ToolDefinition[] { properties: { query: { type: "string", - description: - "A search query phrased as a clear, specific natural language question or statement that includes key context.", + description: "A search query phrased as a clear, specific natural language question or statement that includes key context.", }, }, required: ["query"], diff --git a/src/session.ts b/src/session.ts index 0116fbd..5308a11 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,9 +2,9 @@ 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"; @@ -13,11 +13,22 @@ import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL } import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; import { logApiError } from "./error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; +import { + assertNoHighRiskSecretsForModel, + sanitizeForModelPipeline, + sanitizeToolCallsForReplay +} 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 FINAL_HTTP_BODY_LOG_PATH = path.join(os.homedir(), ".deepcode", "logs", "final-http-body.jsonl"); +const require = createRequire(import.meta.url); +const ejs = require("ejs") as { + render: (template: string, data?: Record) => string; +}; type ChatCompletionDebugOptions = { enabled?: boolean; @@ -42,7 +53,9 @@ function summarizeCompletionOptions(options?: Record): Record | null; // {pid: {startTime, command}} + processes: Map | null; // {pid: {startTime, command}} }; export type SessionsIndex = { @@ -233,7 +252,7 @@ export class SessionManager { startedAt, estimatedTokens: Math.round(estimatedTokens), formattedTokens: this.formatEstimatedTokens(estimatedTokens), - phase, + phase }); } @@ -276,18 +295,20 @@ 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; try { - response = await ( - client.chat.completions.create as unknown as ( - body: Record, - options?: Record - ) => Promise - )(streamRequest, options); + assertNoHighRiskSecretsForModel(streamRequest); + outboundRequest = sanitizeForModelPipeline(streamRequest).value as Record; + this.logFinalHttpBody(requestId, sessionId, outboundRequest); + 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 +319,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 +331,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 +350,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 +361,11 @@ 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(); const trackText = (value: unknown) => { if (typeof value !== "string" || value.length === 0) { @@ -431,9 +449,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,9 +462,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 }); throw error; } finally { @@ -469,7 +487,7 @@ export class SessionManager { const finalResponse = { choices: [{ message }], - usage, + usage }; this.logChatCompletionDebug(debug, { timestamp: new Date().toISOString(), @@ -480,9 +498,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 +515,33 @@ export class SessionManager { logOpenAIChatCompletionDebug(entry); } + private logFinalHttpBody( + requestId: string, + sessionId: string | undefined, + body: Record + ): void { + if (process.env[FINAL_HTTP_BODY_LOG_ENV] !== "true") { + return; + } + + try { + fs.mkdirSync(path.dirname(FINAL_HTTP_BODY_LOG_PATH), { recursive: true }); + fs.appendFileSync( + FINAL_HTTP_BODY_LOG_PATH, + JSON.stringify({ + timestamp: new Date().toISOString(), + requestId, + sessionId, + boundary: "before client.chat.completions.create", + body + }) + "\n", + "utf8" + ); + } catch { + // Boundary logging must never affect the request path. + } + } + async identifyMatchingSkillNames( skills: SkillInfo[], userPrompt: string, @@ -512,43 +557,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(); 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" } + }); this.throwIfAborted(options?.signal); - + const rawContent = response.choices?.[0]?.message?.content; const content = typeof rawContent === "string" ? rawContent : ""; if (!content) { @@ -559,7 +596,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 +660,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 +707,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 +775,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 +822,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(); @@ -810,17 +854,19 @@ The candidate skills are as follows:\n\n`; 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 +874,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 +918,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 +973,7 @@ ${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, zdr } = this.createOpenAIClient(); const now = new Date().toISOString(); if (!client) { @@ -932,15 +981,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 +997,7 @@ ${skillMd} ...entry, status: "interrupted", failReason: "interrupted", - updateTime: now, + updateTime: now })); this.maybeNotifyTaskCompletion(sessionId, notify, startedAt); return; @@ -961,13 +1006,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 +1027,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 thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort, provider, zdr); const response = await this.createChatCompletionStream( client, { model, messages, tools: getTools(this.getPromptToolOptions()), - ...thinkingOptions, + ...thinkingOptions }, { signal: sessionController.signal }, sessionId, @@ -1008,7 +1049,7 @@ ${skillMd} enabled: debugLogEnabled, location: "SessionManager.activateSession", baseURL, - params: { iteration, thinkingEnabled, reasoningEffort }, + params: { iteration, thinkingEnabled, reasoningEffort } } ); @@ -1048,9 +1089,15 @@ ${skillMd} toolCalls, usage: accumulateUsage(entry.usage, 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 +1116,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 +1129,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 +1148,7 @@ ${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, zdr } = this.createOpenAIClient(); if (!client) { return; } @@ -1111,12 +1157,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 +1177,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); + 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 } + }); this.throwIfAborted(signal); const rawLlmResponse = response.choices?.[0]?.message?.content; const llmResponse = typeof rawLlmResponse === "string" ? rawLlmResponse : ""; @@ -1157,7 +1199,7 @@ ${skillMd} ...entry, usage: accumulateUsage(entry.usage, responseUsage), activeTokens: getTotalTokens(responseUsage), - updateTime: now, + updateTime: now })); for (let i = startIndex; i < endIndex; i += 1) { @@ -1176,8 +1218,8 @@ ${skillMd} createTime: now, updateTime: now, meta: { - isSummary: true, - }, + isSummary: true + } }; sessionMessages.splice(endIndex, 0, summaryMessage); this.saveSessionMessages(sessionId, sessionMessages); @@ -1185,7 +1227,7 @@ ${skillMd} private getPromptToolOptions(): { webSearchEnabled: boolean } { return { - webSearchEnabled: true, + webSearchEnabled: true }; } @@ -1199,9 +1241,9 @@ ${skillMd} method: "POST", headers: { "Content-Type": "application/json", - Token: machineId, + Token: machineId }, - body: JSON.stringify({}), + body: JSON.stringify({}) }) .then(async (response) => { if (response.ok) { @@ -1209,7 +1251,9 @@ ${skillMd} } const body = await response.text().catch(() => ""); - throw new Error(`New prompt API request failed with status ${response.status}${body ? `: ${body}` : ""}`); + 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); @@ -1260,7 +1304,7 @@ ${skillMd} status: "interrupted", failReason: "interrupted", processes: null, - updateTime: now, + updateTime: now })); const contentParts = ["Interrupted."]; @@ -1271,7 +1315,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 +1366,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 +1375,7 @@ ${skillMd} return { ...message, visible: typeof message.content === "string" ? !this.isInvisibleExecution(message.content) : message.visible, - meta: nextMeta, + meta: nextMeta }; } @@ -1369,7 +1417,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 +1431,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 +1469,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 +1489,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 +1506,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 +1533,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 +1546,7 @@ ${skillMd} if (content) { return { content, - displayPath: candidatePath.displayPath, + displayPath: candidatePath.displayPath }; } } @@ -1508,16 +1566,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 +1602,7 @@ ${skillMd} compacted: false, visible: false, createTime: now, - updateTime: now, + updateTime: now }; } @@ -1551,17 +1624,17 @@ ${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; const messageParams: { tool_calls?: unknown[]; reasoning_content?: string } | null = toolCalls || hasReasoningContent ? {} : null; if (toolCalls) { - messageParams!.tool_calls = toolCalls; + messageParams!.tool_calls = sanitizeToolCallsForReplay(toolCalls) ?? []; } if (hasReasoningContent) { messageParams!.reasoning_content = reasoningContent; @@ -1577,7 +1650,7 @@ ${skillMd} visible: (content || reasoningContent || "").trim() ? true : false, createTime: now, updateTime: now, - meta: toolCalls ? { asThinking: true } : undefined, + meta: toolCalls ? { asThinking: true } : undefined }; } @@ -1605,28 +1678,41 @@ ${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 }; } let waitingForUser = false; const followUpMessages: SessionMessage[] = []; + let blockedSensitiveOutput = false; for (const execution of toolExecutions) { if (execution.result.awaitUserResponse === true) { waitingForUser = true; } - const toolFunction = this.findToolFunction(toolCalls, execution.toolCallId); - const toolMessage = this.buildToolMessage(sessionId, execution.toolCallId, execution.content, toolFunction); + if (execution.blockedSensitiveOutput === true) { + waitingForUser = true; + blockedSensitiveOutput = true; + } + const toolFunction = this.findSanitizedToolFunction(toolCalls, execution.toolCallId); + const toolMessage = this.buildToolMessage( + sessionId, + execution.toolCallId, + execution.content, + toolFunction + ); this.appendSessionMessage(sessionId, toolMessage); this.onAssistantMessage(toolMessage, true); @@ -1635,7 +1721,11 @@ ${skillMd} continue; } followUpMessages.push( - this.buildSystemMessage(sessionId, followUpMessage.content, followUpMessage.contentParams ?? null) + this.buildSystemMessage( + sessionId, + followUpMessage.content, + followUpMessage.contentParams ?? null + ) ); } } @@ -1643,10 +1733,22 @@ ${skillMd} for (const followUpMessage of followUpMessages) { this.appendSessionMessage(sessionId, followUpMessage); } + if (blockedSensitiveOutput) { + const blockedMessage = this.buildAssistantMessage( + sessionId, + "yo thats secret material, im not reading that shit. I blocked it before it could be sent to the model.", + null + ); + this.appendSessionMessage(sessionId, blockedMessage); + this.onAssistantMessage(blockedMessage, true); + } return { waitingForUser }; } - private buildOpenAIMessages(messages: SessionMessage[], thinkingEnabled: boolean): ChatCompletionMessageParam[] { + private buildOpenAIMessages( + messages: SessionMessage[], + thinkingEnabled: boolean, + ): ChatCompletionMessageParam[] { const activeMessages = messages.filter((message) => !message.compacted); const toolPairings = this.pairToolMessages(activeMessages); const openAIMessages: ChatCompletionMessageParam[] = []; @@ -1683,17 +1785,19 @@ ${skillMd} 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 +1814,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 +1929,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; } @@ -1851,6 +1954,11 @@ ${skillMd} return null; } + private findSanitizedToolFunction(toolCalls: unknown[], toolCallId: string): unknown | null { + const sanitizedToolCalls = sanitizeToolCallsForReplay(toolCalls); + return sanitizedToolCalls ? this.findToolFunction(sanitizedToolCalls, toolCallId) : null; + } + private buildToolParamsSnippet(toolFunction: unknown | null): string { if (!toolFunction || typeof toolFunction !== "object") { return ""; @@ -1948,7 +2056,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 +2081,7 @@ ${skillMd} return { ...entry, processes, - updateTime: now, + updateTime: now }; }); } @@ -1982,7 +2094,7 @@ ${skillMd} return { ...entry, processes: processes.size > 0 ? processes : null, - updateTime: now, + updateTime: now }; }); } @@ -2004,7 +2116,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 +2124,8 @@ ${skillMd} name: toolName, error: reason, metadata: { - interrupted: true, - }, + interrupted: true + } }, null, 2 @@ -2033,7 +2145,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, @@ -2047,7 +2159,7 @@ ${skillMd} 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 +2199,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..213ace0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -5,18 +5,20 @@ export type DeepcodingEnv = { BASE_URL?: string; API_KEY?: string; THINKING?: string; + PROVIDER?: string; + ZDR?: string; }; -export type ReasoningEffort = "high" | "max"; +export type ReasoningEffort = "xhigh" | "high" | "medium" | "low" | "minimal" | "none"; export type DeepcodingSettings = { env?: DeepcodingEnv; - model?: string; thinkingEnabled?: boolean; reasoningEffort?: ReasoningEffort; debugLogEnabled?: boolean; notify?: string; webSearchTool?: string; + zdr?: boolean; }; export type ResolvedDeepcodingSettings = { @@ -28,19 +30,22 @@ export type ResolvedDeepcodingSettings = { debugLogEnabled: boolean; notify?: string; webSearchTool?: string; -}; - -export type ModelConfigSelection = { - model: string; - thinkingEnabled: boolean; - reasoningEffort: ReasoningEffort; + provider?: string; + zdr?: 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; } @@ -58,10 +63,12 @@ export function resolveSettings( 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 zdr = env.ZDR ? env.ZDR.trim().toLowerCase() === "true" : (settings?.zdr === true); return { apiKey: env.API_KEY?.trim(), @@ -72,35 +79,7 @@ export function resolveSettings( debugLogEnabled: settings?.debugLogEnabled === true, notify: notify || undefined, webSearchTool: webSearchTool || undefined, + provider: provider || undefined, + zdr: zdr || 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..ea6a082 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, /╭─+╮/); 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..42c3477 --- /dev/null +++ b/src/tests/privacy-guard.test.ts @@ -0,0 +1,126 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + assertNoHighRiskSecretsForModel, + sanitizeForModelPipeline, + sanitizeToolCallsForReplay +} from "../privacy-guard"; + +test("sanitizeForModelPipeline redacts tool output and marks high-risk secrets as blocked", () => { + 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.blocked, 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.blocked, 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 blocks outbound model requests", () => { + assert.throws( + () => + assertNoHighRiskSecretsForModel({ + messages: [ + { + role: "tool", + content: "secret=sk-or-abcdef1234567890" + } + ] + }), + /high-risk secret/ + ); +}); + +test("assertNoHighRiskSecretsForModel does not hard-block generic test passwords", () => { + assert.doesNotThrow(() => + assertNoHighRiskSecretsForModel({ + messages: [ + { + role: "user", + content: "ok password: 454525234" + } + ] + }) + ); +}); + +test("sanitizeForModelPipeline redacts generic password assignments without hard-blocking", () => { + const result = sanitizeForModelPipeline({ + output: "password: 454525234", + userContent: "im giving test password 1234523 note it" + }); + + assert.equal(result.blocked, false); + assert.deepEqual(result.value, { + output: "password:[REDACTED_SECRET]", + userContent: "im giving test password [REDACTED_SECRET] note it" + }); +}); + +test("sanitizeForModelPipeline blocks and redacts unknown high-entropy tokens", () => { + const token = "A7fK9pQ2rT6vX1mN8bC4dE5gH3jL0sZyWqR"; + const result = sanitizeForModelPipeline({ + output: `session token ${token}` + }); + + assert.equal(result.blocked, true); + assert.deepEqual(result.value, { + output: "session token [REDACTED_HIGH_ENTROPY_SECRET]" + }); +}); + +test("sanitizeForModelPipeline does not flag low-entropy placeholder strings", () => { + const placeholder = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const result = sanitizeForModelPipeline({ + output: `placeholder ${placeholder}` + }); + + assert.equal(result.blocked, false); + assert.deepEqual(result.value, { + output: `placeholder ${placeholder}` + }); +}); 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..b18b628 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,7 +210,10 @@ 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", () => { @@ -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" ); @@ -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); }); @@ -322,7 +335,7 @@ 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; @@ -338,23 +351,42 @@ 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("replySession stores /init and sends the active root project AGENTS path to the LLM", async () => { +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-"); + process.env.HOME = 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 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; @@ -367,21 +399,15 @@ 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; @@ -394,17 +420,12 @@ 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 () => { @@ -417,7 +438,7 @@ test("createSession reports a new prompt with the machineId token", async () => fetchCalls.push({ input, init }); return { ok: true, - text: async () => "", + text: async () => "" } as Response; }) as typeof fetch; @@ -449,7 +470,7 @@ test("replySession reports a new prompt with the machineId token", async () => { fetchCalls.push({ input, init }); return { ok: true, - text: async () => "", + text: async () => "" } as Response; }) as typeof fetch; @@ -475,11 +496,10 @@ test("replySession preserves raw session messages when a previous tool call is p const home = createTempDir("deepcode-pending-tool-home-"); process.env.HOME = 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 +512,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 +526,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 +538,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 +568,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 +577,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 +586,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 +612,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 +624,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 +653,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 +673,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 +683,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 +691,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 +704,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 +718,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 +733,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 +750,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 +759,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 +782,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 +796,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 +818,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 +832,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 +841,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/); @@ -885,7 +871,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 +880,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); @@ -923,18 +909,18 @@ test("SessionManager resets active tokens to latest post-compaction response usa 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); @@ -975,13 +961,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 +976,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 +985,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, "思考"); @@ -1025,9 +1013,12 @@ test("SessionManager cancels skill matching before a session is created", async 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 +1029,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); @@ -1064,19 +1055,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 +1075,7 @@ test("SessionManager treats OpenAI APIUserAbortError as interrupted", async () = if (entry.status === "processing") { queueMicrotask(() => manager.interruptActiveSession()); } - }, + } }); await manager.handleUserPrompt({ text: "" }); @@ -1105,11 +1095,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 +1111,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 +1122,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 +1137,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 +1150,7 @@ class APIUserAbortError extends Error {} function createChatResponse(content: string, usage: Record): unknown { return { choices: [{ message: { content } }], - usage, + usage }; } @@ -1180,7 +1170,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" }; } diff --git a/src/tests/settings-and-notify.test.ts b/src/tests/settings-and-notify.test.ts index f2c1ca1..6814446 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" } ); @@ -33,38 +33,21 @@ test("resolveSettings reads top-level thinkingEnabled, notify, and webSearchTool assert.equal(resolved.webSearchTool, "/tmp/web-search.sh"); }); -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", - } - ); - - 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 +56,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 +73,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 +85,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 +101,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 +173,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 index 7d19357..a8f2ff2 100644 --- a/src/tests/shell-utils.test.ts +++ b/src/tests/shell-utils.test.ts @@ -5,7 +5,7 @@ import { getShellKind, posixPathToWindowsPath, rewriteWindowsNullRedirect, - windowsPathToPosixPath, + windowsPathToPosixPath } from "../tools/shell-utils"; import { isAbsoluteFilePath, normalizeFilePath } from "../tools/state"; @@ -31,10 +31,7 @@ test("Windows nul redirects are rewritten for POSIX bash", () => { 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("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"); }); @@ -43,7 +40,10 @@ test("File tool path normalization converts Git Bash drive paths on Windows", () 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("/cygdrive/c/Users/foo/file.txt", "win32"), + "C:\\Users\\foo\\file.txt" + ); assert.equal(normalizeFilePath("/dev/null", "win32"), "\\dev\\null"); }); 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 index 256e0d6..71219f4 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -22,7 +22,11 @@ afterEach(() => { 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"); + fs.writeFileSync( + filePath, + ["alpha", "target = 1", "omega", "beta", "target = 1", "done"].join("\n"), + "utf8" + ); const sessionId = "snippet-scope"; const readResult = await handleReadTool( @@ -31,7 +35,9 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i ); assert.equal(readResult.ok, true); - const snippet = (readResult.metadata?.snippet ?? null) as { id: string; startLine: number; endLine: number } | null; + 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); @@ -40,7 +46,7 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i { snippet_id: snippet?.id, old_string: "target = 1", - new_string: "target = 2", + new_string: "target = 2" }, createContext(sessionId, workspace) ); @@ -57,6 +63,44 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i ); }); +test("Read refuses obvious secret-bearing files by default", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, ".env"); + fs.writeFileSync(filePath, "JWT_PRIVATE_KEY=secret\n", "utf8"); + + const readResult = await handleReadTool( + { file_path: filePath }, + createContext("sensitive-read", workspace) + ); + + assert.equal(readResult.ok, false); + assert.match(readResult.error ?? "", /Refusing to read sensitive file/); +}); + +test("Read can explicitly allow sensitive files via environment override", async () => { + const workspace = createTempWorkspace(); + const filePath = path.join(workspace, ".env"); + fs.writeFileSync(filePath, "JWT_PRIVATE_KEY=secret\n", "utf8"); + const original = process.env.DEEPCODE_ALLOW_SENSITIVE_READS; + process.env.DEEPCODE_ALLOW_SENSITIVE_READS = "true"; + + try { + const readResult = await handleReadTool( + { file_path: filePath }, + createContext("sensitive-read-override", workspace) + ); + + assert.equal(readResult.ok, true); + assert.match(readResult.output ?? "", /JWT_PRIVATE_KEY=secret/); + } finally { + if (original === undefined) { + delete process.env.DEEPCODE_ALLOW_SENSITIVE_READS; + } else { + process.env.DEEPCODE_ALLOW_SENSITIVE_READS = original; + } + } +}); + test("Edit returns candidate match snippets when old_string is not unique", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, "duplicate.txt"); @@ -69,13 +113,16 @@ test("Edit returns candidate match snippets when old_string is not unique", asyn { file_path: filePath, old_string: "city", - new_string: "location", + 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."); + 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; @@ -102,13 +149,16 @@ test("replace_all requires expected_occurrences for broad short-fragment replace file_path: filePath, old_string: fragment, new_string: " schema:\n type: array", - replace_all: true, + replace_all: true }, createContext(sessionId, workspace) ); assert.equal(blockedResult.ok, false); - assert.match(blockedResult.error ?? "", /provide expected_occurrences to confirm this broader replacement/); + assert.match( + blockedResult.error ?? "", + /provide expected_occurrences to confirm this broader replacement/ + ); const allowedResult = await handleEditTool( { @@ -116,7 +166,7 @@ test("replace_all requires expected_occurrences for broad short-fragment replace old_string: fragment, new_string: " schema:\n type: array", replace_all: true, - expected_occurrences: 3, + expected_occurrences: 3 }, createContext(sessionId, workspace) ); @@ -127,7 +177,7 @@ test("replace_all requires expected_occurrences for broad short-fragment replace [ " schema:\n type: array", " schema:\n type: array", - " schema:\n type: array", + " schema:\n type: array" ].join("\n---\n") ); }); @@ -144,7 +194,7 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn { file_path: filePath, old_string: "params['city_json'] = f'\\\\\"{city}\\\\\"'", - new_string: "params['city_json'] = city", + new_string: "params['city_json'] = city" }, createContext(sessionId, workspace, { createOpenAIClient: () => ({ @@ -159,17 +209,17 @@ test("Edit accepts a unique loose-escape match when only escaping differs", asyn "" + "" + "" + - "", - }, - }, - ], - }), - }, - }, + "" + } + } + ] + }) + } + } } as any, model: "test-model", - thinkingEnabled: false, - }), + thinkingEnabled: false + }) }) ); @@ -187,8 +237,8 @@ test("Write repairs JSON object content for .json files", async () => { file_path: filePath, content: { name: "demo", - private: true, - } as unknown as string, + private: true + } as unknown as string }, createContext("write-json-object", workspace) ); @@ -200,7 +250,10 @@ test("Write repairs JSON object content for .json files", async () => { 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}'); + 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 () => { @@ -210,7 +263,7 @@ test("Write updates file state so a follow-up Edit can succeed without another R const writeResult = await handleWriteTool( { file_path: filePath, - content: "alpha\nbeta\n", + content: "alpha\nbeta\n" }, createContext("write-then-edit", workspace) ); @@ -223,7 +276,7 @@ test("Write updates file state so a follow-up Edit can succeed without another R { file_path: filePath, old_string: "beta", - new_string: "gamma", + new_string: "gamma" }, createContext("write-then-edit", workspace) ); @@ -246,7 +299,7 @@ test("Write requires a full read before overwriting an existing file", async () const blockedResult = await handleWriteTool( { file_path: filePath, - content: "rewritten", + content: "rewritten" }, createContext(sessionId, workspace) ); @@ -263,7 +316,7 @@ test("Write can overwrite an existing empty file without a prior read", async () const writeResult = await handleWriteTool( { file_path: filePath, - content: "initialized\n", + content: "initialized\n" }, createContext("write-empty-existing", workspace) ); @@ -291,7 +344,7 @@ test("Edit rejects stale reads after the file changes on disk", async () => { { file_path: filePath, old_string: "after", - new_string: "final", + new_string: "final" }, createContext(sessionId, workspace) ); @@ -307,7 +360,7 @@ test("Write preserves the exact trailing newline policy from the provided conten const writeResult = await handleWriteTool( { file_path: filePath, - content: "no trailing newline", + content: "no trailing newline" }, createContext("write-no-newline", workspace) ); @@ -329,7 +382,7 @@ test("Edit preserves CRLF line endings for existing files", async () => { { file_path: filePath, old_string: "beta", - new_string: "gamma", + new_string: "gamma" }, createContext(sessionId, workspace) ); @@ -350,7 +403,10 @@ test("Read returns an acknowledgement for images and attaches the image as a fol ) ); - const readResult = await handleReadTool({ file_path: filePath }, createContext("image-read", workspace)); + const readResult = await handleReadTool( + { file_path: filePath }, + createContext("image-read", workspace) + ); assert.equal(readResult.ok, true); assert.equal(readResult.output, "File loaded."); @@ -361,11 +417,15 @@ test("Read returns an acknowledgement for images and attaches the image as a fol const followUpMessage = readResult.followUpMessages?.[0]; assert.equal(followUpMessage?.role, "system"); assert.match(followUpMessage?.content ?? "", /pixel\.png/); - const contentParams = Array.isArray(followUpMessage?.contentParams) ? followUpMessage.contentParams : []; + 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 ?? ""), + String( + ((contentParams[0] as { image_url?: { url?: unknown } }).image_url?.url ?? "") + ), /^data:image\/png;base64,/ ); }); @@ -383,10 +443,10 @@ function createContext( type: "function", function: { name: "test", - arguments: "{}", - }, + arguments: "{}" + } }, - ...overrides, + ...overrides }; } diff --git a/src/tests/updateCheck.test.ts b/src/tests/updateCheck.test.ts index ce77fe5..19341f6 100644 --- a/src/tests/updateCheck.test.ts +++ b/src/tests/updateCheck.test.ts @@ -10,7 +10,7 @@ test("compareVersions orders semantic versions", () => { }); test("parseNpmViewVersion parses npm view JSON and plain output", () => { - assert.equal(parseNpmViewVersion('"0.1.4"\n'), "0.1.4"); + 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..2f69a10 100644 --- a/src/tests/web-search-handler.test.ts +++ b/src/tests/web-search-handler.test.ts @@ -25,7 +25,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,13 +41,16 @@ 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]); @@ -65,16 +72,15 @@ test("WebSearch uses the default API when no script is configured", async () => choices: [ { message: { - content: - '{"dominant_language":"en","reason":"Most Node.js release notes are published in English."}', - }, - }, - ], + 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; @@ -89,14 +95,14 @@ test("WebSearch uses the default API when no script is configured", async () => organic_results: [ { title: "Node.js Releases", - link: "https://nodejs.org/en/about/previous-releases", - }, - ], + link: "https://nodejs.org/en/about/previous-releases" + } + ] }, null, 2 - ), - }), + ) + }) } as Response; }) as typeof fetch; @@ -106,7 +112,7 @@ test("WebSearch uses the default API when no script is configured", async () => client: fakeClient, machineId: "machine-id-123", onProcessStart: (id, command) => starts.push({ id, command }), - onProcessExit: (id) => exits.push(id), + onProcessExit: (id) => exits.push(id) }) ); @@ -125,10 +131,16 @@ test("WebSearch uses the default API when no script is configured", async () => 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)); + 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 default mode requires a valid LLM configuration in ~/.deepcode/settings.json." + ); }); function createContext( @@ -149,18 +161,18 @@ function createContext( type: "function", function: { name: "WebSearch", - arguments: "{}", - }, + arguments: "{}" + } }, createOpenAIClient: () => ({ client: options.client ?? null, model: "test-model", thinkingEnabled: false, webSearchTool: options.webSearchTool, - machineId: options.machineId, + 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 index 155d82a..da8f9ef 100644 --- a/src/tools/bash-handler.ts +++ b/src/tools/bash-handler.ts @@ -6,7 +6,7 @@ import { buildShellInitCommand, resolveShellPath, rewriteWindowsNullRedirect, - toNativeCwd, + toNativeCwd } from "./shell-utils"; const MAX_OUTPUT_CHARS = 30000; @@ -33,7 +33,7 @@ export async function handleBashTool( return { ok: false, name: "bash", - error: 'Missing required "command" string.', + error: "Missing required \"command\" string." }; } @@ -54,7 +54,11 @@ export async function handleBashTool( 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, ok: false }, + "bash", + errorMessage + ); } return formatResult(result, "bash"); @@ -110,7 +114,7 @@ async function executeShellCommand( env: buildShellEnv(shellPath), detached, windowsHide: true, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["ignore", "pipe", "pipe"] }); const pid = child.pid; if (typeof pid === "number") { @@ -141,7 +145,7 @@ async function executeShellCommand( stderr, exitCode: typeof code === "number" ? code : null, signal: signal ?? null, - error, + error }); }); }); @@ -181,7 +185,7 @@ function buildToolCommandResult( signal, truncated, shellPath, - startCwd, + startCwd }; } @@ -239,14 +243,18 @@ function buildErrorMessage(exitCode: number | null, signal: string | null, error return "Command failed."; } -function formatResult(result: ToolCommandResult, name: string, errorMessage?: string): ToolExecutionResult { +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, + startCwd: result.startCwd }; const outputValue = result.output ? result.output : undefined; @@ -256,6 +264,6 @@ function formatResult(result: ToolCommandResult, name: string, errorMessage?: st name, output: outputValue, error: errorMessage, - metadata, + metadata }; } diff --git a/src/tools/edit-handler.ts b/src/tools/edit-handler.ts index 403f984..c8740de 100644 --- a/src/tools/edit-handler.ts +++ b/src/tools/edit-handler.ts @@ -2,7 +2,12 @@ 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 { + buildDiffPreview, + hasFileChangedSinceState, + readTextFileWithMetadata, + writeTextFile +} from "./file-utils"; import { executeValidatedTool, semanticBoolean } from "./runtime"; import { createSnippet, @@ -11,7 +16,7 @@ import { isAbsoluteFilePath, isFullFileView, normalizeFilePath, - recordFileState, + recordFileState } from "./state"; const MAX_CANDIDATE_COUNT = 5; @@ -72,7 +77,7 @@ const editSchema = z.strictObject({ return Number(value); } return value; - }, z.number().int().min(1, "expected_occurrences must be >= 1.").optional()), + }, z.number().int().min(1, "expected_occurrences must be >= 1.").optional()) }); export async function handleEditTool( @@ -93,7 +98,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: 'Missing required "file_path" string or "snippet_id" string.', + error: "Missing required \"file_path\" string or \"snippet_id\" string." }; } @@ -106,7 +111,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "file_path must be an absolute path.", + error: "file_path must be an absolute path." }; } @@ -114,7 +119,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `Unknown snippet_id: ${snippetId}`, + error: `Unknown snippet_id: ${snippetId}` }; } @@ -122,7 +127,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "snippet_id does not belong to the provided file_path.", + error: "snippet_id does not belong to the provided file_path." }; } @@ -130,7 +135,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "old_string must not be empty.", + error: "old_string must not be empty." }; } @@ -138,7 +143,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "new_string must differ from old_string.", + error: "new_string must differ from old_string." }; } @@ -146,7 +151,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `File not found: ${filePath}`, + error: `File not found: ${filePath}` }; } @@ -158,7 +163,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: `Failed to stat file: ${message}`, + error: `Failed to stat file: ${message}` }; } @@ -166,7 +171,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "file_path points to a directory.", + error: "file_path points to a directory." }; } @@ -175,7 +180,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "Must read file before editing.", + error: "Must read file before editing." }; } @@ -183,7 +188,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "File was only partially read. Use snippet_id or read the full file before editing.", + error: "File was only partially read. Use snippet_id or read the full file before editing." }; } @@ -191,7 +196,7 @@ export async function handleEditTool( return { ok: false, name: "edit", - error: "File has been modified since read. Read it again before editing.", + error: "File has been modified since read. Read it again before editing." }; } @@ -245,11 +250,15 @@ export async function handleEditTool( metadata: closestMatch ? { scope: formatScopeMetadata(scope), - closest_match: buildClosestMatchMetadata(context.sessionId, filePath, closestMatch), + closest_match: buildClosestMatchMetadata( + context.sessionId, + filePath, + closestMatch + ) } : { - scope: formatScopeMetadata(scope), - }, + scope: formatScopeMetadata(scope) + } }; } @@ -261,8 +270,8 @@ export async function handleEditTool( metadata: { match_count: matches.length, scope: formatScopeMetadata(scope), - candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches), - }, + candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches) + } }; } @@ -271,7 +280,7 @@ export async function handleEditTool( replaceAll, matchCount: matches.length, oldString: replacementOldString, - expectedOccurrences, + expectedOccurrences }); if (replaceAllGuardError) { return { @@ -281,12 +290,18 @@ export async function handleEditTool( metadata: { match_count: matches.length, scope: formatScopeMetadata(scope), - candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches), - }, + candidates: buildCandidateMetadata(context.sessionId, filePath, raw, matches) + } }; } - const updated = applyReplacement(raw, replacementOldString, replacementNewString, matches, replaceAll); + 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); @@ -295,7 +310,7 @@ export async function handleEditTool( content: freshMetadata.content, timestamp: freshMetadata.timestamp, encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings, + lineEndings: freshMetadata.lineEndings }); const replacedCount = replaceAll ? matches.length : 1; return { @@ -311,15 +326,15 @@ export async function handleEditTool( encoding: freshMetadata.encoding, line_endings: freshMetadata.lineEndings, diff_preview: diffPreview, - scope: formatScopeMetadata(scope), - }, + scope: formatScopeMetadata(scope) + } }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "edit", - error: message, + error: message }; } }, @@ -333,7 +348,7 @@ export async function handleEditTool( nextInput.snippet_id = nextInput.snippet_id.trim(); } return { ok: true, input: nextInput }; - }, + } } ); } @@ -372,7 +387,7 @@ function buildSearchScope( endOffset: raw.length, startLine: 1, endLine: lineIndex.lines.length, - snippetId: null, + snippetId: null }; } @@ -384,7 +399,7 @@ function buildSearchScope( endOffset: lineIndex.lineStarts[safeEndLine + 1], startLine: safeStartLine, endLine: safeEndLine, - snippetId: snippet.id, + snippetId: snippet.id }; } @@ -412,7 +427,7 @@ function findOccurrences(raw: string, needle: string, scope: SearchScope): Match startOffset, endOffset, startLine: offsetToLine(raw, startOffset), - endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)), + endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)) }); searchIndex = found + needle.length; } @@ -447,7 +462,7 @@ function findLooseEscapeMatches(raw: string, needle: string, scope: SearchScope) startOffset, endOffset, startLine: offsetToLine(raw, startOffset), - endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)), + endLine: offsetToLine(raw, Math.max(startOffset, endOffset - 1)) }); } @@ -481,7 +496,10 @@ function validateReplaceAllGuard(input: { } if (input.expectedOccurrences !== null && input.expectedOccurrences !== input.matchCount) { - return `replace_all expected ${input.expectedOccurrences} occurrence(s), ` + `but found ${input.matchCount}.`; + return ( + `replace_all expected ${input.expectedOccurrences} occurrence(s), ` + + `but found ${input.matchCount}.` + ); } const isShortFragment = input.oldString.trim().length < SHORT_REPLACE_ALL_LENGTH; @@ -534,7 +552,7 @@ function buildCandidateMetadata( snippet_id: snippet?.id ?? null, start_line: match.startLine, end_line: match.endLine, - preview, + preview }; }); } @@ -544,8 +562,17 @@ function buildClosestMatchMetadata( 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); + 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, @@ -553,7 +580,7 @@ function buildClosestMatchMetadata( end_line: closestMatch.endLine, similarity: Number(closestMatch.score.toFixed(3)), strategy: closestMatch.strategy, - preview, + preview }; } @@ -562,7 +589,7 @@ function formatScopeMetadata(scope: SearchScope): Record { file_path: scope.filePath, start_line: scope.startLine, end_line: scope.endLine, - snippet_id: scope.snippetId, + snippet_id: scope.snippetId }; } @@ -573,7 +600,9 @@ function buildPreview(raw: string, startLine: number, endLine: number): string { } function formatWithLineNumbers(lines: string[], startLine: number): string { - return lines.map((line, index) => `${String(startLine + index).padStart(6, " ")}\t${line}`).join("\n"); + return lines + .map((line, index) => `${String(startLine + index).padStart(6, " ")}\t${line}`) + .join("\n"); } function findClosestMatch( @@ -591,7 +620,7 @@ function findClosestMatch( startLine: match.startLine, endLine: match.endLine, score: match.score, - strategy: "loose_escape", + strategy: "loose_escape" }; if (!bestLooseMatch || candidate.score > bestLooseMatch.score) { bestLooseMatch = candidate; @@ -626,7 +655,7 @@ function findClosestMatch( startLine, endLine, score, - strategy: "fuzzy_window", + strategy: "fuzzy_window" }; if (!bestMatch || candidate.score > bestMatch.score) { @@ -687,7 +716,7 @@ async function correctEscapedStringsWithLLM( } try { - const response = await client.chat.completions.create({ + const response = await (client.chat.completions.create as unknown as (body: Record) => Promise<{ choices?: Array<{ message?: { content?: string } }> }>)({ model, messages: [ { @@ -695,7 +724,7 @@ async function correctEscapedStringsWithLLM( 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.", + "Do not change semantics; only fix quoting or escaping so corrected_old_string matches the snippet exactly." }, { role: "user", @@ -711,10 +740,10 @@ async function correctEscapedStringsWithLLM( " \n" + " \n" + " \n" + - "", - }, + "" + } ], - ...buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort), + ...buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort) }); const content = response.choices?.[0]?.message?.content ?? ""; @@ -757,10 +786,13 @@ function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null const correctedOldString = oldMatch?.[1] ?? oldMatch?.[2]; const correctedNewString = newMatch?.[1] ?? newMatch?.[2]; - if (typeof correctedOldString === "string" && typeof correctedNewString === "string") { + if ( + typeof correctedOldString === "string" && + typeof correctedNewString === "string" + ) { return { oldString: correctedOldString, - newString: correctedNewString, + newString: correctedNewString }; } @@ -768,7 +800,7 @@ function parseCorrectedEditStrings(content: string): CorrectedEditStrings | null } function isEscapeSensitiveChar(value: string): boolean { - return value === '"' || value === "'" || value === "`" || value === "\\"; + return value === "\"" || value === "'" || value === "`" || value === "\\"; } function escapeRegExp(value: string): string { diff --git a/src/tools/executor.ts b/src/tools/executor.ts index eec0b20..a2bc572 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -6,6 +6,7 @@ import { handleEditTool } from "./edit-handler"; import { handleReadTool } from "./read-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; +import { sanitizeForModelPipeline } from "../privacy-guard"; export type CreateOpenAIClient = () => { client: OpenAI | null; @@ -17,6 +18,8 @@ export type CreateOpenAIClient = () => { notify?: string; webSearchTool?: string; machineId?: string; + provider?: string; + zdr?: boolean; }; export type ToolCall = { @@ -68,6 +71,12 @@ export type ToolCallExecution = { toolCallId: string; content: string; result: ToolExecutionResult; + blockedSensitiveOutput?: boolean; +}; + +type FormattedToolResult = { + content: string; + blockedSensitiveOutput: boolean; }; export class ToolExecutor { @@ -96,10 +105,12 @@ export class ToolExecutor { break; } const result = await this.executeToolCall(sessionId, toolCall, hooks); + const formattedResult = this.formatToolResult(result); executions.push({ toolCallId: toolCall.id, - content: this.formatToolResult(result), + content: formattedResult.content, result, + blockedSensitiveOutput: formattedResult.blockedSensitiveOutput }); if (hooks?.shouldStop?.()) { break; @@ -141,15 +152,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 +176,7 @@ export class ToolExecutor { return { ok: false, name: toolName, - error: `Unknown tool: ${toolName}`, + error: `Unknown tool: ${toolName}` }; } @@ -173,7 +185,7 @@ export class ToolExecutor { return { ok: false, name: toolName, - error: parsedArgs.error, + error: parsedArgs.error }; } @@ -184,14 +196,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,15 +227,15 @@ 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." }; } } - private formatToolResult(result: ToolExecutionResult): string { + private formatToolResult(result: ToolExecutionResult): FormattedToolResult { const payload: Record = { ok: result.ok, - name: result.name, + name: result.name }; if (typeof result.output !== "undefined") { @@ -242,6 +254,24 @@ export class ToolExecutor { payload.awaitUserResponse = true; } - return JSON.stringify(payload, null, 2); + const sanitized = sanitizeForModelPipeline(payload); + if (sanitized.blocked) { + return { + content: JSON.stringify({ + ok: false, + name: result.name, + blockedSensitiveOutput: true, + error: "Tool output contained high-risk secret material and was redacted before model replay.", + awaitUserResponse: true + }, null, 2), + blockedSensitiveOutput: true + }; + } + + return { + content: JSON.stringify(sanitized.value, null, 2), + blockedSensitiveOutput: false + }; } + } diff --git a/src/tools/file-utils.ts b/src/tools/file-utils.ts index 6656172..b5705d9 100644 --- a/src/tools/file-utils.ts +++ b/src/tools/file-utils.ts @@ -35,7 +35,7 @@ export function readTextFileWithMetadata(filePath: string): FileReadMetadata { content: normalizeContent(raw), encoding, lineEndings: detectLineEndings(raw), - timestamp: Math.floor(stat.mtimeMs), + timestamp: Math.floor(stat.mtimeMs) }; } @@ -61,7 +61,10 @@ export function hasFileChangedSinceState(filePath: string, state: FileState): bo return false; } - const isFullRead = !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined"; + const isFullRead = + !state.isPartialView && + typeof state.offset === "undefined" && + typeof state.limit === "undefined"; return !(isFullRead && current.content === state.content); } @@ -83,7 +86,11 @@ export function buildDiffPreview( const newLines = toDiffLines(updated); let prefix = 0; - while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) { + while ( + prefix < oldLines.length && + prefix < newLines.length && + oldLines[prefix] === newLines[prefix] + ) { prefix += 1; } @@ -104,7 +111,7 @@ export function buildDiffPreview( const previewLines = [ `--- ${original === null ? "/dev/null" : `a/${filePath}`}`, `+++ b/${filePath}`, - `@@ -${oldStart},${oldChanged.length} +${newStart},${newChanged.length} @@`, + `@@ -${oldStart},${oldChanged.length} +${newStart},${newChanged.length} @@` ]; if (prefix > 0) { diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts index 548bcfd..f076108 100644 --- a/src/tools/read-handler.ts +++ b/src/tools/read-handler.ts @@ -1,7 +1,11 @@ import * as fs from "fs"; import * as path from "path"; import ignore from "ignore"; -import type { ToolExecutionContext, ToolExecutionFollowUpMessage, ToolExecutionResult } from "./executor"; +import type { + ToolExecutionContext, + ToolExecutionFollowUpMessage, + ToolExecutionResult +} from "./executor"; import { readTextFileWithMetadata } from "./file-utils"; import { createSnippet, isAbsoluteFilePath, markFileRead, normalizeFilePath } from "./state"; @@ -32,7 +36,38 @@ const DEFAULT_GITIGNORE = [ "*.class", "*.jar", "*.war", - "target/", + "target/" +]; +const SENSITIVE_FILE_BASENAMES = new Set([ + ".env", + ".env.local", + ".env.development", + ".env.production", + ".npmrc", + ".pypirc", + ".netrc", + "credentials.json", + "secrets.json", + "kubeconfig", + "id_rsa", + "id_dsa", + "id_ecdsa", + "id_ed25519" +]); +const SENSITIVE_FILE_EXTENSIONS = new Set([ + ".key", + ".pem", + ".p12", + ".pfx", + ".jks", + ".keystore" +]); +const SENSITIVE_RELATIVE_PATHS = [ + ".aws/credentials", + ".azure/accessTokens.json", + ".docker/config.json", + ".kube/config", + "google_application_credentials.json" ]; type PageRange = { @@ -62,7 +97,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: 'Missing required "file_path" string.', + error: "Missing required \"file_path\" string." }; } @@ -71,12 +106,14 @@ export async function handleReadTool( return { ok: false, name: "read", - error: "file_path must be an absolute path.", + 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) : []; + const matches = normalizedSuffix + ? findSuffixMatches(context.projectRoot, normalizedSuffix, isIgnored) + : []; if (matches.length > 1) { return { ok: false, @@ -84,7 +121,7 @@ export async function handleReadTool( 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.` : ""), + (matches.length > 3 ? `\n...and ${matches.length - 3} more.` : "") }; } @@ -94,13 +131,15 @@ export async function handleReadTool( return { ok: false, name: "read", - error: "file_path must be an absolute path. " + `The file_path "${filePath}" is ambiguous.`, + 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}`, + error: `File not found: ${filePath}` }; } } @@ -112,7 +151,16 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `File not found: ${filePath}`, + error: `File not found: ${filePath}` + }; + } + + const sensitiveCheck = checkSensitiveRead(filePath, context.projectRoot); + if (!sensitiveCheck.ok) { + return { + ok: false, + name: "read", + error: sensitiveCheck.error }; } @@ -124,7 +172,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `Failed to stat file: ${message}`, + error: `Failed to stat file: ${message}` }; } @@ -132,7 +180,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: "file_path points to a directory. Use bash ls for directories.", + error: "file_path points to a directory. Use bash ls for directories." }; } @@ -143,12 +191,12 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true, + isPartialView: true }); return { ok: true, name: "read", - output, + output }; } @@ -162,7 +210,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF has ${pageCount} pages; provide "pages" to read a range.`, + error: `PDF has ${pageCount} pages; provide \"pages\" to read a range.` }; } @@ -170,7 +218,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.`, + error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.` }; } @@ -178,7 +226,7 @@ export async function handleReadTool( return { ok: false, name: "read", - error: `PDF page range exceeds total page count (${pageCount}).`, + error: `PDF page range exceeds total page count (${pageCount}).` }; } @@ -186,7 +234,7 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true, + isPartialView: true }); return { ok: true, @@ -197,8 +245,8 @@ export async function handleReadTool( encoding: "base64", bytes: buffer.length, pageCount, - pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null, - }, + pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null + } }; } @@ -208,7 +256,7 @@ export async function handleReadTool( markFileRead(context.sessionId, filePath, { content: "", timestamp: Math.floor(stat.mtimeMs), - isPartialView: true, + isPartialView: true }); return { ok: true, @@ -216,9 +264,11 @@ export async function handleReadTool( output: "File loaded.", metadata: { mime, - bytes: buffer.length, + bytes: buffer.length }, - followUpMessages: [buildImageFollowUpMessage(filePath, mime, buffer)], + followUpMessages: [ + buildImageFollowUpMessage(filePath, mime, buffer) + ] }; } @@ -228,14 +278,14 @@ export async function handleReadTool( return { ok: false, name: "read", - error: offset.error, + error: offset.error }; } if (!limit.ok) { return { ok: false, name: "read", - error: limit.error, + error: limit.error }; } @@ -244,10 +294,13 @@ export async function handleReadTool( content: textResult.content, timestamp: textResult.timestamp, offset: textResult.isPartialView ? textResult.startLine : undefined, - limit: textResult.isPartialView ? Math.max(1, textResult.endLine - textResult.startLine + 1) : undefined, + limit: + textResult.isPartialView + ? Math.max(1, textResult.endLine - textResult.startLine + 1) + : undefined, isPartialView: textResult.isPartialView, encoding: textResult.encoding, - lineEndings: textResult.lineEndings, + lineEndings: textResult.lineEndings }); const snippet = createSnippet( context.sessionId, @@ -266,21 +319,57 @@ export async function handleReadTool( id: snippet.id, filePath: snippet.filePath, startLine: snippet.startLine, - endLine: snippet.endLine, - }, + endLine: snippet.endLine + } } - : undefined, + : undefined }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "read", - error: message, + error: message }; } } +function checkSensitiveRead( + filePath: string, + projectRoot: string +): { ok: true } | { ok: false; error: string } { + if (process.env.DEEPCODE_ALLOW_SENSITIVE_READS === "true") { + return { ok: true }; + } + + const normalizedPath = path.normalize(filePath); + const basename = path.basename(normalizedPath).toLowerCase(); + const ext = path.extname(normalizedPath).toLowerCase(); + const relToProject = path.relative(projectRoot, normalizedPath).replace(/\\/g, "/").toLowerCase(); + const relToHome = path.relative(process.env.HOME || process.env.USERPROFILE || "", normalizedPath) + .replace(/\\/g, "/") + .toLowerCase(); + + const isSensitive = + SENSITIVE_FILE_BASENAMES.has(basename) || + SENSITIVE_FILE_EXTENSIONS.has(ext) || + SENSITIVE_RELATIVE_PATHS.includes(relToProject) || + SENSITIVE_RELATIVE_PATHS.includes(relToHome) || + /^\.env\./.test(basename) || + /^service[-_]?account.*\.json$/.test(basename); + + if (!isSensitive) { + return { ok: true }; + } + + return { + ok: false, + error: + `Refusing to read sensitive file "${path.basename(filePath)}" by default. ` + + "Set DEEPCODE_ALLOW_SENSITIVE_READS=true only if you explicitly need to expose this file to the model." + }; +} + function normalizeRelativeSuffix(relativePath: string): string | null { const normalized = path.normalize(relativePath).replace(/^(\.\/|\\)+/, ""); return normalized.trim() ? path.sep + normalized : null; @@ -326,7 +415,9 @@ function findSuffixMatches( return matches; } -function loadGitignoreMatcher(projectRoot: string): ((relPath: string, isDir: boolean) => boolean) | null { +function loadGitignoreMatcher( + projectRoot: string +): ((relPath: string, isDir: boolean) => boolean) | null { const gitignorePath = path.join(projectRoot, ".gitignore"); if (!fs.existsSync(gitignorePath)) { const ig = ignore(); @@ -385,7 +476,9 @@ function parseLineNumber( return { ok: true, value: integer }; } -function parseLineLimit(value: unknown): { ok: true; value: number } | { ok: false; error: string } { +function parseLineLimit( + value: unknown +): { ok: true; value: number } | { ok: false; error: string } { if (value === undefined || value === null) { return { ok: true, value: DEFAULT_LINE_LIMIT }; } @@ -413,7 +506,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView: false, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp, + timestamp: metadata.timestamp }; } @@ -428,7 +521,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView: false, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp, + timestamp: metadata.timestamp }; } @@ -447,7 +540,7 @@ function readTextFile(filePath: string, offset: number | null, limit: number): T isPartialView, encoding: metadata.encoding, lineEndings: metadata.lineEndings, - timestamp: metadata.timestamp, + timestamp: metadata.timestamp }; } @@ -462,7 +555,19 @@ function formatWithLineNumbers(lines: string[], startLineNumber: number): string } function isImageExtension(ext: string): boolean { - return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tif", ".tiff", ".svg", ".ico", ".avif"].includes(ext); + return [ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".tif", + ".tiff", + ".svg", + ".ico", + ".avif" + ].includes(ext); } function getImageMimeType(ext: string): string { @@ -491,20 +596,25 @@ function getImageMimeType(ext: string): string { } } -function buildImageFollowUpMessage(filePath: string, mime: string, buffer: Buffer): ToolExecutionFollowUpMessage { +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.", + `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")}`, - }, - }, - ], + url: `data:${mime};base64,${buffer.toString("base64")}` + } + } + ] }; } @@ -524,7 +634,7 @@ function parsePageRange(input: string): PageRange { 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".'); + throw new Error("pages must be a single range like \"1-5\" or \"3\"."); } const parts = trimmed.split("-").map((part) => part.trim()); @@ -542,7 +652,7 @@ function parsePageRange(input: string): PageRange { return { start, end, count: end - start + 1 }; } - throw new Error('pages must be a single range like "1-5" or "3".'); + throw new Error("pages must be a single range like \"1-5\" or \"3\"."); } function parsePositiveInt(value: string, label: string): number { @@ -584,7 +694,8 @@ function readNotebook(filePath: string): string { const outputs = Array.isArray(cell.outputs) ? cell.outputs : []; outputs.forEach((output, outputIndex) => { - const outputType = typeof output.output_type === "string" ? output.output_type : "output"; + const outputType = + typeof output.output_type === "string" ? output.output_type : "output"; lines.push(`# Output ${outputIndex + 1} (${outputType})`); lines.push(...formatNotebookOutput(output)); }); diff --git a/src/tools/runtime.ts b/src/tools/runtime.ts index 9a4620b..acb6bcd 100644 --- a/src/tools/runtime.ts +++ b/src/tools/runtime.ts @@ -1,35 +1,34 @@ import { z } from "zod"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; -export type ValidationResult = { ok: true; input: Record } | { ok: false; error: string }; +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); + if (value === "true") { + return true; + } + if (value === "false") { + return false; } return value; }, - z - .number() - .int() - .min(options.min ?? Number.MIN_SAFE_INTEGER, `${label} must be >= ${options.min ?? Number.MIN_SAFE_INTEGER}.`) + 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, @@ -47,7 +46,7 @@ export async function executeValidatedTool+\s*)[Nn][Uu][Ll](?=\s|$|[|&;)\n])/g; let cachedGitBashPath: string | null = null; @@ -32,7 +35,7 @@ export function findGitBashPath(): string { } throw new Error( - "Deep Code on Windows requires Git Bash. Install Git Bash for Windows and ensure bash.exe is available in PATH." + "Deep Code on Windows requires Git Bash. Install Git for Windows and ensure git.exe is available in PATH." ); } @@ -62,9 +65,15 @@ export function getShellKind(shellPath: string): ShellKind { export function buildShellInitCommand(shellPath: string): string | null { switch (getShellKind(shellPath)) { case "zsh": - return ['ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi'].join("; "); + return [ + 'ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', + 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi' + ].join("; "); case "bash": - return ['BASHRC="${BASH_ENV:-$HOME/.bashrc}"', 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi'].join("; "); + return [ + 'BASHRC="${BASH_ENV:-$HOME/.bashrc}"', + 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi' + ].join("; "); default: return null; } @@ -132,7 +141,7 @@ export function buildShellEnv(shellPath: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, SHELL: shellPath, - GIT_EDITOR: "true", + GIT_EDITOR: "true" }; if (process.platform === "win32") { @@ -151,14 +160,11 @@ function findAllWindowsExecutableCandidates(executable: string): string[] { const output = execFileSync("where.exe", [executable], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - windowsHide: true, + windowsHide: true }); return filterWindowsExecutableCandidates([ - ...output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ...extraCandidates, + ...output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean), + ...extraCandidates ]); } catch { return filterWindowsExecutableCandidates(extraCandidates); diff --git a/src/tools/state.ts b/src/tools/state.ts index 7816573..d254086 100644 --- a/src/tools/state.ts +++ b/src/tools/state.ts @@ -50,7 +50,10 @@ export function isAbsoluteFilePath(filePath: string, platform: NodeJS.Platform = } const normalized = path.win32.normalize(nativePath); - return path.win32.isAbsolute(normalized) && (/^[A-Za-z]:[\\/]/.test(normalized) || /^\\\\/.test(normalized)); + return ( + path.win32.isAbsolute(normalized) && + (/^[A-Za-z]:[\\/]/.test(normalized) || /^\\\\/.test(normalized)) + ); } function isGitBashAbsolutePath(filePath: string): boolean { @@ -71,7 +74,7 @@ export function recordFileState(sessionId: string, state: FileState): void { const normalizedPath = normalizeFilePath(state.filePath); sessionState.set(normalizedPath, { ...state, - filePath: normalizedPath, + filePath: normalizedPath }); } @@ -92,7 +95,7 @@ export function markFileRead( limit: state?.limit, isPartialView: state?.isPartialView, encoding: state?.encoding, - lineEndings: state?.lineEndings, + lineEndings: state?.lineEndings }); } @@ -110,7 +113,10 @@ export function wasFileRead(sessionId: string, filePath: string): boolean { export function isFullFileView(state: FileState | null): boolean { return Boolean( - state && !state.isPartialView && typeof state.offset === "undefined" && typeof state.limit === "undefined" + state && + !state.isPartialView && + typeof state.offset === "undefined" && + typeof state.limit === "undefined" ); } @@ -133,7 +139,7 @@ export function createSnippet( filePath: normalizeFilePath(filePath), startLine, endLine, - preview, + preview }; let snippets = snippetsBySession.get(sessionId); diff --git a/src/tools/web-search-handler.ts b/src/tools/web-search-handler.ts index 558271b..1389699 100644 --- a/src/tools/web-search-handler.ts +++ b/src/tools/web-search-handler.ts @@ -39,7 +39,7 @@ export async function handleWebSearchTool( return { ok: false, name: "WebSearch", - error: 'Missing required "query" string.', + error: "Missing required \"query\" string." }; } @@ -53,7 +53,8 @@ export async function handleWebSearchTool( return { ok: false, name: "WebSearch", - error: "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json.", + error: + "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json." }; } @@ -83,8 +84,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, stderr: execution.stderr || undefined, - truncated, - }, + truncated + } }; } @@ -98,8 +99,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, stderr: execution.stderr || undefined, - truncated, - }, + truncated + } }; } @@ -111,8 +112,8 @@ async function executeConfiguredWebSearch( exitCode: execution.exitCode, signal: execution.signal, truncated, - stderr: execution.stderr || undefined, - }, + stderr: execution.stderr || undefined + } }; } @@ -123,7 +124,11 @@ async function executeDefaultWebSearch( ): Promise { try { const prepared = await prepareSearchQuery(query, llmContext); - const output = await runDefaultWebSearchRequest(prepared.resolvedQuery, llmContext.machineId, context); + const output = await runDefaultWebSearchRequest( + prepared.resolvedQuery, + llmContext.machineId, + context + ); return { ok: true, @@ -134,15 +139,15 @@ async function executeDefaultWebSearch( resolvedQuery: prepared.resolvedQuery, translated: prepared.translated, dominantLanguage: prepared.decision.dominantLanguage, - languageReason: prepared.decision.reason, - }, + 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}`, + error: `WebSearch default mode failed: ${message}` }; } } @@ -156,7 +161,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 +192,7 @@ async function runWebSearchScript( stderr, exitCode: typeof code === "number" ? code : null, signal: signal ?? null, - error, + error }); }); }); @@ -203,7 +208,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: translatedQuery, decision, - translated: true, + translated: true }; } } @@ -214,7 +219,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: translatedQuery, decision, - translated: true, + translated: true }; } } @@ -222,7 +227,7 @@ async function prepareSearchQuery(query: string, llmContext: LLMClientContext): return { resolvedQuery: query, decision, - translated: false, + translated: false }; } @@ -230,7 +235,10 @@ function containsChineseChar(text: string): boolean { return /[\u4e00-\u9fff]/.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 +259,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 +279,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; @@ -331,14 +337,16 @@ async function runDefaultWebSearchRequest( method: "POST", headers: { "Content-Type": "application/json", - Token: machineId, + Token: machineId }, - body: JSON.stringify({ query }), + 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}` : ""}`); + throw new Error( + `WebSearch API request failed with status ${response.status}${body ? `: ${body}` : ""}` + ); } const payload = (await response.json()) as { @@ -369,7 +377,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 index 4524e21..7b6e6c6 100644 --- a/src/tools/write-handler.ts +++ b/src/tools/write-handler.ts @@ -7,19 +7,27 @@ import { hasFileChangedSinceState, normalizeContent, readTextFileWithMetadata, - writeTextFile, + writeTextFile } from "./file-utils"; import { executeValidatedTool } from "./runtime"; -import { getFileState, isAbsoluteFilePath, isFullFileView, normalizeFilePath, recordFileState } from "./state"; +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.", - }), + "content must be a string. If you are writing JSON, serialize the full document to text before calling write." + }) }); +type WriteInput = z.infer; + type WriteRepairMetadata = { input_repaired: boolean; repair_kind: "json-stringify-content"; @@ -42,7 +50,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "file_path must be an absolute path.", + error: "file_path must be an absolute path." }; } @@ -56,7 +64,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: `Failed to stat file: ${message}`, + error: `Failed to stat file: ${message}` }; } @@ -64,7 +72,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "file_path points to a directory.", + error: "file_path points to a directory." }; } @@ -74,7 +82,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "Must read the full existing file before writing.", + error: "Must read the full existing file before writing." }; } @@ -82,7 +90,7 @@ export async function handleWriteTool( return { ok: false, name: "write", - error: "File has been modified since read. Read it again before writing.", + error: "File has been modified since read. Read it again before writing." }; } } @@ -95,8 +103,14 @@ export async function handleWriteTool( 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 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); @@ -105,7 +119,7 @@ export async function handleWriteTool( content: freshMetadata.content, timestamp: freshMetadata.timestamp, encoding: freshMetadata.encoding, - lineEndings: freshMetadata.lineEndings, + lineEndings: freshMetadata.lineEndings }); return { @@ -120,21 +134,22 @@ export async function handleWriteTool( line_endings: freshMetadata.lineEndings, cache_refreshed: true, diff_preview: diffPreview, - ...repairMetadata, - }, + ...repairMetadata + } }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { ok: false, name: "write", - error: message, + error: message }; } }, { preprocess: (rawInput) => { - const filePath = typeof rawInput.file_path === "string" ? normalizeFilePath(rawInput.file_path) : ""; + const filePath = + typeof rawInput.file_path === "string" ? normalizeFilePath(rawInput.file_path) : ""; const content = rawInput.content; if ( filePath.toLowerCase().endsWith(".json") && @@ -144,7 +159,7 @@ export async function handleWriteTool( ) { repairMetadata = { input_repaired: true, - repair_kind: "json-stringify-content", + repair_kind: "json-stringify-content" }; return { @@ -152,17 +167,19 @@ export async function handleWriteTool( input: { ...rawInput, file_path: filePath, - content: JSON.stringify(content, null, 2), - }, + content: JSON.stringify(content, null, 2) + } }; } repairMetadata = null; return { ok: true, - input: typeof rawInput.file_path === "string" ? { ...rawInput, file_path: filePath } : rawInput, + 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..15a70db 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -12,14 +12,9 @@ import { type SessionMessage, type SessionStatus, type SkillInfo, - type UserPromptContent, + type UserPromptContent } from "../session"; -import { - applyModelConfigSelection, - resolveSettings, - type DeepcodingSettings, - type ModelConfigSelection, -} from "../settings"; +import { resolveSettings, type DeepcodingSettings, type ReasoningEffort } from "../settings"; import { PromptInput, type PromptSubmission } from "./PromptInput"; import { MessageView } from "./MessageView"; import { SessionList } from "./SessionList"; @@ -30,7 +25,7 @@ import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { findPendingAskUserQuestion, formatAskUserQuestionAnswers, - type AskUserQuestionAnswers, + type AskUserQuestionAnswers } from "./askUserQuestion"; import { buildExitSummaryText } from "./exitSummary"; @@ -63,7 +58,6 @@ 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 messagesRef = useRef([]); @@ -89,7 +83,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return; } setStreamProgress(progress); - }, + } }); }, [projectRoot]); @@ -101,30 +95,28 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return () => clearInterval(id); }, [busy]); + useEffect(() => { + refreshSessionsList(); + void refreshSkills(); + // 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] - ); + } - useEffect(() => { - refreshSessionsList(); - void refreshSkills(); - }, [refreshSessionsList, refreshSkills]); + async function refreshSkills(sessionId?: string): Promise { + try { + const list = await sessionManager.listSkills(sessionId ?? sessionManager.getActiveSessionId() ?? undefined); + setSkills(list); + } catch { + // ignore + } + } const writeRef = useRef(write); writeRef.current = write; @@ -178,19 +170,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); @@ -209,28 +204,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 +239,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 +247,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 +255,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 +292,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R (answers: AskUserQuestionAnswers) => { void handlePrompt({ text: formatAskUserQuestionAnswers(answers), - imageUrls: [], + imageUrls: [] }); }, [handlePrompt] @@ -352,7 +306,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R }, [pendingQuestion]); return ( - + {(item) => { if (item.id.startsWith("__welcome__")) { @@ -367,7 +321,13 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R /> ); } - return ; + return ( + + ); }} {statusLine ? ( @@ -396,14 +356,12 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R )} @@ -431,14 +389,14 @@ 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 }; } @@ -456,7 +414,7 @@ function buildStatusLine(entry: SessionEntry): string { 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 +425,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 +437,13 @@ export function createOpenAIClient(): { model: string; baseURL: string; thinkingEnabled: boolean; - reasoningEffort: "high" | "max"; + reasoningEffort: ReasoningEffort; debugLogEnabled: boolean; notify?: string; webSearchTool?: string; machineId?: string; + provider?: string; + zdr?: boolean; } { const settings = resolveCurrentSettings(); if (!settings.apiKey) { @@ -515,12 +457,18 @@ export function createOpenAIClient(): { notify: settings.notify, webSearchTool: settings.webSearchTool, machineId: getMachineId(), + provider: settings.provider, + zdr: settings.zdr }; } 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 +480,8 @@ export function createOpenAIClient(): { notify: settings.notify, webSearchTool: settings.webSearchTool, machineId: getMachineId(), + provider: settings.provider, + zdr: settings.zdr }; } @@ -552,18 +502,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..77d1ee9 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,16 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | return ( - {content ? {renderMarkdown(content)} : null} + + {content ? {renderMarkdown(content)} : null} + ); } return ( - - - + {content ? {renderMarkdown(content)} : null} @@ -91,9 +89,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | if (message.meta?.isSummary) { return ( - - (conversation summary inserted) - + (conversation summary inserted) ); } @@ -106,7 +102,7 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | function StatusLine({ bulletColor, name, - params, + params }: { bulletColor: "gray" | "green" | "red"; name: string; @@ -115,14 +111,10 @@ function StatusLine({ return ( {[ - - ✧ - , + , " ", - - {name} - , - params ? {` ${params}`} : null, + {name}, + params ? {` ${params}`} : null ]} ); @@ -153,16 +145,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 +213,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 +225,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 }; @@ -268,7 +257,7 @@ export function parseDiffPreview(diffPreview: string): DiffPreviewLine[] { return { marker: " ", content: line.startsWith(" ") ? line.slice(1) : line, - kind: "context", + kind: "context" }; }); } diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 74c38d5..0c68d38 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 @@ -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 index f2b9e21..93bd62b 100644 --- a/src/ui/UpdatePrompt.tsx +++ b/src/ui/UpdatePrompt.tsx @@ -15,22 +15,27 @@ type Props = { onSelect: (choice: UpdatePromptChoice) => void; }; -export function UpdatePrompt({ currentVersion, latestVersion, installCommand, onSelect }: Props): React.ReactElement { +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}\``, + label: `Install the latest version with \`${installCommand}\`` }, { value: "ignore-once", - label: "Ignore once", + label: "Ignore once" }, { value: "ignore-version", - label: `Ignore this version (${latestVersion})`, - }, + label: `Ignore this version (${latestVersion})` + } ]; useInput((input, key) => { diff --git a/src/ui/WelcomeScreen.tsx b/src/ui/WelcomeScreen.tsx index 5e25379..e481592 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} + + + {">"}_ Deep Code + (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..b290950 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,14 +12,10 @@ 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"; @@ -40,7 +29,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 +49,10 @@ export { moveLineEnd, killLine, deleteWordBefore, - deleteWordAfter, reset, isEmpty, getCurrentSlashToken, - type PromptBufferState, + type PromptBufferState } from "./promptBuffer"; export { BUILTIN_SLASH_COMMANDS, @@ -74,7 +62,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 index 626e529..5613f51 100644 --- a/src/updateCheck.ts +++ b/src/updateCheck.ts @@ -49,16 +49,14 @@ export async function promptForPendingUpdate(packageInfo: PackageInfo): Promise< const choice = await promptUpdateChoice({ currentVersion: packageInfo.version, latestVersion: pending.latestVersion, - installCommand, + 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` - ); + 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 }; } @@ -97,8 +95,8 @@ export async function checkForNpmUpdate(packageInfo: PackageInfo): Promise currentVersion: packageInfo.version, latestVersion, packageName: packageInfo.name, - checkedAt: new Date().toISOString(), - }, + checkedAt: new Date().toISOString() + } }); } catch { // Update checks must never affect CLI startup or normal operation. @@ -129,7 +127,7 @@ export function getUpdateStatePath(): string { async function promptUpdateChoice({ currentVersion, latestVersion, - installCommand, + installCommand }: { currentVersion: string; latestVersion: string; @@ -152,7 +150,7 @@ async function promptUpdateChoice({ currentVersion, latestVersion, installCommand, - onSelect: handleSelect, + onSelect: handleSelect }), { exitOnCtrlC: false } ); @@ -161,9 +159,8 @@ async function promptUpdateChoice({ async function runNpmInstallGlobal(installSpec: string): Promise { return new Promise((resolve) => { - const child = spawn("npm", ["install", "-g", installSpec], { - stdio: "inherit", - shell: process.platform === "win32", + const child = spawn(resolveNpmExecutable(), ["install", "-g", installSpec], { + stdio: "inherit" }); child.on("error", (error) => { process.stderr.write(`Failed to start npm install: ${error.message}\n`); @@ -205,9 +202,8 @@ function runNpmViewLatestVersion( if (registry) { args.push("--registry", registry); } - const child = spawn("npm", args, { - stdio: ["ignore", "pipe", "pipe"], - shell: process.platform === "win32", + const child = spawn(resolveNpmExecutable(), args, { + stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; @@ -241,6 +237,10 @@ function runNpmViewLatestVersion( }); } +function resolveNpmExecutable(): string { + return process.platform === "win32" ? "npm.cmd" : "npm"; +} + export function parseNpmViewVersion(output: string): string | null { const trimmed = output.trim(); if (!trimmed) { @@ -264,10 +264,8 @@ function readUpdateState(): 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 - ) - : [], + ? parsed.ignoredVersions.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : [] }; } catch { return {}; From b919ebeb7cd977158395a05668c94a882f0a9648 Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Tue, 12 May 2026 22:50:58 +0300 Subject: [PATCH 02/10] cleanup --- README_cn.md | 115 ------------------------------------------- README_en.md | 114 ------------------------------------------ resources/intro1.png | Bin 50698 -> 0 bytes resources/intro2.png | Bin 53791 -> 0 bytes 4 files changed, 229 deletions(-) delete mode 100644 README_cn.md delete mode 100644 README_en.md delete mode 100644 resources/intro1.png delete mode 100644 resources/intro2.png 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/resources/intro1.png b/resources/intro1.png deleted file mode 100644 index 45bb14d983a49690fefb74a7118960ac79545a7b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50698 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 diff --git a/resources/intro2.png b/resources/intro2.png deleted file mode 100644 index 942556f4d16ec4c14b0a96ea70fa9d1b97f07b99..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53791 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 From 3a05c038a2a9428a4e33497205d7ebb5cf83ee55 Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Tue, 12 May 2026 23:45:33 +0300 Subject: [PATCH 03/10] test 1 --- src/privacy-guard.ts | 58 +++++++++++++++++++------ src/prompt.ts | 8 ++-- src/session.ts | 19 ++++---- src/tests/privacy-guard.test.ts | 41 +++++++++++++----- src/tests/tool-handlers.test.ts | 30 ++----------- src/tools/executor.ts | 29 ++++++------- src/tools/read-handler.ts | 77 --------------------------------- 7 files changed, 101 insertions(+), 161 deletions(-) diff --git a/src/privacy-guard.ts b/src/privacy-guard.ts index 178dc48..6a765bb 100644 --- a/src/privacy-guard.ts +++ b/src/privacy-guard.ts @@ -11,12 +11,14 @@ 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 HIGH_ENTROPY_TOKEN_PATTERN = /(? = streamRequest; try { - assertNoHighRiskSecretsForModel(streamRequest); outboundRequest = sanitizeForModelPipeline(streamRequest).value as Record; this.logFinalHttpBody(requestId, sessionId, outboundRequest); response = await (client.chat.completions.create as unknown as ( @@ -1697,14 +1695,13 @@ ${skillMd} } let waitingForUser = false; const followUpMessages: SessionMessage[] = []; - let blockedSensitiveOutput = false; + let redactedSensitiveOutput = false; for (const execution of toolExecutions) { if (execution.result.awaitUserResponse === true) { waitingForUser = true; } - if (execution.blockedSensitiveOutput === true) { - waitingForUser = true; - blockedSensitiveOutput = true; + if (execution.redactedSensitiveOutput === true) { + redactedSensitiveOutput = true; } const toolFunction = this.findSanitizedToolFunction(toolCalls, execution.toolCallId); const toolMessage = this.buildToolMessage( @@ -1733,14 +1730,14 @@ ${skillMd} for (const followUpMessage of followUpMessages) { this.appendSessionMessage(sessionId, followUpMessage); } - if (blockedSensitiveOutput) { - const blockedMessage = this.buildAssistantMessage( + if (redactedSensitiveOutput) { + const warningMessage = this.buildAssistantMessage( sessionId, - "yo thats secret material, im not reading that shit. I blocked it before it could be sent to the model.", + "Sensitive-looking material was found and redacted before model replay. Continuing with the sanitized output.", null ); - this.appendSessionMessage(sessionId, blockedMessage); - this.onAssistantMessage(blockedMessage, true); + this.appendSessionMessage(sessionId, warningMessage); + this.onAssistantMessage(warningMessage, true); } return { waitingForUser }; } diff --git a/src/tests/privacy-guard.test.ts b/src/tests/privacy-guard.test.ts index 42c3477..957d740 100644 --- a/src/tests/privacy-guard.test.ts +++ b/src/tests/privacy-guard.test.ts @@ -6,7 +6,7 @@ import { sanitizeToolCallsForReplay } from "../privacy-guard"; -test("sanitizeForModelPipeline redacts tool output and marks high-risk secrets as blocked", () => { +test("sanitizeForModelPipeline redacts tool output and marks high-risk secrets", () => { const result = sanitizeForModelPipeline({ output: "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature " + @@ -17,7 +17,7 @@ test("sanitizeForModelPipeline redacts tool output and marks high-risk secrets a } }); - assert.equal(result.blocked, true); + 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\]/); @@ -32,7 +32,7 @@ test("sanitizeForModelPipeline redacts private keys before model replay", () => output: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----" }); - assert.equal(result.blocked, true); + assert.equal(result.redactedSensitiveContent, true); assert.deepEqual(result.value, { output: "[REDACTED_PRIVATE_KEY]" }); }); @@ -60,8 +60,8 @@ test("sanitizeToolCallsForReplay redacts function arguments", () => { ]); }); -test("assertNoHighRiskSecretsForModel blocks outbound model requests", () => { - assert.throws( +test("assertNoHighRiskSecretsForModel does not block outbound model requests", () => { + assert.doesNotThrow( () => assertNoHighRiskSecretsForModel({ messages: [ @@ -71,11 +71,10 @@ test("assertNoHighRiskSecretsForModel blocks outbound model requests", () => { } ] }), - /high-risk secret/ ); }); -test("assertNoHighRiskSecretsForModel does not hard-block generic test passwords", () => { +test("assertNoHighRiskSecretsForModel allows generic test passwords", () => { assert.doesNotThrow(() => assertNoHighRiskSecretsForModel({ messages: [ @@ -88,38 +87,56 @@ test("assertNoHighRiskSecretsForModel does not hard-block generic test passwords ); }); -test("sanitizeForModelPipeline redacts generic password assignments without hard-blocking", () => { +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.blocked, false); + assert.equal(result.redactedSensitiveContent, false); assert.deepEqual(result.value, { output: "password:[REDACTED_SECRET]", userContent: "im giving test password [REDACTED_SECRET] note it" }); }); -test("sanitizeForModelPipeline blocks and redacts unknown high-entropy tokens", () => { +test("sanitizeForModelPipeline redacts unknown high-entropy tokens without blocking", () => { const token = "A7fK9pQ2rT6vX1mN8bC4dE5gH3jL0sZyWqR"; const result = sanitizeForModelPipeline({ output: `session token ${token}` }); - assert.equal(result.blocked, true); + 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.blocked, false); + assert.equal(result.redactedSensitiveContent, false); assert.deepEqual(result.value, { output: `placeholder ${placeholder}` }); diff --git a/src/tests/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts index 71219f4..8f24954 100644 --- a/src/tests/tool-handlers.test.ts +++ b/src/tests/tool-handlers.test.ts @@ -63,7 +63,7 @@ test("Read returns snippet metadata and Edit can scope replacements by snippet_i ); }); -test("Read refuses obvious secret-bearing files by default", async () => { +test("Read allows obvious secret-bearing files", async () => { const workspace = createTempWorkspace(); const filePath = path.join(workspace, ".env"); fs.writeFileSync(filePath, "JWT_PRIVATE_KEY=secret\n", "utf8"); @@ -73,32 +73,8 @@ test("Read refuses obvious secret-bearing files by default", async () => { createContext("sensitive-read", workspace) ); - assert.equal(readResult.ok, false); - assert.match(readResult.error ?? "", /Refusing to read sensitive file/); -}); - -test("Read can explicitly allow sensitive files via environment override", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, ".env"); - fs.writeFileSync(filePath, "JWT_PRIVATE_KEY=secret\n", "utf8"); - const original = process.env.DEEPCODE_ALLOW_SENSITIVE_READS; - process.env.DEEPCODE_ALLOW_SENSITIVE_READS = "true"; - - try { - const readResult = await handleReadTool( - { file_path: filePath }, - createContext("sensitive-read-override", workspace) - ); - - assert.equal(readResult.ok, true); - assert.match(readResult.output ?? "", /JWT_PRIVATE_KEY=secret/); - } finally { - if (original === undefined) { - delete process.env.DEEPCODE_ALLOW_SENSITIVE_READS; - } else { - process.env.DEEPCODE_ALLOW_SENSITIVE_READS = original; - } - } + assert.equal(readResult.ok, true); + assert.match(readResult.output ?? "", /JWT_PRIVATE_KEY=secret/); }); test("Edit returns candidate match snippets when old_string is not unique", async () => { diff --git a/src/tools/executor.ts b/src/tools/executor.ts index a2bc572..013e236 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -71,12 +71,12 @@ export type ToolCallExecution = { toolCallId: string; content: string; result: ToolExecutionResult; - blockedSensitiveOutput?: boolean; + redactedSensitiveOutput?: boolean; }; type FormattedToolResult = { content: string; - blockedSensitiveOutput: boolean; + redactedSensitiveOutput: boolean; }; export class ToolExecutor { @@ -110,7 +110,7 @@ export class ToolExecutor { toolCallId: toolCall.id, content: formattedResult.content, result, - blockedSensitiveOutput: formattedResult.blockedSensitiveOutput + redactedSensitiveOutput: formattedResult.redactedSensitiveOutput }); if (hooks?.shouldStop?.()) { break; @@ -255,22 +255,19 @@ export class ToolExecutor { } const sanitized = sanitizeForModelPipeline(payload); - if (sanitized.blocked) { - return { - content: JSON.stringify({ - ok: false, - name: result.name, - blockedSensitiveOutput: true, - error: "Tool output contained high-risk secret material and was redacted before model replay.", - awaitUserResponse: true - }, null, 2), - blockedSensitiveOutput: true - }; + const sanitizedPayload: Record = + sanitized.value && typeof sanitized.value === "object" && !Array.isArray(sanitized.value) + ? { ...(sanitized.value as Record) } + : { value: sanitized.value }; + + if (sanitized.redactedSensitiveContent) { + sanitizedPayload.privacyWarning = + "Sensitive-looking material was redacted before model replay. Continue using the sanitized output."; } return { - content: JSON.stringify(sanitized.value, null, 2), - blockedSensitiveOutput: false + content: JSON.stringify(sanitizedPayload, null, 2), + redactedSensitiveOutput: sanitized.redactedSensitiveContent }; } diff --git a/src/tools/read-handler.ts b/src/tools/read-handler.ts index f076108..1fec586 100644 --- a/src/tools/read-handler.ts +++ b/src/tools/read-handler.ts @@ -38,38 +38,6 @@ const DEFAULT_GITIGNORE = [ "*.war", "target/" ]; -const SENSITIVE_FILE_BASENAMES = new Set([ - ".env", - ".env.local", - ".env.development", - ".env.production", - ".npmrc", - ".pypirc", - ".netrc", - "credentials.json", - "secrets.json", - "kubeconfig", - "id_rsa", - "id_dsa", - "id_ecdsa", - "id_ed25519" -]); -const SENSITIVE_FILE_EXTENSIONS = new Set([ - ".key", - ".pem", - ".p12", - ".pfx", - ".jks", - ".keystore" -]); -const SENSITIVE_RELATIVE_PATHS = [ - ".aws/credentials", - ".azure/accessTokens.json", - ".docker/config.json", - ".kube/config", - "google_application_credentials.json" -]; - type PageRange = { start: number; end: number; @@ -155,15 +123,6 @@ export async function handleReadTool( }; } - const sensitiveCheck = checkSensitiveRead(filePath, context.projectRoot); - if (!sensitiveCheck.ok) { - return { - ok: false, - name: "read", - error: sensitiveCheck.error - }; - } - let stat: fs.Stats; try { stat = fs.statSync(filePath); @@ -334,42 +293,6 @@ export async function handleReadTool( } } -function checkSensitiveRead( - filePath: string, - projectRoot: string -): { ok: true } | { ok: false; error: string } { - if (process.env.DEEPCODE_ALLOW_SENSITIVE_READS === "true") { - return { ok: true }; - } - - const normalizedPath = path.normalize(filePath); - const basename = path.basename(normalizedPath).toLowerCase(); - const ext = path.extname(normalizedPath).toLowerCase(); - const relToProject = path.relative(projectRoot, normalizedPath).replace(/\\/g, "/").toLowerCase(); - const relToHome = path.relative(process.env.HOME || process.env.USERPROFILE || "", normalizedPath) - .replace(/\\/g, "/") - .toLowerCase(); - - const isSensitive = - SENSITIVE_FILE_BASENAMES.has(basename) || - SENSITIVE_FILE_EXTENSIONS.has(ext) || - SENSITIVE_RELATIVE_PATHS.includes(relToProject) || - SENSITIVE_RELATIVE_PATHS.includes(relToHome) || - /^\.env\./.test(basename) || - /^service[-_]?account.*\.json$/.test(basename); - - if (!isSensitive) { - return { ok: true }; - } - - return { - ok: false, - error: - `Refusing to read sensitive file "${path.basename(filePath)}" by default. ` + - "Set DEEPCODE_ALLOW_SENSITIVE_READS=true only if you explicitly need to expose this file to the model." - }; -} - function normalizeRelativeSuffix(relativePath: string): string | null { const normalized = path.normalize(relativePath).replace(/^(\.\/|\\)+/, ""); return normalized.trim() ? path.sep + normalized : null; From 89875860fb7b98ff6115bd91f4459ea50d8943ce Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Wed, 13 May 2026 02:07:12 +0300 Subject: [PATCH 04/10] nothing --- src/prompt.ts | 13 ++++++++++--- src/session.ts | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/prompt.ts b/src/prompt.ts index eddd5cb..67a944a 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -318,7 +318,14 @@ export function getCompactPrompt(sessionMessages: SessionMessage[]): string { 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 shellModeOpts = process.platform === "win32" ? { "shell mode": "git-bash" } : {}; const runtimeVersions = getRuntimeVersionInfo(); @@ -334,9 +341,9 @@ function getRuntimeContext(projectRoot: string): string { "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 { diff --git a/src/session.ts b/src/session.ts index 3ebe6b9..11cf8d0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1632,7 +1632,7 @@ ${skillMd} const messageParams: { tool_calls?: unknown[]; reasoning_content?: string } | null = toolCalls || hasReasoningContent ? {} : null; if (toolCalls) { - messageParams!.tool_calls = sanitizeToolCallsForReplay(toolCalls) ?? []; + messageParams!.tool_calls = sanitizeForModelPipeline(toolCalls).value as unknown[]; } if (hasReasoningContent) { messageParams!.reasoning_content = reasoningContent; From 5e63b2305645c8695d165389302fe398fb88c8bf Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Wed, 13 May 2026 02:18:02 +0300 Subject: [PATCH 05/10] test 2 --- src/session.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/session.ts b/src/session.ts index 11cf8d0..dca1d33 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1632,7 +1632,7 @@ ${skillMd} const messageParams: { tool_calls?: unknown[]; reasoning_content?: string } | null = toolCalls || hasReasoningContent ? {} : null; if (toolCalls) { - messageParams!.tool_calls = sanitizeForModelPipeline(toolCalls).value as unknown[]; + messageParams!.tool_calls = toolCalls; } if (hasReasoningContent) { messageParams!.reasoning_content = reasoningContent; From e0efa046563aee75af34723e1e7965b25179a0e8 Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Wed, 13 May 2026 20:59:34 +0300 Subject: [PATCH 06/10] fixed critical error that doesnt let the model cache properly from the tools --- package-lock.json | 10 + package.json | 2 + scripts/inspect-final-http-body.mjs | 101 +++++++++ src/privacy-guard.ts | 98 +++++++++ src/session.ts | 147 +++++++++---- src/settings.ts | 13 ++ src/tests/privacy-guard.test.ts | 39 ++++ src/tests/session.test.ts | 284 ++++++++++++++++++++++++-- src/tests/settings-and-notify.test.ts | 32 +++ src/tools/executor.ts | 34 +-- src/ui/App.tsx | 10 +- 11 files changed, 688 insertions(+), 82 deletions(-) create mode 100644 scripts/inspect-final-http-body.mjs diff --git a/package-lock.json b/package-lock.json index 93c2204..9a263ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "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", @@ -489,6 +490,15 @@ "node": ">=18" } }, + "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", + "funding": { + "url": "https://ko-fi.com/hackylabs" + } + }, "node_modules/@types/gradient-string": { "version": "1.1.6", "resolved": "https://registry.npmmirror.com/@types/gradient-string/-/gradient-string-1.1.6.tgz", diff --git a/package.json b/package.json index ff1ab01..c886a33 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,13 @@ "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", "build": "npm run typecheck && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", + "inspect:cache-log": "node scripts/inspect-final-http-body.mjs", "test": "tsx --test src/tests/*.test.ts", "test:single": "tsx --test", "prepack": "npm run build" }, "dependencies": { + "@hackylabs/deep-redact": "^3.0.5", "chalk": "^5.6.2", "ejs": "^5.0.2", "gradient-string": "^3.0.0", 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/privacy-guard.ts b/src/privacy-guard.ts index 6a765bb..ca7469f 100644 --- a/src/privacy-guard.ts +++ b/src/privacy-guard.ts @@ -1,3 +1,5 @@ +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; @@ -9,12 +11,45 @@ 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; @@ -46,6 +81,18 @@ export function sanitizeForModelPipeline(value: unknown): PipelineSanitizationRe }; } +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; @@ -110,6 +157,47 @@ function redactString(value: string): string { .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; @@ -221,7 +309,17 @@ function resetPatterns(): void { 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/session.ts b/src/session.ts index dca1d33..c8fe059 100644 --- a/src/session.ts +++ b/src/session.ts @@ -13,17 +13,14 @@ import { getCompactPrompt, getSystemPrompt, getTools, AGENT_DRIFT_GUARD_SKILL } import { ToolExecutor, type CreateOpenAIClient } from "./tools/executor"; import { logApiError } from "./error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; -import { - sanitizeForModelPipeline, - sanitizeToolCallsForReplay -} from "./privacy-guard"; +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 FINAL_HTTP_BODY_LOG_PATH = path.join(os.homedir(), ".deepcode", "logs", "final-http-body.jsonl"); const require = createRequire(import.meta.url); const ejs = require("ejs") as { render: (template: string, data?: Record) => string; @@ -99,6 +96,17 @@ function getTotalTokens(usage: unknown | null | undefined): number { return typeof totalTokens === "number" ? totalTokens : 0; } +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"); + } +} + export type SessionStatus = | "failed" | "pending" @@ -278,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; @@ -300,9 +309,20 @@ export class SessionManager { let response: unknown; let outboundRequest: Record = streamRequest; + let providerRedactedSensitiveContent = false; try { - outboundRequest = sanitizeForModelPipeline(streamRequest).value as Record; - this.logFinalHttpBody(requestId, sessionId, outboundRequest); + 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 @@ -364,6 +384,7 @@ export class SessionManager { type?: string; function?: { name?: string; arguments?: string }; }>(); + let lastToolCallIndex: number | null = null; const trackText = (value: unknown) => { if (typeof value !== "string" || value.length === 0) { @@ -373,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) { @@ -408,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; @@ -516,21 +575,26 @@ export class SessionManager { private logFinalHttpBody( requestId: string, sessionId: string | undefined, - body: Record + body: Record, + providerPrivacyMode: ProviderPrivacyMode, + providerRedactedSensitiveContent: boolean ): void { if (process.env[FINAL_HTTP_BODY_LOG_ENV] !== "true") { return; } try { - fs.mkdirSync(path.dirname(FINAL_HTTP_BODY_LOG_PATH), { recursive: true }); + const logPath = path.join(os.homedir(), ".deepcode", "logs", "final-http-body.jsonl"); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); fs.appendFileSync( - FINAL_HTTP_BODY_LOG_PATH, + logPath, JSON.stringify({ timestamp: new Date().toISOString(), requestId, sessionId, boundary: "before client.chat.completions.create", + providerPrivacyMode, + providerRedactedSensitiveContent, body }) + "\n", "utf8" @@ -563,7 +627,7 @@ The candidate skills are as follows:\n\n`; } 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 []; } @@ -581,7 +645,7 @@ The candidate skills are as follows:\n\n`; location: "SessionManager.identifyMatchingSkillNames", baseURL, params: { purpose: "skill-matching" } - }); + }, providerPrivacyMode); this.throwIfAborted(options?.signal); const rawContent = response.choices?.[0]?.message?.content; @@ -971,7 +1035,18 @@ ${skillMd} async activateSession(sessionId: string, controller?: AbortController): Promise { const startedAt = Date.now(); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, notify, provider, zdr } = this.createOpenAIClient(); + const { + client, + model, + baseURL, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, + notify, + provider, + providerPrivacyMode, + zdr + } = this.createOpenAIClient(); const now = new Date().toISOString(); if (!client) { @@ -1039,6 +1114,9 @@ ${skillMd} model, messages, tools: getTools(this.getPromptToolOptions()), + ...(isOpenRouterBaseURL(baseURL) + ? { cache_control: { type: "ephemeral", ttl: "1h" } } + : {}), ...thinkingOptions }, { signal: sessionController.signal }, @@ -1048,7 +1126,8 @@ ${skillMd} location: "SessionManager.activateSession", baseURL, params: { iteration, thinkingEnabled, reasoningEffort } - } + }, + providerPrivacyMode ); const message = response.choices?.[0]?.message; @@ -1146,7 +1225,17 @@ ${skillMd} async compactSession(sessionId: string, signal?: AbortSignal): Promise { this.throwIfAborted(signal); - const { client, model, baseURL, thinkingEnabled, reasoningEffort, debugLogEnabled, provider, zdr } = this.createOpenAIClient(); + const { + client, + model, + baseURL, + thinkingEnabled, + reasoningEffort, + debugLogEnabled, + provider, + providerPrivacyMode, + zdr + } = this.createOpenAIClient(); if (!client) { return; } @@ -1185,7 +1274,7 @@ ${skillMd} location: "SessionManager.compactSession", baseURL, params: { thinkingEnabled, reasoningEffort } - }); + }, providerPrivacyMode); this.throwIfAborted(signal); const rawLlmResponse = response.choices?.[0]?.message?.content; const llmResponse = typeof rawLlmResponse === "string" ? rawLlmResponse : ""; @@ -1695,15 +1784,11 @@ ${skillMd} } let waitingForUser = false; const followUpMessages: SessionMessage[] = []; - let redactedSensitiveOutput = false; for (const execution of toolExecutions) { if (execution.result.awaitUserResponse === true) { waitingForUser = true; } - if (execution.redactedSensitiveOutput === true) { - redactedSensitiveOutput = true; - } - const toolFunction = this.findSanitizedToolFunction(toolCalls, execution.toolCallId); + const toolFunction = this.findToolFunction(toolCalls, execution.toolCallId); const toolMessage = this.buildToolMessage( sessionId, execution.toolCallId, @@ -1730,15 +1815,6 @@ ${skillMd} for (const followUpMessage of followUpMessages) { this.appendSessionMessage(sessionId, followUpMessage); } - if (redactedSensitiveOutput) { - const warningMessage = this.buildAssistantMessage( - sessionId, - "Sensitive-looking material was found and redacted before model replay. Continuing with the sanitized output.", - null - ); - this.appendSessionMessage(sessionId, warningMessage); - this.onAssistantMessage(warningMessage, true); - } return { waitingForUser }; } @@ -1951,11 +2027,6 @@ ${skillMd} return null; } - private findSanitizedToolFunction(toolCalls: unknown[], toolCallId: string): unknown | null { - const sanitizedToolCalls = sanitizeToolCallsForReplay(toolCalls); - return sanitizedToolCalls ? this.findToolFunction(sanitizedToolCalls, toolCallId) : null; - } - private buildToolParamsSnippet(toolFunction: unknown | null): string { if (!toolFunction || typeof toolFunction !== "object") { return ""; diff --git a/src/settings.ts b/src/settings.ts index 213ace0..5c96b91 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -6,10 +6,13 @@ export type DeepcodingEnv = { API_KEY?: string; THINKING?: string; PROVIDER?: string; + PROVIDER_PRIVACY?: string; + providerPrivacyMode?: string; ZDR?: string; }; export type ReasoningEffort = "xhigh" | "high" | "medium" | "low" | "minimal" | "none"; +export type ProviderPrivacyMode = "off" | "strict"; export type DeepcodingSettings = { env?: DeepcodingEnv; @@ -18,6 +21,7 @@ export type DeepcodingSettings = { debugLogEnabled?: boolean; notify?: string; webSearchTool?: string; + providerPrivacyMode?: ProviderPrivacyMode; zdr?: boolean; }; @@ -31,6 +35,7 @@ export type ResolvedDeepcodingSettings = { notify?: string; webSearchTool?: string; provider?: string; + providerPrivacyMode: ProviderPrivacyMode; zdr?: boolean; }; @@ -58,6 +63,10 @@ function resolveThinkingEnabled( return defaultsToThinkingMode(model); } +function resolveProviderPrivacyMode(value: unknown): ProviderPrivacyMode { + return value === "strict" ? "strict" : "off"; +} + export function resolveSettings( settings: DeepcodingSettings | null | undefined, defaults: { model: string; baseURL: string } @@ -68,6 +77,9 @@ export function resolveSettings( 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); return { @@ -80,6 +92,7 @@ export function resolveSettings( notify: notify || undefined, webSearchTool: webSearchTool || undefined, provider: provider || undefined, + providerPrivacyMode, zdr: zdr || undefined }; } diff --git a/src/tests/privacy-guard.test.ts b/src/tests/privacy-guard.test.ts index 957d740..98464c9 100644 --- a/src/tests/privacy-guard.test.ts +++ b/src/tests/privacy-guard.test.ts @@ -2,6 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { assertNoHighRiskSecretsForModel, + sanitizeForProviderStrict, sanitizeForModelPipeline, sanitizeToolCallsForReplay } from "../privacy-guard"; @@ -141,3 +142,41 @@ test("sanitizeForModelPipeline does not flag low-entropy placeholder strings", ( 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/session.test.ts b/src/tests/session.test.ts index b18b628..165e349 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -219,7 +219,7 @@ test("SessionManager replays normal assistant messages with reasoning content in 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); @@ -250,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 }); @@ -298,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 }); @@ -338,7 +338,7 @@ test("SessionManager lists project skills from .agents with legacy .deepcode com 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 }); @@ -365,7 +365,7 @@ test("createSession expands /init with the active .deepcode project AGENTS path" 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-"); - process.env.HOME = home; + setTestHome(home); process.env.USERPROFILE = home; globalThis.fetch = (async () => ({ ok: true, text: async () => "" }) as Response) as typeof fetch; @@ -389,7 +389,7 @@ test("createSession forces user AGENTS instructions when no project AGENTS file 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"); @@ -410,7 +410,7 @@ test("replySession expands /init with the active root project AGENTS path", asyn 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 }); @@ -431,7 +431,7 @@ test("createSession expands /init as generate when no project AGENTS file is eff test("createSession reports a new prompt with the machineId token", 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) => { @@ -463,7 +463,7 @@ test("createSession reports a new prompt with the machineId token", async () => test("replySession reports a new prompt with the machineId token", 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) => { @@ -494,7 +494,7 @@ test("replySession reports a new prompt with the machineId token", async () => { 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, @@ -861,7 +861,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", { @@ -903,7 +903,7 @@ 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", { @@ -940,7 +940,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; @@ -1006,10 +1006,231 @@ 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 }); @@ -1044,7 +1265,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 = { @@ -1154,6 +1375,34 @@ function createChatResponse(content: string, usage: Record): un }; } +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 + }; +} + function buildTestMessage( id: string, sessionId: string, @@ -1180,6 +1429,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 6814446..6bedb12 100644 --- a/src/tests/settings-and-notify.test.ts +++ b/src/tests/settings-and-notify.test.ts @@ -31,6 +31,38 @@ 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 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" + ); }); test("resolveSettings still accepts legacy env.THINKING and defaults reasoning effort when absent", () => { diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 013e236..0d09a92 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -1,12 +1,11 @@ import type OpenAI from "openai"; -import type { ReasoningEffort } from "../settings"; +import type { ProviderPrivacyMode, ReasoningEffort } from "../settings"; import { handleAskUserQuestionTool } from "./ask-user-question-handler"; import { handleBashTool } from "./bash-handler"; import { handleEditTool } from "./edit-handler"; import { handleReadTool } from "./read-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; -import { sanitizeForModelPipeline } from "../privacy-guard"; export type CreateOpenAIClient = () => { client: OpenAI | null; @@ -19,6 +18,7 @@ export type CreateOpenAIClient = () => { webSearchTool?: string; machineId?: string; provider?: string; + providerPrivacyMode?: ProviderPrivacyMode; zdr?: boolean; }; @@ -71,12 +71,6 @@ export type ToolCallExecution = { toolCallId: string; content: string; result: ToolExecutionResult; - redactedSensitiveOutput?: boolean; -}; - -type FormattedToolResult = { - content: string; - redactedSensitiveOutput: boolean; }; export class ToolExecutor { @@ -105,12 +99,10 @@ export class ToolExecutor { break; } const result = await this.executeToolCall(sessionId, toolCall, hooks); - const formattedResult = this.formatToolResult(result); executions.push({ toolCallId: toolCall.id, - content: formattedResult.content, - result, - redactedSensitiveOutput: formattedResult.redactedSensitiveOutput + content: this.formatToolResult(result), + result }); if (hooks?.shouldStop?.()) { break; @@ -232,7 +224,7 @@ export class ToolExecutor { } } - private formatToolResult(result: ToolExecutionResult): FormattedToolResult { + private formatToolResult(result: ToolExecutionResult): string { const payload: Record = { ok: result.ok, name: result.name @@ -254,21 +246,7 @@ export class ToolExecutor { payload.awaitUserResponse = true; } - const sanitized = sanitizeForModelPipeline(payload); - const sanitizedPayload: Record = - sanitized.value && typeof sanitized.value === "object" && !Array.isArray(sanitized.value) - ? { ...(sanitized.value as Record) } - : { value: sanitized.value }; - - if (sanitized.redactedSensitiveContent) { - sanitizedPayload.privacyWarning = - "Sensitive-looking material was redacted before model replay. Continue using the sanitized output."; - } - - return { - content: JSON.stringify(sanitizedPayload, null, 2), - redactedSensitiveOutput: sanitized.redactedSensitiveContent - }; + return JSON.stringify(payload, null, 2); } } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 15a70db..68296c1 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -14,7 +14,12 @@ import { type SkillInfo, type UserPromptContent } from "../session"; -import { resolveSettings, type DeepcodingSettings, type ReasoningEffort } from "../settings"; +import { + resolveSettings, + type DeepcodingSettings, + type ProviderPrivacyMode, + type ReasoningEffort +} from "../settings"; import { PromptInput, type PromptSubmission } from "./PromptInput"; import { MessageView } from "./MessageView"; import { SessionList } from "./SessionList"; @@ -443,6 +448,7 @@ export function createOpenAIClient(): { webSearchTool?: string; machineId?: string; provider?: string; + providerPrivacyMode: ProviderPrivacyMode; zdr?: boolean; } { const settings = resolveCurrentSettings(); @@ -458,6 +464,7 @@ export function createOpenAIClient(): { webSearchTool: settings.webSearchTool, machineId: getMachineId(), provider: settings.provider, + providerPrivacyMode: settings.providerPrivacyMode, zdr: settings.zdr }; } @@ -481,6 +488,7 @@ export function createOpenAIClient(): { webSearchTool: settings.webSearchTool, machineId: getMachineId(), provider: settings.provider, + providerPrivacyMode: settings.providerPrivacyMode, zdr: settings.zdr }; } From aac02cd70ea2f5254a71028cc918db554e784b84 Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Wed, 13 May 2026 21:07:06 +0300 Subject: [PATCH 07/10] asd --- README.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4eb9866..73af5fb 100644 --- a/README.md +++ b/README.md @@ -2,20 +2,22 @@ The system prompt in this codebase is heavily modified, please rewrite before use! -Deep Code CLI is a heavily modified terminal AI coding agent for running DeepSeek and other OpenAI-compatible models through a stricter privacy layer. -This fork is focused on company-code usage: reducing accidental leaks, sanitizing the final HTTP payload before it reaches a provider, and keeping provider routing explicit. +Deep Code CLI is a heavily modified terminal AI coding agent for running DeepSeek and other OpenAI-compatible models. +This fork is focused on company-code usage: preserving tool/cache correctness while optionally redacting sensitive values from the final HTTP payload before it reaches a provider. ## Security Model -This CLI is designed to make accidental leakage harder, not impossible -It protects the model request pipeline by scanning and sanitizing the JSON body that is actually sent upstream. That includes user messages, system messages, assistant reasoning fields, tool messages, and nested request fields -It does not replace normal company security controls. You should still avoid pasting real production secrets, use provider ZDR when available, keep fallback routing disabled, and review final-boundary logs during hardening +This CLI is designed to make accidental leakage harder, not impossible. +The internal session state stays raw so tool calls, tool results, and prompt-cache replay keep working across requests. +When `providerPrivacyMode` 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. You should 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 ### Final Request Sanitization -Before any request is sent to the model provider, the CLI builds a sanitized outbound request body. +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: @@ -26,13 +28,14 @@ The sanitizer redacts: - GitHub tokens. - AWS access keys. - Unknown high-entropy secret-looking tokens. -- Absolute local paths in model-replayed content. +- Secret-looking values near keys such as `password`, `secret`, `token`, `api_key`, and `private_key`. -High-risk secrets are blocked before send. Generic test credentials are redacted instead of failing the session. +The strict sanitizer preserves file paths, tool call IDs, tool result structure, and ordinary code context so cached tool workflows can still replay correctly. ### Tool Output Protection -Tool results are scanned before they become `tool` messages. If a tool output contains high-risk secret material, the CLI blocks automatic continuation and inserts a local warning instead of sending the raw result to the model. +Tool results are stored raw inside the local session. +With `providerPrivacyMode: "strict"`, sensitive values inside those tool results are redacted in the final provider-bound request clone before the model sees them. ### Sensitive File Reads @@ -53,16 +56,20 @@ Example OpenRouter + DeepSeek configuration: "env": { "MODEL": "deepseek/deepseek-v4-pro", "BASE_URL": "https://openrouter.ai/api/v1", - "API_KEY": "sk-or-...", + "API_KEY": "sk-or-v1-REDACTED", "PROVIDER": "siliconflow", "ZDR": "true" }, + "providerPrivacyMode": "strict", "debugLogEnabled": true, "thinkingEnabled": true, "reasoningEffort": "max" } ``` +`providerPrivacyMode` defaults to `off`. +Use `strict` when the final provider payload must be scrubbed before it leaves the CLI. + To explicitly allow sensitive reads: ```powershell @@ -94,7 +101,8 @@ Logs are written to: ## Important Notes -- ZDR helps with provider retention, but it does not replace local redaction and blocking. +- `providerPrivacyMode: "strict"` redacts the final provider request clone. It does not mutate local session history. +- ZDR helps with provider retention, but it does not replace local redaction. - Provider fallback should stay disabled for company-code use. - Secret detection is regex and entropy based; it is strong but not perfect. - The model can still receive sanitized proprietary code and context. From a02cfdc0cd371ff61f6e4a7b4676d71d604ebd76 Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Sat, 16 May 2026 01:53:17 +0300 Subject: [PATCH 08/10] v1.0 uff --- README.md | 119 +++++++++++++++++++++++------------------ src/error-logger.ts | 26 +++++++++ src/openai-thinking.ts | 35 +++++++----- src/session.ts | 98 +++++++++++++++++++++++++++++---- src/settings.ts | 16 +++++- src/tools/executor.ts | 21 +++++++- src/ui/App.tsx | 8 ++- 7 files changed, 242 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 73af5fb..e0f5864 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,13 @@ # Deep Code CLI -The system prompt in this codebase is heavily modified, please rewrite before use! - -Deep Code CLI is a heavily modified terminal AI coding agent for running DeepSeek and other OpenAI-compatible models. -This fork is focused on company-code usage: preserving tool/cache correctness while optionally redacting sensitive values from the final HTTP payload before it reaches a provider. +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 -This CLI is designed to make accidental leakage harder, not impossible. The internal session state stays raw so tool calls, tool results, and prompt-cache replay keep working across requests. -When `providerPrivacyMode` 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. You should 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. +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 @@ -21,75 +18,92 @@ It does not write redacted text back into session history, cache state, or tool The sanitizer redacts: -- Password-like phrases. -- JWTs. -- PEM private keys. -- Known API key formats. -- GitHub tokens. -- AWS access keys. -- Unknown high-entropy secret-looking tokens. -- Secret-looking values near keys such as `password`, `secret`, `token`, `api_key`, and `private_key`. +- 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` -The strict sanitizer preserves file paths, tool call IDs, tool result structure, and ordinary code context so cached tool workflows can still replay correctly. +The strict sanitizer preserves file paths, tool call IDs, tool result structure, and ordinary code context so cached tool workflows replay correctly. ### Tool Output Protection Tool results are stored raw inside the local session. -With `providerPrivacyMode: "strict"`, sensitive values inside those tool results are redacted in the final provider-bound request clone before the model sees them. +With `PROVIDER_PRIVACY: "strict"`, sensitive values inside those tool results are redacted in the final provider-bound request clone before the model sees them. ### Sensitive File Reads -Obvious secret-bearing files are refused by default, including `.env`, `.npmrc`, `.pypirc`, private keys, certificate/key stores, kube configs, Docker configs, cloud credentials, and service-account JSON files. +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. + +To explicitly allow sensitive reads: + +```powershell +$env:DEEPCODE_ALLOW_SENSITIVE_READS="true" +node dist/cli.js +``` ## Configuration -Create or edit: +Create or edit `~/.deepcode/settings.json`. -```text -~/.deepcode/settings.json -``` +### All settings keys + +**Inside `env`** (uppercase, mirror environment variables): + +| 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`): -Example OpenRouter + DeepSeek configuration: +| 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) | + +> **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. + +### Example settings.json ```json { "env": { - "MODEL": "deepseek/deepseek-v4-pro", + "MODEL": "gpt6.0 heh", "BASE_URL": "https://openrouter.ai/api/v1", - "API_KEY": "sk-or-v1-REDACTED", - "PROVIDER": "siliconflow", - "ZDR": "true" + "API_KEY": "key, huh? not giving that sh1t", + "THINKING": "enabled", + "PROVIDER_PRIVACY": "strict", + "ZDR": "true", + "DATA_COLLECTION": "deny" }, - "providerPrivacyMode": "strict", - "debugLogEnabled": true, - "thinkingEnabled": true, - "reasoningEffort": "max" + "debugLogEnabled": false, + "reasoningEffort": "medium" } ``` - -`providerPrivacyMode` defaults to `off`. -Use `strict` when the final provider payload must be scrubbed before it leaves the CLI. - -To explicitly allow sensitive reads: - -```powershell -$env:DEEPCODE_ALLOW_SENSITIVE_READS="true" -node dist/cli.js -``` - -Logs are written to: +## Logs ```text -%USERPROFILE%\.deepcode\logs\final-http-body.jsonl +~/.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) ``` ## Keyboard Shortcuts | Key | Action | -| --- | --- | +|-----|--------| | `Enter` | Send message | | `Shift+Enter` | Insert newline | -| `Ctrl+V` | Paste image from clipboard where supported | +| `Ctrl+V` | Paste image from clipboard | | `Esc` | Interrupt current response | | `/` | Open command menu | | `/new` | Start a new session | @@ -98,15 +112,14 @@ Logs are written to: | `/exit` | Exit | | `Ctrl+D` twice | Exit | - ## Important Notes -- `providerPrivacyMode: "strict"` redacts the final provider request clone. It does not mutate local session history. -- ZDR helps with provider retention, but it does not replace local redaction. -- Provider fallback should stay disabled for company-code use. -- Secret detection is regex and entropy based; it is strong but not perfect. -- The model can still receive sanitized proprietary code and context. -- Review boundary logs while hardening, then disable them. +- `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 diff --git a/src/error-logger.ts b/src/error-logger.ts index 42b391b..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,6 +12,31 @@ 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. diff --git a/src/openai-thinking.ts b/src/openai-thinking.ts index ec2c2e0..b59898a 100644 --- a/src/openai-thinking.ts +++ b/src/openai-thinking.ts @@ -1,4 +1,4 @@ -import type { ReasoningEffort } from "./settings"; +import type { DataCollection, ReasoningEffort } from "./settings"; type ThinkingConfig = { type: "enabled" | "disabled"; @@ -7,6 +7,8 @@ type ThinkingConfig = { type ProviderOptions = { only?: string[]; allow_fallbacks: boolean; + zdr?: boolean; + data_collection?: DataCollection; }; type ThinkingRequestOptions = { @@ -23,17 +25,25 @@ export function buildThinkingRequestOptions( baseURL?: string, reasoningEffort: ReasoningEffort = "xhigh", provider?: string, - _zdr?: boolean + zdr?: boolean, + dataCollection?: DataCollection ): ThinkingRequestOptions { - const providerOptions: ProviderOptions | undefined = - provider - ? { - only: [provider], - allow_fallbacks: false - } - : undefined; - - if (isOpenRouterBaseURL(baseURL)) { + 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 } : {}) @@ -42,8 +52,7 @@ export function buildThinkingRequestOptions( return { thinking: { type: thinkingEnabled ? "enabled" : "disabled" }, - ...(thinkingEnabled ? { reasoning_effort: reasoningEffort } : {}), - ...(providerOptions ? { provider: providerOptions } : {}) + ...(thinkingEnabled ? { reasoning_effort: reasoningEffort } : {}) }; } diff --git a/src/session.ts b/src/session.ts index c8fe059..c68d5a1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -11,7 +11,7 @@ import { buildThinkingRequestOptions } from "./openai-thinking"; import { DEEPSEEK_V4_MODELS } from "./model-capabilities"; import { getCompactPrompt, getSystemPrompt, getTools, 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"; @@ -528,6 +528,29 @@ export class SessionManager { 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); @@ -1045,7 +1068,8 @@ ${skillMd} notify, provider, providerPrivacyMode, - zdr + zdr, + dataCollection } = this.createOpenAIClient(); const now = new Date().toISOString(); @@ -1106,17 +1130,14 @@ ${skillMd} await this.compactSession(sessionId, sessionController.signal); } - const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled); - const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort, provider, zdr); + const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled, isOpenRouterBaseURL(baseURL)); + const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort, provider, zdr, dataCollection); const response = await this.createChatCompletionStream( client, { model, messages, tools: getTools(this.getPromptToolOptions()), - ...(isOpenRouterBaseURL(baseURL) - ? { cache_control: { type: "ephemeral", ttl: "1h" } } - : {}), ...thinkingOptions }, { signal: sessionController.signal }, @@ -1234,7 +1255,8 @@ ${skillMd} debugLogEnabled, provider, providerPrivacyMode, - zdr + zdr, + dataCollection } = this.createOpenAIClient(); if (!client) { return; @@ -1264,7 +1286,7 @@ ${skillMd} } const compactPrompt = getCompactPrompt(sessionMessages.slice(startIndex, endIndex)); - const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort, provider, zdr); + const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort, provider, zdr, dataCollection); const response = await this.createChatCompletionStream(client, { model, messages: [{ role: "user", content: compactPrompt }], @@ -1821,10 +1843,17 @@ ${skillMd} 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(); + + const firstNonSystemIndex = activeMessages.findIndex((m) => m.role !== "system"); + const lastSystemIndex = firstNonSystemIndex === -1 + ? activeMessages.length - 1 + : firstNonSystemIndex - 1; for (let index = 0; index < activeMessages.length; index += 1) { const message = activeMessages[index]; @@ -1832,7 +1861,46 @@ ${skillMd} continue; } - openAIMessages.push(this.sessionMessageToOpenAIMessage(message, thinkingEnabled)); + const openAIMessage = this.sessionMessageToOpenAIMessage(message, thinkingEnabled); + + if (addCacheControl && index === lastSystemIndex && message.role === "system") { + const text = typeof openAIMessage.content === "string" ? openAIMessage.content : ""; + (openAIMessage as unknown as Record).content = [ + { type: "text", text, cache_control: { type: "ephemeral" } } + ]; + } + + // 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) { @@ -1844,6 +1912,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) { diff --git a/src/settings.ts b/src/settings.ts index 5c96b91..a62d143 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,10 +9,12 @@ export type DeepcodingEnv = { PROVIDER_PRIVACY?: string; providerPrivacyMode?: string; ZDR?: string; + DATA_COLLECTION?: string; }; export type ReasoningEffort = "xhigh" | "high" | "medium" | "low" | "minimal" | "none"; export type ProviderPrivacyMode = "off" | "strict"; +export type DataCollection = "allow" | "deny"; export type DeepcodingSettings = { env?: DeepcodingEnv; @@ -23,6 +25,7 @@ export type DeepcodingSettings = { webSearchTool?: string; providerPrivacyMode?: ProviderPrivacyMode; zdr?: boolean; + dataCollection?: DataCollection; }; export type ResolvedDeepcodingSettings = { @@ -37,6 +40,7 @@ export type ResolvedDeepcodingSettings = { provider?: string; providerPrivacyMode: ProviderPrivacyMode; zdr?: boolean; + dataCollection?: DataCollection; }; function resolveReasoningEffort(value: unknown): ReasoningEffort { @@ -67,6 +71,12 @@ 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 } @@ -81,6 +91,9 @@ export function resolveSettings( 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(), @@ -93,6 +106,7 @@ export function resolveSettings( webSearchTool: webSearchTool || undefined, provider: provider || undefined, providerPrivacyMode, - zdr: zdr || undefined + zdr: zdr || undefined, + dataCollection }; } diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 0d09a92..6582625 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -1,5 +1,6 @@ import type OpenAI from "openai"; -import type { ProviderPrivacyMode, 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"; @@ -20,6 +21,7 @@ export type CreateOpenAIClient = () => { provider?: string; providerPrivacyMode?: ProviderPrivacyMode; zdr?: boolean; + dataCollection?: DataCollection; }; export type ToolCall = { @@ -89,9 +91,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) { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 68296c1..c8bd189 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -16,6 +16,7 @@ import { } from "../session"; import { resolveSettings, + type DataCollection, type DeepcodingSettings, type ProviderPrivacyMode, type ReasoningEffort @@ -450,6 +451,7 @@ export function createOpenAIClient(): { provider?: string; providerPrivacyMode: ProviderPrivacyMode; zdr?: boolean; + dataCollection?: DataCollection; } { const settings = resolveCurrentSettings(); if (!settings.apiKey) { @@ -465,7 +467,8 @@ export function createOpenAIClient(): { machineId: getMachineId(), provider: settings.provider, providerPrivacyMode: settings.providerPrivacyMode, - zdr: settings.zdr + zdr: settings.zdr, + dataCollection: settings.dataCollection }; } @@ -489,7 +492,8 @@ export function createOpenAIClient(): { machineId: getMachineId(), provider: settings.provider, providerPrivacyMode: settings.providerPrivacyMode, - zdr: settings.zdr + zdr: settings.zdr, + dataCollection: settings.dataCollection }; } From a753435785121e05873720ef685793a8c584c132 Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Sat, 16 May 2026 18:29:00 +0300 Subject: [PATCH 09/10] serena for for the damn navigation thro the useless codebase + better caching --- .gitignore | 3 + package.json | 23 +- src/cli.tsx | 75 +-- src/prompt.ts | 993 ++++++++++++++++++++++++++++---- src/session.ts | 100 ++-- src/settings.ts | 5 +- src/tests/shell-utils.test.ts | 55 -- src/tests/tool-handlers.test.ts | 433 -------------- src/tests/updateCheck.test.ts | 16 - src/tools/bash-handler.ts | 269 --------- src/tools/edit-handler.ts | 865 ---------------------------- src/tools/executor.ts | 92 ++- src/tools/file-utils.ts | 150 ----- src/tools/read-handler.ts | 684 ---------------------- src/tools/runtime.ts | 73 --- src/tools/shell-utils.ts | 192 ------ src/tools/state.ts | 159 ----- src/tools/write-handler.ts | 185 ------ src/ui/App.tsx | 7 +- src/ui/MessageView.tsx | 47 +- src/ui/PromptInput.tsx | 2 +- src/ui/UpdatePrompt.tsx | 87 --- src/ui/WelcomeScreen.tsx | 6 +- src/ui/index.ts | 1 - src/updateCheck.ts | 294 ---------- test_value.txt | 1 + 26 files changed, 1111 insertions(+), 3706 deletions(-) delete mode 100644 src/tests/shell-utils.test.ts delete mode 100644 src/tests/tool-handlers.test.ts delete mode 100644 src/tests/updateCheck.test.ts delete mode 100644 src/tools/bash-handler.ts delete mode 100644 src/tools/edit-handler.ts delete mode 100644 src/tools/file-utils.ts delete mode 100644 src/tools/read-handler.ts delete mode 100644 src/tools/runtime.ts delete mode 100644 src/tools/shell-utils.ts delete mode 100644 src/tools/state.ts delete mode 100644 src/tools/write-handler.ts delete mode 100644 src/ui/UpdatePrompt.tsx delete mode 100644 src/updateCheck.ts create mode 100644 test_value.txt 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/package.json b/package.json index c886a33..90c2a1d 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,15 @@ { - "name": "@vegamo/deepcode-cli", - "version": "0.1.20", - "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,16 +20,16 @@ "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", - "build": "npm run typecheck && npm run bundle && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"", - "inspect:cache-log": "node scripts/inspect-final-http-body.mjs", - "test": "tsx --test src/tests/*.test.ts", - "test:single": "tsx --test", - "prepack": "npm run build" + "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", diff --git a/src/cli.tsx b/src/cli.tsx index 88f401d..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", @@ -49,7 +47,6 @@ if (args.includes("--help") || args.includes("-h")) { } const projectRoot = process.cwd(); -configureWindowsShell(); if (!process.stdin.isTTY) { process.stderr.write( @@ -59,63 +56,41 @@ if (!process.stdin.isTTY) { process.exit(1); } -void main(); +const restartRef: { current: (() => void) | null } = { current: null }; -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(); - }; - - 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 { - // eslint-disable-next-line @typescript-eslint/no-var-requires const pkg = require("../package.json") as { name?: unknown; version?: unknown }; return { - name: typeof pkg.name === "string" ? pkg.name : "@vegamo/deepcode-cli", + 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/prompt.ts b/src/prompt.ts index 67a944a..b78f51c 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -2,7 +2,6 @@ import { execFileSync, execSync } from "child_process"; import * as os from "os"; import * as path from "path"; import type { SessionMessage } from "./session"; -import { findGitBashPath } from "./tools/shell-utils"; export const AGENT_DRIFT_GUARD_SKILL = ` --- @@ -293,7 +292,17 @@ const TOOL_USAGE_GUIDANCE = `# Tool Usage All provided tools are available for use. Choose any available tool when it helps complete the user's request, inspect the workspace, verify behavior, or gather needed context. Never read obvious secret-bearing files unless the user explicitly asks and the environment has enabled sensitive reads. -Available tool schemas are provided separately in the API request.`; +Available tool schemas are provided separately in the API request. + +# 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; @@ -349,25 +358,16 @@ function getRuntimeContext(projectRoot: string): string { 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 shellSingleQuote(value: string): string { - return `'${value.replace(/'/g, "'\"'\"'")}'`; -} - function getRuntimeVersionInfo(): Record { const versions: Record = {}; const pythonVersion = getCommandVersion("python3", ["--version"]); @@ -385,27 +385,17 @@ 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()}`; @@ -427,23 +417,29 @@ export type ToolDefinition = { }; export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { + void options; 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 and return its output. " + + "The working directory defaults to the project root. " + + "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"], @@ -451,58 +447,95 @@ 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 file (or a slice of it) within the project directory. " + + "Use get_symbols_overview first when you need a structural overview.", parameters: { type: "object", properties: { - questions: { - type: "array", + 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: ["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: - "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"], - }, + "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: ["questions"], + required: ["relative_path", "needle", "repl", "mode"], additionalProperties: false, }, }, @@ -510,31 +543,61 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { { type: "function", function: { - name: "read", + name: "delete_lines", description: - "Read files from the filesystem (text, images, PDFs, notebooks).", + "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: { - 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 delete.", }, - limit: { + end_line: { type: "number", - description: "Number of lines to read", + description: "0-based index of the last line to delete (inclusive).", }, - pages: { + }, + required: ["relative_path", "start_line", "end_line"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + 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: { + relative_path: { type: "string", - description: - 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.', + description: "Relative path to the file from the project root.", + }, + start_line: { + type: "number", + description: "0-based index of the first line to replace.", + }, + end_line: { + type: "number", + description: "0-based index of the last line to replace (inclusive).", + }, + content: { + type: "string", + description: "New content to insert in place of the deleted lines.", }, }, - required: ["file_path"], + required: ["relative_path", "start_line", "end_line", "content"], additionalProperties: false, }, }, @@ -542,22 +605,77 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { { type: "function", function: { - name: "write", + name: "insert_at_line", description: - "Create files or overwrite them with a complete string payload. Prefer edit for existing files.", + "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: ["relative_path", "recursive"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "find_file", + description: + "Find files matching a filename pattern (glob) within the project directory.", + parameters: { + type: "object", + properties: { + file_mask: { + type: "string", + description: "Filename or file mask (supports * and ? wildcards), e.g. '*.ts' or 'index.*'.", + }, + relative_path: { + type: "string", + description: "Directory to search in. Use '.' for the project root.", }, }, - required: ["file_path", "content"], + required: ["file_mask", "relative_path"], additionalProperties: false, }, }, @@ -565,64 +683,689 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { { type: "function", function: { - name: "edit", - description: "Perform scoped string replacements in files.", + name: "search_for_pattern", + description: + "Search for a regex pattern across project files. " + + "Prefer symbolic tools when you know which symbol you are looking for.", parameters: { type: "object", properties: { - file_path: { + substring_pattern: { type: "string", - description: "Absolute path to file. Optional when snippet_id is provided.", + description: + "Regex pattern to search for (Python re, DOTALL enabled). " + + "Avoid .* at start/end; use .*? in the middle for non-greedy multi-line matches.", }, - snippet_id: { + 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: "Snippet id returned by the Read or Edit tool to scope the search range after a partial read.", + description: "Glob pattern for files to include (e.g. 'src/**/*.ts'). Empty means all non-ignored files.", }, - old_string: { + paths_exclude_glob: { type: "string", - description: "Exact text to replace inside the file or snippet scope", + description: "Glob pattern for files to exclude (e.g. '**/*.test.ts').", }, - new_string: { + relative_path: { type: "string", - description: "Replacement text (must differ from old_string)", + description: "Restrict search to this subdirectory (relative path). Empty means entire project.", }, - replace_all: { + 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 an overview of the top-level symbols (classes, functions, etc.) defined in a file. " + + "Call this first when exploring a new file before reading the full content.", + 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.", + }, + }, + required: ["relative_path"], + additionalProperties: false, + }, + }, + }, + { + type: "function", + function: { + name: "find_symbol", + description: + "Find symbols (classes, methods, functions, etc.) by name path pattern across the codebase. " + + "A name path is like 'MyClass/my_method'. Supports simple names, relative paths, and absolute paths (prefix '/').", + parameters: { + type: "object", + properties: { + name_path_pattern: { + type: "string", description: - "Replace all occurences of old_string (default false)", - default: false, + "Name path pattern to match. Examples: 'handleBashTool', 'ToolExecutor/executeToolCall', '/ToolExecutor/registerToolHandlers'.", + }, + depth: { + type: "number", + description: "Depth of descendants to retrieve. Defaults to 0.", + }, + relative_path: { + type: "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.", + }, + substring_matching: { + type: "boolean", + description: "If true, the last element of the pattern uses substring matching. Defaults to false.", }, - expected_occurrences: { + max_matches: { type: "number", + description: "Maximum number of matches to return. -1 means no limit. Use 1 when searching for a unique symbol.", + }, + }, + required: ["name_path_pattern"], + additionalProperties: false, + }, + }, + }, + { + 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, + }, + }, + }, + { + 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: - "Expected number of matches, especially useful as a safety check with replace_all", + "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: ["old_string", "new_string"], + required: ["relative_path", "regex"], 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: "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, }, - required: ["query"], - 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, + }, + }, + }, + ]; return tools; } diff --git a/src/session.ts b/src/session.ts index c68d5a1..384e4b1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1069,7 +1069,8 @@ ${skillMd} provider, providerPrivacyMode, zdr, - dataCollection + dataCollection, + cacheControl } = this.createOpenAIClient(); const now = new Date().toISOString(); @@ -1130,7 +1131,7 @@ ${skillMd} await this.compactSession(sessionId, sessionController.signal); } - const messages = this.buildOpenAIMessages(this.listSessionMessages(sessionId), thinkingEnabled, isOpenRouterBaseURL(baseURL)); + 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, @@ -1840,6 +1841,17 @@ ${skillMd} return { waitingForUser }; } + 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, @@ -1850,11 +1862,6 @@ ${skillMd} const openAIMessages: ChatCompletionMessageParam[] = []; const emittedToolCallIds = new Set(); - const firstNonSystemIndex = activeMessages.findIndex((m) => m.role !== "system"); - const lastSystemIndex = firstNonSystemIndex === -1 - ? activeMessages.length - 1 - : firstNonSystemIndex - 1; - for (let index = 0; index < activeMessages.length; index += 1) { const message = activeMessages[index]; if (message.role === "tool") { @@ -1863,13 +1870,6 @@ ${skillMd} const openAIMessage = this.sessionMessageToOpenAIMessage(message, thinkingEnabled); - if (addCacheControl && index === lastSystemIndex && message.role === "system") { - const text = typeof openAIMessage.content === "string" ? openAIMessage.content : ""; - (openAIMessage as unknown as Record).content = [ - { type: "text", text, cache_control: { type: "ephemeral" } } - ]; - } - // 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. @@ -1933,6 +1933,35 @@ ${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; } @@ -2133,31 +2162,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 { diff --git a/src/settings.ts b/src/settings.ts index a62d143..c681ee3 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -26,6 +26,7 @@ export type DeepcodingSettings = { providerPrivacyMode?: ProviderPrivacyMode; zdr?: boolean; dataCollection?: DataCollection; + cacheControl?: boolean; }; export type ResolvedDeepcodingSettings = { @@ -41,6 +42,7 @@ export type ResolvedDeepcodingSettings = { providerPrivacyMode: ProviderPrivacyMode; zdr?: boolean; dataCollection?: DataCollection; + cacheControl?: boolean; }; function resolveReasoningEffort(value: unknown): ReasoningEffort { @@ -107,6 +109,7 @@ export function resolveSettings( provider: provider || undefined, providerPrivacyMode, zdr: zdr || undefined, - dataCollection + dataCollection, + cacheControl: settings?.cacheControl === true ? true : undefined }; } diff --git a/src/tests/shell-utils.test.ts b/src/tests/shell-utils.test.ts deleted file mode 100644 index a8f2ff2..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/tool-handlers.test.ts b/src/tests/tool-handlers.test.ts deleted file mode 100644 index 8f24954..0000000 --- a/src/tests/tool-handlers.test.ts +++ /dev/null @@ -1,433 +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("Read allows obvious secret-bearing files", async () => { - const workspace = createTempWorkspace(); - const filePath = path.join(workspace, ".env"); - fs.writeFileSync(filePath, "JWT_PRIVATE_KEY=secret\n", "utf8"); - - const readResult = await handleReadTool( - { file_path: filePath }, - createContext("sensitive-read", workspace) - ); - - assert.equal(readResult.ok, true); - assert.match(readResult.output ?? "", /JWT_PRIVATE_KEY=secret/); -}); - -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 19341f6..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/tools/bash-handler.ts b/src/tools/bash-handler.ts deleted file mode 100644 index da8f9ef..0000000 --- a/src/tools/bash-handler.ts +++ /dev/null @@ -1,269 +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 c8740de..0000000 --- a/src/tools/edit-handler.ts +++ /dev/null @@ -1,865 +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 as unknown as (body: Record) => Promise<{ choices?: Array<{ message?: { content?: string } }> }>)({ - 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 6582625..e4da9ad 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -2,11 +2,43 @@ import type OpenAI from "openai"; 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 { + 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; @@ -22,6 +54,7 @@ export type CreateOpenAIClient = () => { providerPrivacyMode?: ProviderPrivacyMode; zdr?: boolean; dataCollection?: DataCollection; + cacheControl?: boolean; }; export type ToolCall = { @@ -129,10 +162,53 @@ 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); + + // Non-Serena tools (kept as-is) this.toolHandlers.set("AskUserQuestion", handleAskUserQuestionTool); this.toolHandlers.set("WebSearch", handleWebSearchTool); } diff --git a/src/tools/file-utils.ts b/src/tools/file-utils.ts deleted file mode 100644 index b5705d9..0000000 --- a/src/tools/file-utils.ts +++ /dev/null @@ -1,150 +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 1fec586..0000000 --- a/src/tools/read-handler.ts +++ /dev/null @@ -1,684 +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 acb6bcd..0000000 --- a/src/tools/runtime.ts +++ /dev/null @@ -1,73 +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 6be71f9..0000000 --- a/src/tools/shell-utils.ts +++ /dev/null @@ -1,192 +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 for Windows and ensure git.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 d254086..0000000 --- a/src/tools/state.ts +++ /dev/null @@ -1,159 +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/write-handler.ts b/src/tools/write-handler.ts deleted file mode 100644 index 7b6e6c6..0000000 --- a/src/tools/write-handler.ts +++ /dev/null @@ -1,185 +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 WriteInput = z.infer; - -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 c8bd189..fd1b282 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -452,6 +452,7 @@ export function createOpenAIClient(): { providerPrivacyMode: ProviderPrivacyMode; zdr?: boolean; dataCollection?: DataCollection; + cacheControl?: boolean; } { const settings = resolveCurrentSettings(); if (!settings.apiKey) { @@ -468,7 +469,8 @@ export function createOpenAIClient(): { provider: settings.provider, providerPrivacyMode: settings.providerPrivacyMode, zdr: settings.zdr, - dataCollection: settings.dataCollection + dataCollection: settings.dataCollection, + cacheControl: settings.cacheControl }; } @@ -493,7 +495,8 @@ export function createOpenAIClient(): { provider: settings.provider, providerPrivacyMode: settings.providerPrivacyMode, zdr: settings.zdr, - dataCollection: settings.dataCollection + dataCollection: settings.dataCollection, + cacheControl: settings.cacheControl }; } diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index 77d1ee9..fa36d58 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -55,7 +55,6 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | return ( - {content ? {renderMarkdown(content)} : null} @@ -99,6 +98,38 @@ 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, @@ -108,11 +139,11 @@ function StatusLine({ name: string; params: string; }): React.ReactElement { + const isSerena = SERENA_TOOLS.has(name); return ( {[ - , - " ", + isSerena ? serena › : null, {name}, params ? {` ${params}`} : null ]} @@ -122,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); } @@ -233,7 +266,8 @@ function parseToolPayload( } 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; @@ -287,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 0c68d38..d39918d 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -114,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); diff --git a/src/ui/UpdatePrompt.tsx b/src/ui/UpdatePrompt.tsx deleted file mode 100644 index 93bd62b..0000000 --- a/src/ui/UpdatePrompt.tsx +++ /dev/null @@ -1,87 +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 e481592..1585bfc 100644 --- a/src/ui/WelcomeScreen.tsx +++ b/src/ui/WelcomeScreen.tsx @@ -29,11 +29,11 @@ export function WelcomeScreen({ return ( - {">"}_ Deep Code - (v{version || "unknown"}) + SIMEON's dev CLI + (v{version || "unknown"}) - developed and maintained by SIMO + developed and maintained by SIMO ); diff --git a/src/ui/index.ts b/src/ui/index.ts index b290950..893a492 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -20,7 +20,6 @@ export { 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, diff --git a/src/updateCheck.ts b/src/updateCheck.ts deleted file mode 100644 index 5613f51..0000000 --- a/src/updateCheck.ts +++ /dev/null @@ -1,294 +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(resolveNpmExecutable(), ["install", "-g", installSpec], { - stdio: "inherit" - }); - 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(resolveNpmExecutable(), args, { - stdio: ["ignore", "pipe", "pipe"] - }); - - 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 }); - }); - }); -} - -function resolveNpmExecutable(): string { - return process.platform === "win32" ? "npm.cmd" : "npm"; -} - -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 From 4e77f271d078a8c1a9cddc170926594d43ac423f Mon Sep 17 00:00:00 2001 From: Simeon Mladenov <87178822+simo8902@users.noreply.github.com> Date: Thu, 21 May 2026 09:59:23 +0300 Subject: [PATCH 10/10] usage report to statusbar + usage of rg, sg + serena --- src/prompt.ts | 175 ++++++++++++++++++++++++--- src/session.ts | 41 ++----- src/tests/exitSummary.test.ts | 1 + src/tests/session.test.ts | 26 +--- src/tests/web-search-handler.test.ts | 80 +----------- src/tools/executor.ts | 8 +- src/tools/web-search-handler.ts | 116 ++---------------- src/ui/App.tsx | 60 ++++++++- 8 files changed, 253 insertions(+), 254 deletions(-) diff --git a/src/prompt.ts b/src/prompt.ts index b78f51c..38fb47a 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -286,15 +286,57 @@ Be token-aware and avoid unnecessary verbosity.`; type PromptToolOptions = { webSearchEnabled?: boolean; + ripgrepEnabled?: boolean; + astGrepEnabled?: boolean; }; const TOOL_USAGE_GUIDANCE = `# Tool Usage -All provided tools are available for use. Choose any available tool when it helps complete the user's request, inspect the workspace, verify behavior, or gather needed context. Never read obvious secret-bearing files unless the user explicitly asks and the environment has enabled sensitive reads. +Never read obvious secret-bearing files unless the user explicitly asks and the environment has enabled sensitive reads. -Available tool schemas are provided separately in the API request. +## Mandatory Tool Selection Protocol -# Session Startup +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 + +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. @@ -345,8 +387,8 @@ function getRuntimeContext(projectRoot: string): string { ...shellModeOpts, ...runtimeVersions, "command installed": { - "ast-grep": checkToolInstalled("ast-grep"), - "ripgrep": checkToolInstalled("rg"), + "ast-grep (sg)": checkToolInstalled("sg"), + "ripgrep (rg)": checkToolInstalled("rg"), "jq": checkToolInstalled("jq") } }; @@ -355,7 +397,7 @@ function getRuntimeContext(projectRoot: string): string { return result; } -function checkToolInstalled(tool: string): boolean { +export function checkToolInstalled(tool: string): boolean { try { if (process.platform === "win32") { execFileSync("where.exe", [tool], { encoding: "utf8", stdio: "ignore", windowsHide: true }); @@ -417,7 +459,6 @@ export type ToolDefinition = { }; export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { - void options; const tools: ToolDefinition[] = [ // ── Serena: shell ──────────────────────────────────────────────────────── @@ -426,8 +467,8 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { function: { name: "execute_shell_command", description: - "Execute a shell command and return its output. " + - "The working directory defaults to the project root. " + + "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", @@ -454,8 +495,10 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { function: { name: "read_file", description: - "Read a file (or a slice of it) within the project directory. " + - "Use get_symbols_overview first when you need a structural overview.", + "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: { @@ -685,8 +728,9 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { function: { name: "search_for_pattern", description: - "Search for a regex pattern across project files. " + - "Prefer symbolic tools when you know which symbol you are looking for.", + "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: { @@ -748,8 +792,10 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { function: { name: "get_symbols_overview", description: - "Get an overview of the top-level symbols (classes, functions, etc.) defined in a file. " + - "Call this first when exploring a new file before reading the full content.", + "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: { @@ -772,8 +818,9 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { function: { name: "find_symbol", description: - "Find symbols (classes, methods, functions, etc.) by name path pattern across the codebase. " + - "A name path is like 'MyClass/my_method'. Supports simple names, relative paths, and absolute paths (prefix '/').", + "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: { @@ -1367,5 +1414,99 @@ export function getTools(options: PromptToolOptions = {}): ToolDefinition[] { }, ]; + // ── 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 384e4b1..9c0c83c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -9,7 +9,7 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open 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, logWarn } from "./error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./debug-logger"; @@ -17,7 +17,6 @@ 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"; @@ -125,6 +124,7 @@ export type SessionEntry = { status: SessionStatus; failReason: string | null; usage: unknown | null; + lastResponseUsage: unknown | null; activeTokens: number; createTime: string; updateTime: string; @@ -936,6 +936,7 @@ The candidate skills are as follows:\n\n`; status: "pending", failReason: null, usage: null, + lastResponseUsage: null, activeTokens: 0, createTime: now, updateTime: now, @@ -1187,6 +1188,7 @@ ${skillMd} assistantRefusal: refusal, toolCalls, usage: accumulateUsage(entry.usage, responseUsage), + lastResponseUsage: responseUsage, activeTokens: getTotalTokens(responseUsage), status: refusal ? "failed" @@ -1335,40 +1337,16 @@ ${skillMd} this.saveSessionMessages(sessionId, sessionMessages); } - private getPromptToolOptions(): { webSearchEnabled: boolean } { + private getPromptToolOptions(): { webSearchEnabled: boolean; ripgrepEnabled: boolean; astGrepEnabled: boolean } { return { - webSearchEnabled: true + 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 { @@ -2330,6 +2308,7 @@ ${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(), diff --git a/src/tests/exitSummary.test.ts b/src/tests/exitSummary.test.ts index ea6a082..7a33809 100644 --- a/src/tests/exitSummary.test.ts +++ b/src/tests/exitSummary.test.ts @@ -45,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/session.test.ts b/src/tests/session.test.ts index 165e349..c65958f 100644 --- a/src/tests/session.test.ts +++ b/src/tests/session.test.ts @@ -428,7 +428,7 @@ test("createSession expands /init as generate when no project AGENTS file is eff 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-"); setTestHome(home); @@ -436,10 +436,7 @@ test("createSession reports a new prompt with the machineId token", async () => 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"); @@ -453,14 +450,10 @@ 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-"); setTestHome(home); @@ -468,10 +461,7 @@ test("replySession reports a new prompt with the machineId token", async () => { 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"); @@ -484,11 +474,7 @@ 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 () => { diff --git a/src/tests/web-search-handler.test.ts b/src/tests/web-search-handler.test.ts index 2f69a10..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"; @@ -56,81 +55,14 @@ test("WebSearch executes the configured script with the query as one argument", 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) - }) - ); - - 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) @@ -139,16 +71,15 @@ test("WebSearch returns a configuration error when neither a script nor an LLM c assert.equal(result.ok, false); assert.equal( result.error, - "WebSearch default mode requires a valid LLM configuration in ~/.deepcode/settings.json." + "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; } = {} @@ -165,11 +96,10 @@ function createContext( } }, createOpenAIClient: () => ({ - client: options.client ?? null, + client: null, model: "test-model", thinkingEnabled: false, webSearchTool: options.webSearchTool, - machineId: options.machineId }), onProcessStart: options.onProcessStart, onProcessExit: options.onProcessExit diff --git a/src/tools/executor.ts b/src/tools/executor.ts index e4da9ad..96e0428 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -2,6 +2,8 @@ import type OpenAI from "openai"; import type { DataCollection, ProviderPrivacyMode, ReasoningEffort } from "../settings"; import { logWarn } from "../error-logger"; import { handleAskUserQuestionTool } from "./ask-user-question-handler"; +import { handleRipgrepTool } from "./ripgrep-handler"; +import { handleAstGrepTool } from "./ast-grep-handler"; import { handleCheckOnboardingPerformedTool, handleCreateTextFileTool, @@ -208,7 +210,11 @@ export class ToolExecutor { this.toolHandlers.set("open_dashboard", handleOpenDashboardTool); this.toolHandlers.set("get_current_config", handleGetCurrentConfigTool); - // Non-Serena tools (kept as-is) + // 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); } diff --git a/src/tools/web-search-handler.ts b/src/tools/web-search-handler.ts index 1389699..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"; @@ -49,16 +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 { @@ -117,41 +111,6 @@ async function executeConfiguredWebSearch( }; } -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, @@ -205,34 +164,22 @@ 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( @@ -321,49 +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; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index fd1b282..beece86 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -65,6 +65,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const [showWelcome, setShowWelcome] = useState(true); const [welcomeNonce, setWelcomeNonce] = useState(0); const [nowTick, setNowTick] = useState(0); + const [balance, setBalance] = useState(""); const messagesRef = useRef([]); messagesRef.current = messages; @@ -104,6 +105,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R useEffect(() => { refreshSessionsList(); void refreshSkills(); + void refreshBalance(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -124,6 +126,29 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R } } + 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; const handlePrompt = useCallback( @@ -201,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); @@ -336,9 +362,9 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R ); }} - {statusLine ? ( + {(statusLine || balance) ? ( - {statusLine} + {[statusLine, balance ? `balance: ${balance}` : ""].filter(Boolean).join(" - ")} ) : null} {errorLine ? ( @@ -406,16 +432,42 @@ function buildSyntheticUserMessage(content: string, imageCount: number): Session }; } +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 {