diff --git a/.deepcode/AGENTS.md b/.deepcode/AGENTS.md index 186f078b..dc475ac4 100644 --- a/.deepcode/AGENTS.md +++ b/.deepcode/AGENTS.md @@ -7,35 +7,41 @@ This is an **npm workspaces monorepo**. Packages live under `packages/`. ``` packages/ ├── core/src/ # LLM session, tool execution, shared utilities +│ ├── index.ts # Barrel export +│ ├── session.ts # SessionManager — LLM loop, compaction, tool orchestration +│ ├── prompt.ts # System prompt builder & tool definitions +│ ├── settings.ts # Settings resolution from ~/.deepcode/settings.json │ ├── common/ # File I/O, permissions, telemetry, OpenAI client, shell utils, etc. │ ├── tools/ # 7 built-in handlers (bash, read, write, edit, web-search, ask-user-question, update-plan) │ ├── mcp/ # MCP client & manager (JSON-RPC lifecycle) -│ ├── session.ts # SessionManager — LLM loop, compaction, tool orchestration -│ ├── prompt.ts # System prompt builder & tool definitions -│ └── settings.ts # Settings resolution from ~/.deepcode/settings.json +│ └── tests/ # Core tests with run-tests.mjs runner ├── cli/src/ # Terminal UI (Ink/React) │ ├── cli.tsx # Entry point — renders AppContainer │ ├── cli-args.ts # CLI argument parsing (yargs: -p, -r, -v, -h) │ ├── common/ # Update checker │ ├── utils/ # stdio helpers, version, package info -│ ├── generated/ # Build-time git commit info -│ ├── ui/views/ # Top-level screens (App, PromptInput, SessionList, PermissionPrompt, WelcomeScreen, UpdatePrompt, McpStatusList, etc.) -│ ├── ui/components/ # Reusable Ink components (MessageView, DropdownMenu, ModelsDropdown, etc.) +│ ├── ui/views/ # Top-level screens (App, AppContainer, PromptInput, SessionList, +│ │ # PermissionPrompt, WelcomeScreen, UpdatePrompt, McpStatusList, +│ │ # AskUserQuestionPrompt, PlanImplementationPrompt, ProcessStdoutView, +│ │ # SlashCommandMenu, UndoSelector, ThemedGradient) +│ ├── ui/components/ # Reusable Ink components (MessageView, DropdownMenu, ModelsDropdown, +│ │ # RawModeExitPrompt, SkillsDropdown, FileMentionMenu, etc.) │ ├── ui/core/ # Prompt buffer, slash commands, file mentions, clipboard, undo/redo │ ├── ui/hooks/ # Custom hooks (cursor, history navigation, paste handling, terminal input, statusline) │ ├── ui/contexts/ # React contexts (AppContext, RawModeContext) │ ├── ui/statusline/ # Pluggable statusline providers (command, module) -│ ├── ui/utils/ # Shared UI utilities (writing, formatting) +│ ├── ui/utils/ # Shared UI utilities (writing, formatting) │ └── tests/ # UI-focused tests with run-tests.mjs runner ├── vscode-ide-companion/ # VSCode extension companion │ └── src/ # extension.ts, provider.ts, utils.ts -docs/ # User-facing documentation (configuration, MCP, notify, permissions) +docs/ # User-facing documentation (configuration, MCP, permissions, plan-mode, +│ # agent-skills, AGENTS.md, architecture, statusline, session-persistence, etc.) +resources/ # Static assets (intro screenshots) scripts/ # Build, release, and packaging scripts dist/ # Bundled CLI output — single-file dist/cli.js (gitignored) -dist/bundled/ # Bundled skills & references shipped with the CLI ``` -Templates for tool descriptions and prompts are at `packages/cli/dist/templates/` (copied during build from `packages/core/templates/`). Built-in skills are under `packages/cli/dist/bundled/`. +Templates for tool descriptions and prompts live at `packages/core/templates/` (copied to `packages/cli/dist/templates/` during build). Built-in skills are at `packages/core/templates/skills/bundled/`. ## Build, Test, and Development Commands @@ -79,7 +85,7 @@ Run the CLI locally for manual testing: `node packages/cli/dist/cli.js` (after ` **TypeScript**: Strict mode enabled (`strict: true`). Use `import type` for type-only imports (`@typescript-eslint/consistent-type-imports`). Unused variables prefixed with `_` are allowed (`argsIgnorePattern: "^_"`). Target ES2022, module ESNext with bundler resolution. JSX is `react-jsx`. -**Formatting/Linting**: Prettier (double quotes, 2-space indent, semicolons) + ESLint (typescript-eslint, react-hooks). Run `npm run check` before pushing. On commit, Husky + lint-staged auto-formats staged `*.{ts,tsx,js,mjs,cjs,jsx}` and `*.json` files. +**Formatting/Linting**: Prettier (double quotes, 2-space indent, semicolons) + ESLint (typescript-eslint, react-hooks). Run `npm run check` before pushing. On commit, Husky invokes lint-staged to auto-format staged files. **File naming**: `kebab-case.ts` for modules, `kebab-case.tsx` for React/Ink components. Test files: `*.test.ts` (always kebab-case). @@ -135,5 +141,5 @@ A **file history system** (`packages/core/src/common/file-history.ts`) provides - **AGENTS.md loading**: The CLI loads agent instructions from `./AGENTS.md`, `./.deepcode/AGENTS.md`, or `~/.deepcode/AGENTS.md` (first found wins). - **Skills**: Place skill definitions in `~/.agents/skills//SKILL.md` (user-level) or `./.agents/skills//SKILL.md` (project-level). Legacy path `./.deepcode/skills/` is also supported. Each SKILL.md uses YAML frontmatter with `name` and `description` fields. -- **Built-in skills**: Four bundled skills ship with the CLI — `plan` (task planning workflow), `deepcode-self-refer` (Deep Code CLI documentation), `skill-digester` (digest & install skills), `skill-writer` (create & debug skills). Additionally, `karpathy-guidelines` (behavioral guidelines to reduce LLM coding mistakes) is injected as a default skill template. +- **Built-in skills**: Three bundled skills ship with the CLI — `deepcode-self-refer` (Deep Code CLI documentation), `skill-digester` (digest & install skills), `skill-writer` (create & debug skills). Additionally, `karpathy-guidelines` (behavioral guidelines to reduce LLM coding mistakes) is injected as a default skill template. Plan Mode is driven by a prompt template at `packages/core/templates/prompts/plan.md`. - **Prompt file references**: Use `@path/to/file` syntax in prompts to load file contents through the read tool. diff --git a/package-lock.json b/package-lock.json index 8da30426..51124bcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vegamo/deepcode", - "version": "0.1.34", + "version": "0.1.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vegamo/deepcode", - "version": "0.1.34", + "version": "0.1.35", "license": "MIT", "workspaces": [ "packages/*" @@ -73,22 +73,22 @@ } }, "node_modules/@azure/abort-controller": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", - "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg==", "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-auth": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", - "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg==", "dev": true, "license": "MIT", "dependencies": { @@ -97,13 +97,13 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-client": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.2.tgz", - "integrity": "sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.0.tgz", + "integrity": "sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==", "dev": true, "license": "MIT", "dependencies": { @@ -116,13 +116,13 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-rest-pipeline": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.24.0.tgz", - "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA==", "dev": true, "license": "MIT", "dependencies": { @@ -135,26 +135,26 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-tracing": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", - "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ==", "dev": true, "license": "MIT", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/core-util": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", - "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw==", "dev": true, "license": "MIT", "dependencies": { @@ -163,7 +163,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/identity": { @@ -190,9 +190,9 @@ } }, "node_modules/@azure/logger": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", - "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA==", "dev": true, "license": "MIT", "dependencies": { @@ -200,26 +200,26 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@azure/msal-browser": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.14.0.tgz", - "integrity": "sha512-Dfl7hPZe9/JJwRhFFXHq2z1oHYBuGubmff3kWXOsd1AGgyXlqjNYAWuN/1JL/ZrcZBs8TKMjGSil6Rcc7E8VPQ==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.1.tgz", + "integrity": "sha512-zBhRGzABKSI7hfWh5EaZmril5ybZ7imBN1qEZl5sDTaelr+l8SnPjZO50Q4dnKnm347YPIlBMSnXKZyh3Yu5DQ==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "16.9.0" + "@azure/msal-common": "16.11.2" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.9.0.tgz", - "integrity": "sha512-1MWGjqgUCRAYgLmVFZKp7fs3Rg1TFvIMgywY8ze2olNVvLlJoRThuoziWSDJuwwyJI5L4rnLb9Tyt5D9GvSLPw==", + "version": "16.11.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.2.tgz", + "integrity": "sha512-yDhtBOGDCdK9ipQ9g3+wmlMEPnZx2pXaDicDd9jYyR1L+7lEbvEohTDmF5qejZDutZY3m9pWPxeYxzNC701A2w==", "dev": true, "license": "MIT", "engines": { @@ -227,13 +227,13 @@ } }, "node_modules/@azure/msal-node": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.5.tgz", - "integrity": "sha512-RUuewWk9JvWJS5Yiy8/74Lm1rQAWlrU/qg/Bgtk1jIauVRtnb9XKwS5Xg0J+Whwjesq9EVrBIFgQEP8vHxgezA==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.4.2.tgz", + "integrity": "sha512-fnqOpGYAV+i0RH4W5HB6Oy1IhqGZoCdnp7Y2Sa9k18FlT8aBkCA7L8Hv19hUHLDUK6kVjUO29AfnGX6wgAHyNg==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "16.9.0", + "@azure/msal-common": "16.11.2", "jsonwebtoken": "^9.0.0" }, "engines": { @@ -921,9 +921,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { @@ -1004,9 +1004,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", "dev": true, "license": "MIT", "dependencies": { @@ -1016,7 +1016,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -1028,9 +1028,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -1469,23 +1469,6 @@ "node": ">=8" } }, - "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@textlint/linter-formatter/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/@textlint/linter-formatter/node_modules/pluralize": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", @@ -1493,21 +1476,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@textlint/linter-formatter/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/@textlint/linter-formatter/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -1601,9 +1569,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "25.9.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz", + "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==", "dev": true, "license": "MIT", "dependencies": { @@ -1664,17 +1632,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", - "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/type-utils": "8.61.1", - "@typescript-eslint/utils": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1687,15 +1655,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.1", + "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -1703,16 +1671,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", - "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "engines": { @@ -1728,14 +1696,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", - "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.1", - "@typescript-eslint/types": "^8.61.1", + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "engines": { @@ -1750,14 +1718,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", - "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1" + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1768,9 +1736,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", - "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", "dev": true, "license": "MIT", "engines": { @@ -1785,15 +1753,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", - "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1810,9 +1778,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", - "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", "dev": true, "license": "MIT", "engines": { @@ -1824,16 +1792,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", - "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.1", - "@typescript-eslint/tsconfig-utils": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1862,16 +1830,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -1891,9 +1859,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1904,16 +1872,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", - "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1928,13 +1896,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", - "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1959,9 +1927,9 @@ } }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.6.tgz", - "integrity": "sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.7.tgz", + "integrity": "sha512-JVUD8X2tfDMWjcjLs4yVxxVrS8yR5vnh386GAXT9Qj79nBxxXSaHFQZg5FweLmT8HlPQ3kii6noUB+Z9RN7DvQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1970,7 +1938,7 @@ "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@vegamo/deepcode-cli": { @@ -2184,16 +2152,16 @@ } }, "node_modules/@vscode/vsce/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@vscode/vsce/node_modules/minimatch": { @@ -2213,9 +2181,9 @@ } }, "node_modules/@vscode/vsce/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -2394,9 +2362,9 @@ "optional": true }, "node_modules/baseline-browser-mapping": { - "version": "2.10.38", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", - "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.3.tgz", + "integrity": "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2450,9 +2418,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2474,9 +2442,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", "dev": true, "funding": [ { @@ -2494,10 +2462,10 @@ ], "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", + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { @@ -2608,9 +2576,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "dev": true, "funding": [ { @@ -2725,9 +2693,9 @@ } }, "node_modules/cli-truncate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", - "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", "license": "MIT", "dependencies": { "slice-ansi": "^9.0.0", @@ -2740,6 +2708,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -2766,6 +2750,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/cliui/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -3175,16 +3165,17 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding-sniffer": { @@ -3286,13 +3277,14 @@ } }, "node_modules/es-toolkit": { - "version": "1.47.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", - "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types" ] }, "node_modules/esbuild": { @@ -3360,9 +3352,9 @@ } }, "node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", "dependencies": { @@ -3371,8 +3363,8 @@ "@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/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3562,13 +3554,6 @@ "node": ">=0.10.0" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -3644,9 +3629,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -3758,9 +3743,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", "dev": true, "license": "ISC" }, @@ -3790,9 +3775,9 @@ "optional": true }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "dependencies": { @@ -3949,16 +3934,16 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/glob/node_modules/minimatch": { @@ -4012,15 +3997,28 @@ } }, "node_modules/globby/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { "node": ">= 4" } }, + "node_modules/globby/node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4091,9 +4089,9 @@ } }, "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -4395,9 +4393,9 @@ "optional": true }, "node_modules/ink": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.0.tgz", - "integrity": "sha512-VWE6/yeLtFCJBNLflyI2OSylyXK1Rc24LuXup8Qt+icwkmmycFNdbn8IkSp6Frc0h1iA0NOvvi1ajW44U/w3Qg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", @@ -4488,6 +4486,43 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/ink/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -4643,9 +4678,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -4756,9 +4791,9 @@ } }, "node_modules/jsonwebtoken/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -4848,9 +4883,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -4867,14 +4902,13 @@ } }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.2.0.tgz", + "integrity": "sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==", "dev": true, "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, @@ -4891,70 +4925,6 @@ "yaml": "^2.9.0" } }, - "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "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.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5041,226 +5011,78 @@ "dev": true, "license": "MIT" }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "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" + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">= 0.4" } }, - "node_modules/log-update/node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 8" } }, - "node_modules/log-update/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", "dependencies": { - "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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" @@ -5324,19 +5146,6 @@ "node": ">=6" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -5423,9 +5232,9 @@ "license": "MIT" }, "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", "dev": true, "license": "MIT", "optional": true, @@ -5437,9 +5246,9 @@ } }, "node_modules/node-abi/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "optional": true, @@ -5459,9 +5268,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "dev": true, "license": "MIT", "engines": { @@ -5483,45 +5292,44 @@ } }, "node_modules/normalize-package-data": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", - "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", "license": "BSD-2-Clause", "dependencies": { - "hosted-git-info": "^7.0.0", + "hosted-git-info": "^9.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-package-data/node_modules/hosted-git-info": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", - "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", - "dev": true, + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "license": "ISC", "dependencies": { - "lru-cache": "^10.0.1" + "lru-cache": "^11.1.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-package-data/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", - "dev": true, + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5601,24 +5409,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/openai": { - "version": "6.44.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz", - "integrity": "sha512-09/gH+8jH0RgUwsgWHAaxsKGRT5zVZ95IaJUnqAWj6XejIBmnFRwq2WUIF37VtDEsmGrtPmvCs5+yBSeZGWvkA==", - "license": "Apache-2.0", - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5670,9 +5460,9 @@ } }, "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz", + "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", "dev": true, "license": "MIT", "engines": { @@ -5844,9 +5634,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5880,9 +5670,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -5942,9 +5732,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -5989,13 +5779,14 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -6067,9 +5858,9 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6086,28 +5877,6 @@ "ws": "^7" } }, - "node_modules/react-devtools-core/node_modules/ws": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", - "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/react-reconciler": { "version": "0.33.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", @@ -6153,125 +5922,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-package-up/node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/read-package-up/node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/read-package-up/node_modules/normalize-package-data": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", - "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", - "license": "BSD-2-Clause", - "dependencies": { - "hosted-git-info": "^9.0.0", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/read-package-up/node_modules/read-pkg": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", - "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.4", - "normalize-package-data": "^8.0.0", - "parse-json": "^8.3.0", - "type-fest": "^5.4.4", - "unicorn-magic": "^0.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-package-up/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/read-package-up/node_modules/unicorn-magic": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", - "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", - "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/normalize-package-data": "^2.4.3", - "normalize-package-data": "^6.0.0", - "parse-json": "^8.0.0", - "type-fest": "^4.6.0", - "unicorn-magic": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg/node_modules/unicorn-magic": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", - "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", - "dev": true, + "node_modules/read-pkg": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", + "integrity": "sha512-I8g2lArQiP78ll51UeMZojewtYgIRCKCWqZEgOO8c/uefTI+XDXvCSXu3+YNUaTNvZzobrL5+SqHjBrByRRTdg==", "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.4", + "normalize-package-data": "^8.0.0", + "parse-json": "^8.3.0", + "type-fest": "^5.4.4", + "unicorn-magic": "^0.4.0" + }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6340,13 +6004,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/run-applescript": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", @@ -6413,9 +6070,9 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -6450,6 +6107,100 @@ "node": ">=20.0.0" } }, + "node_modules/secretlint/node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/secretlint/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/secretlint/node_modules/normalize-package-data": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", + "integrity": "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^7.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/secretlint/node_modules/read-pkg": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-9.0.1.tgz", + "integrity": "sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.3", + "normalize-package-data": "^6.0.0", + "parse-json": "^8.0.0", + "type-fest": "^4.6.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/secretlint/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/secretlint/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/secretlint/node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", @@ -6497,9 +6248,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "devOptional": true, "license": "MIT", "engines": { @@ -6762,19 +6513,51 @@ } }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=20" + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, "node_modules/strip-ansi": { @@ -6898,13 +6681,6 @@ "node": ">=8" } }, - "node_modules/table/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/table/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6940,21 +6716,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/table/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/table/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -6981,9 +6742,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", "dev": true, "license": "MIT", "optional": true, @@ -7151,9 +6912,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7207,9 +6968,9 @@ } }, "node_modules/type-fest": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", - "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -7248,16 +7009,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", - "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", + "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1" + "@typescript-eslint/eslint-plugin": "8.65.0", + "@typescript-eslint/parser": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7285,9 +7046,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -7301,13 +7062,12 @@ "license": "MIT" }, "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7457,6 +7217,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/widest-line/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7496,6 +7272,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -7505,16 +7297,17 @@ "optional": true }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "devOptional": true, "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">=8.3.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "utf-8-validate": "^5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -7624,6 +7417,12 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/yargs/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -7707,7 +7506,7 @@ }, "packages/cli": { "name": "@vegamo/deepcode-cli", - "version": "0.1.34", + "version": "0.1.35", "license": "MIT", "dependencies": { "@vegamo/deepcode-core": "file:../core", @@ -7732,8 +7531,6 @@ }, "packages/cli/node_modules/chalk": { "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -7744,8 +7541,6 @@ }, "packages/cli/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { "node": ">= 4" @@ -7753,7 +7548,7 @@ }, "packages/core": { "name": "@vegamo/deepcode-core", - "version": "0.1.34", + "version": "0.1.35", "license": "MIT", "dependencies": { "chalk": "^5.6.2", @@ -7767,8 +7562,6 @@ }, "packages/core/node_modules/chalk": { "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -7779,13 +7572,63 @@ }, "packages/core/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { "node": ">= 4" } }, + "packages/core/node_modules/openai": { + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "packages/core/node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/vscode-ide-companion": { "name": "deepcode-vscode", "version": "0.1.23", diff --git a/package.json b/package.json index 4a0e580e..4f2e5101 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode", - "version": "0.1.34", + "version": "0.1.35", "description": "Deep Code — CLI, core library, and VSCode companion", "license": "MIT", "packageManager": "npm@10.9.4", diff --git a/packages/cli/package.json b/packages/cli/package.json index 98bb90c7..ac8740d1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-cli", - "version": "0.1.34", + "version": "0.1.35", "description": "Deep Code CLI - Vibe coding for the deepseek-v4 model in your terminal", "license": "MIT", "type": "module", diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts index 5a1d6f44..cdde6002 100644 --- a/packages/cli/src/cli-args.ts +++ b/packages/cli/src/cli-args.ts @@ -29,6 +29,8 @@ export interface ParsedCliArgs { * - `string` — --resume was used */ resume: string | true | undefined; + /** When true, create a branch from the resumed session instead of resuming directly. */ + branch: boolean; /** True when --version / -v was passed */ version: boolean; /** True when --help / -h was passed */ @@ -89,6 +91,11 @@ async function configureYargs(argv?: string[]) { type: "string", describe: "Resume a specific session by its ID. Use without an ID to show session picker.", }) + .option("branch", { + alias: "b", + type: "boolean", + describe: "Create a new branch from the resumed session (used with --resume).", + }) .check((argv: { [x: string]: unknown }) => { const query = argv["query"] as string | string[] | undefined; const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query; @@ -156,6 +163,7 @@ export async function parseArguments(argv?: string[]): Promise { return { prompt: parsed.prompt as string | undefined, resume, + branch: parsed.branch === true, version: parsed.version === true, help: parsed.help === true, }; diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 80b11f08..6cde7f94 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -30,6 +30,7 @@ async function main(): Promise { let initialPrompt = parsed.prompt; let resumeSessionId = parsed.resume; + let branchMode = parsed.branch; const projectRoot = process.cwd(); if (!process.stdin.isTTY) { @@ -68,12 +69,15 @@ async function main(): Promise { initialPrompt = undefined; const appResumeSessionId = resumeSessionId; resumeSessionId = undefined; + const appBranchMode = branchMode; + branchMode = false; const inkInstance = render( restartRef.current?.()} />, { exitOnCtrlC: false } diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index 94ce95a4..161a7481 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -31,6 +31,8 @@ test("buildSlashCommands prefixes skills before built-ins", () => { "mcp", "raw", "exit", + "cost", + "compact", ]); }); diff --git a/packages/cli/src/ui/components/ContextBar.tsx b/packages/cli/src/ui/components/ContextBar.tsx new file mode 100644 index 00000000..2bff01f0 --- /dev/null +++ b/packages/cli/src/ui/components/ContextBar.tsx @@ -0,0 +1,124 @@ +import React, { useMemo } from "react"; +import { Box, Text } from "ink"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ContextUsage = { + activeTokens: number; + totalTokens: number; + maxContextTokens: number; + percentage: number; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const BAR_WIDTH = 20; + +/** Context usage >= this % triggers a yellow warning. */ +const YELLOW_THRESHOLD = 70; + +/** Context usage >= this % triggers a red alert. */ +const RED_THRESHOLD = 90; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatTokens(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1)}M`; + } + if (n >= 1_000) { + return `${Math.round(n / 1000)}k`; + } + return String(n); +} + +function buildBar(filled: number, empty: number): string { + return "\u2588".repeat(filled) + "\u2591".repeat(empty); +} + +// --------------------------------------------------------------------------- +// ContextBar +// --------------------------------------------------------------------------- + +/** + * Renders a context-usage status bar showing token consumption as a + * progress bar with percentage, and triggers visual warnings when usage + * exceeds configurable thresholds. + * + * - < 70%: dimmed, no warning + * - >= 70%: yellow with ⚠️ "Try /compact to compress context." + * - >= 90%: red with 🚨 "Context nearly full. Consider /clear or /compact." + */ +export const ContextBar = React.memo(function ContextBar({ + usage, +}: { + usage: ContextUsage | null; +}): React.ReactElement | null { + const rendered = useMemo(() => { + if (!usage || usage.maxContextTokens <= 0) { + return null; + } + + const { percentage, totalTokens, maxContextTokens } = usage; + const clamped = Math.min(100, Math.max(0, percentage)); + const filled = Math.max(0, Math.min(BAR_WIDTH, Math.round((clamped / 100) * BAR_WIDTH))); + const empty = BAR_WIDTH - filled; + const bar = buildBar(filled, empty); + + const tokensText = formatTokens(totalTokens); + const maxText = formatTokens(maxContextTokens); + + let color: string | undefined; + let icon: string | null = null; + let hint: string | null = null; + + if (clamped >= RED_THRESHOLD) { + color = "red"; + icon = "\uD83D\uDEA8"; // 🚨 + hint = " Context nearly full. Consider /clear or /compact."; + } else if (clamped >= YELLOW_THRESHOLD) { + color = "yellow"; + icon = "\u26A0\uFE0F"; // ⚠️ + hint = " Try /compact to compress context."; + } + + return { + bar, + tokensText, + maxText, + clamped, + color, + icon, + hint, + }; + }, [usage]); + + if (!rendered) { + return null; + } + + const { bar, tokensText, maxText, clamped, color, icon, hint } = rendered; + + return ( + + + + [{bar}] {clamped}% Tokens: {tokensText}/{maxText} + + {icon && hint && ( + + {" "} + {icon} + {hint} + + )} + + + ); +}); diff --git a/packages/cli/src/ui/components/index.ts b/packages/cli/src/ui/components/index.ts index f3cbd675..e4a51042 100644 --- a/packages/cli/src/ui/components/index.ts +++ b/packages/cli/src/ui/components/index.ts @@ -5,3 +5,5 @@ export { default as SkillsDropdown } from "./SkillsDropdown"; export { default as ModelsDropdown } from "./ModelsDropdown"; export { default as FileMentionMenu } from "./FileMentionMenu"; export { default as DropdownMenu } from "./DropdownMenu"; +export { ContextBar } from "./ContextBar"; +export type { ContextUsage } from "./ContextBar"; diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index 38646611..584cbcc2 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -12,7 +12,9 @@ export type SlashCommandKind = | "undo" | "mcp" | "raw" - | "exit"; + | "exit" + | "cost" + | "compact"; export type SlashCommandItem = { kind: SlashCommandKind; @@ -91,6 +93,18 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ label: "/exit", description: "Quit Deep Code CLI", }, + { + kind: "cost", + name: "cost", + label: "/cost", + description: "Show token usage and cost for the current session", + }, + { + kind: "compact", + name: "compact", + label: "/compact", + description: "Compress conversation history to free context space", + }, ]; export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { diff --git a/packages/cli/src/ui/utils/index.ts b/packages/cli/src/ui/utils/index.ts index 6feb0306..d461401e 100644 --- a/packages/cli/src/ui/utils/index.ts +++ b/packages/cli/src/ui/utils/index.ts @@ -101,3 +101,45 @@ export function formatThinkingMode( export function formatModelConfig(settings: ModelConfigSelection): string { return `${settings.model}, ${formatThinkingMode(settings)}`; } + +export type CostReportData = { + promptTokens: number; + completionTokens: number; + totalTokens: number; + inputCost: number; + outputCost: number; + totalCost: number; + maxContextTokens: number; +}; + +export function formatLargeNumber(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1)}M`; + } + if (n >= 1_000) { + return `${Math.round(n / 1000).toLocaleString("en-US")}k`; + } + return n.toLocaleString("en-US"); +} + +/** Build a formatted cost report string for the /cost command. */ +export function buildCostReport(data: CostReportData): string { + const usagePercent = + data.maxContextTokens > 0 ? `${((data.totalTokens / data.maxContextTokens) * 100).toFixed(2)}%` : "N/A"; + const usageTokens = + data.maxContextTokens > 0 + ? `${formatLargeNumber(data.totalTokens)}/${formatLargeNumber(data.maxContextTokens)}` + : formatLargeNumber(data.totalTokens); + + return [ + "📊 Session Cost", + "────────────────", + `Input tokens : ${formatLargeNumber(data.promptTokens)}`, + `Output tokens : ${formatLargeNumber(data.completionTokens)}`, + `Total tokens : ${formatLargeNumber(data.totalTokens)}`, + `Input cost : $${data.inputCost.toFixed(4)}`, + `Output cost : $${data.outputCost.toFixed(4)}`, + `Total cost : $${data.totalCost.toFixed(4)}`, + `Context usage : ${usagePercent} (${usageTokens})`, + ].join("\n"); +} diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index eb30dd96..93650fa0 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -6,6 +6,8 @@ import type { PermissionScope } from "@vegamo/deepcode-core"; import { type ModelConfigSelection } from "@vegamo/deepcode-core"; import { type PromptDraft, PromptInput, type PromptSubmission } from "./PromptInput"; import { MessageView, RawModeExitPrompt } from "../components"; +import { ContextBar } from "../components"; +import type { ContextUsage } from "../components"; import { SessionList } from "./SessionList"; import { type UndoRestoreMode, UndoSelector } from "./UndoSelector"; import { buildLoadingText } from "../core/loading-text"; @@ -28,6 +30,7 @@ import { buildPromptDraftFromSessionMessage, buildStatusLine, buildSyntheticUserMessage, + buildCostReport, formatModelConfig, isCurrentSessionEmpty, renderRawModeMessages, @@ -49,6 +52,8 @@ import type { } from "@vegamo/deepcode-core"; import { SessionManager } from "@vegamo/deepcode-core"; import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; +import { CostCalculator } from "@vegamo/deepcode-core"; +import type { CostBreakdown } from "@vegamo/deepcode-core"; import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers"; type View = "chat" | "session-list" | "undo" | "mcp-status"; @@ -59,6 +64,7 @@ type AppProps = { projectRoot: string; initialPrompt?: string; resumeSessionId?: string | true; + branch?: boolean; onRestart?: () => void; }; @@ -95,7 +101,7 @@ const StatusLine = React.memo(function StatusLine({ ); }); -function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProps): React.ReactElement { +function App({ projectRoot, initialPrompt, resumeSessionId, branch = false, onRestart }: AppProps): React.ReactElement { const { exit } = useApp(); const { stdout, write } = useStdout(); const { columns, rows } = useWindowSize(); @@ -366,6 +372,14 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp navigateToSubView("mcp-status"); return; } + if (submission.command === "cost") { + void handleCostCommand(); + return; + } + if (submission.command === "compact") { + void handleCompactCommand(); + return; + } const prompt: UserPromptContent = { text: submission.text, @@ -428,6 +442,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp ); } }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ sessionManager, pendingPermissionReply, @@ -502,6 +517,142 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [projectRoot, sessionManager] ); + /** Builds and displays the /cost report as a system message. */ + const handleCostCommand = useCallback(() => { + const activeSessionId = sessionManager.getActiveSessionId(); + const session = activeSessionId ? sessionManager.getSession(activeSessionId) : null; + const usage = session?.usage; + const settings = resolveCurrentSettings(projectRoot); + const maxContextTokens = settings.maxContextTokens ?? 1_000_000; + + const promptTokens = usage && typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0; + const completionTokens = usage && typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0; + const totalTokens = + usage && typeof usage.total_tokens === "number" ? usage.total_tokens : promptTokens + completionTokens; + + // Calculate cost from per-session usage + const calc = new CostCalculator(projectRoot, settings.pricing); + let cost: CostBreakdown | null = null; + if (activeSessionId) { + cost = calc.getSessionCost(activeSessionId); + } + if (!cost) { + // Compute cost directly from the usage object + cost = usage ? calc.calculateCostFromUsage(usage) : calc.calculateCost(0, 0, 0); + } + + const report = buildCostReport({ + promptTokens, + completionTokens, + totalTokens, + inputCost: cost.inputCost + cost.inputCacheHitCost, + outputCost: cost.outputCost, + totalCost: cost.totalCost, + maxContextTokens, + }); + + const meta: MessageMeta = { asThinking: true }; + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, report, true, meta); + } else { + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + sessionId: "local", + role: "system" as const, + content: report, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta, + }, + ]); + } + }, [projectRoot, sessionManager]); + + /** Triggers conversation compaction via the session manager. */ + const handleCompactCommand = useCallback(async () => { + const activeSessionId = sessionManager.getActiveSessionId(); + if (!activeSessionId) { + setErrorLine("No active session to compact."); + return; + } + + const session = sessionManager.getSession(activeSessionId); + if (!session) { + setErrorLine("Session not found."); + return; + } + + const usageBefore = session.usage; + const tokensBefore = usageBefore && typeof usageBefore.total_tokens === "number" ? usageBefore.total_tokens : 0; + + // Show "compacting..." message + const compactingMeta: MessageMeta = { asThinking: true }; + const compactingContent = "Compacting conversation history..."; + + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, compactingContent, true, compactingMeta); + } else { + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + sessionId: "local", + role: "system" as const, + content: compactingContent, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta: compactingMeta, + }, + ]); + } + + try { + await sessionManager.compactSession(activeSessionId); + + // Refresh session data + refreshSessionsList(); + const updatedSession = sessionManager.getSession(activeSessionId); + const usageAfter = updatedSession?.usage; + const tokensAfter = usageAfter && typeof usageAfter.total_tokens === "number" ? usageAfter.total_tokens : 0; + const freed = Math.max(0, tokensBefore - tokensAfter); + + const successContent = [ + "✅ Context compacted.", + `Tokens before: ${tokensBefore.toLocaleString("en-US")}`, + `Tokens after : ${tokensAfter.toLocaleString("en-US")}`, + `Freed : ${freed.toLocaleString("en-US")} tokens`, + ].join("\n"); + + const successMeta: MessageMeta = { asThinking: true }; + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, successContent, true, successMeta); + } + // Reload visible messages for the active session + setMessages(loadVisibleMessages(sessionManager, activeSessionId)); + setShowWelcome(true); + } catch (error) { + const errMessage = error instanceof Error ? error.message : String(error); + setErrorLine(`Compact failed: ${errMessage}`); + const failContent = `❌ Compaction failed: ${errMessage}`; + const failMeta: MessageMeta = { asThinking: true }; + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, failContent, true, failMeta); + } + } + }, [sessionManager, refreshSessionsList]); + const handleSubmit = useCallback( (submission: PromptSubmission) => { void handlePrompt(submission); @@ -540,7 +691,17 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp ); const handleSelectSession = useCallback( - async (sessionId: string) => { + async (sessionId: string, branchMode: boolean) => { + if (branchMode) { + const newSessionId = sessionManager.createBranchSession(sessionId); + if (!newSessionId) { + setErrorLine("Failed to create branch session."); + navigateToSubView("chat"); + setShowWelcome(true); + return; + } + sessionId = newSessionId; + } sessionManager.setActiveSessionId(sessionId); // Clear first so resets its index to 0. await resetStaticView(loadVisibleMessages(sessionManager, sessionId), { clearScreen: true }); @@ -555,8 +716,9 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setPendingPermissionReply(null); } await refreshSkills(sessionId); + refreshSessionsList(); }, - [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills] + [sessionManager, resetStaticView, pendingPermissionReply, refreshSkills, refreshSessionsList, navigateToSubView] ); /** @@ -579,7 +741,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp navigateToSubView("session-list"); return; } - await handleSelectSession(resumeSessionId); + await handleSelectSession(resumeSessionId, branch); } // Step 2: Submit prompt if provided @@ -594,7 +756,15 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp } void run(); - }, [handleSubmit, handleSelectSession, initialPrompt, navigateToSubView, refreshSessionsList, resumeSessionId]); + }, [ + handleSubmit, + handleSelectSession, + initialPrompt, + navigateToSubView, + refreshSessionsList, + resumeSessionId, + branch, + ]); const handleDeleteSession = useCallback( async (id: string): Promise => { @@ -672,7 +842,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp renderRawModeMessages(allMessages, nextMode); } else if (activeSessionId) { // Switch to chat view to render messages. - handleSelectSession(activeSessionId); + handleSelectSession(activeSessionId, false); } else { // No active session: just show the welcome screen once. setWelcomeNonce((n) => n + 1); @@ -781,6 +951,25 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp toolUsage, }; }, [sessionManager, projectRoot]); + + const contextUsage = useMemo((): ContextUsage | null => { + const info = getSessionInfo(); + if (!info || info.activeSessionId === null) { + return null; + } + if (info.maxContextTokens <= 0) { + return null; + } + const percentage = Math.min(100, Math.round((info.totalTokens / info.maxContextTokens) * 100)); + return { + activeTokens: info.activeTokens, + totalTokens: info.totalTokens, + maxContextTokens: info.maxContextTokens, + percentage, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [getSessionInfo, messages.length, activeStatus, statusLine]); + const statusLineSegments = useStatusLine(resolvedSettings.statusline, projectRoot, getSessionInfo); const promptHistory = useMemo(() => { return messages @@ -934,6 +1123,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp }} {(busy || statusLine) && !isExiting ? : null} + {errorLine ? ( Error: {errorLine} @@ -951,7 +1141,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp ) : view === "session-list" ? ( void handleSelectSession(id)} + onSelect={(id, branchMode) => void handleSelectSession(id, branchMode)} onCancel={() => setView("chat")} onDelete={(id) => { void handleDeleteSession(id); diff --git a/packages/cli/src/ui/views/AppContainer.tsx b/packages/cli/src/ui/views/AppContainer.tsx index 555588f1..58111286 100644 --- a/packages/cli/src/ui/views/AppContainer.tsx +++ b/packages/cli/src/ui/views/AppContainer.tsx @@ -8,14 +8,16 @@ const AppContainer: React.FC<{ version: string; initialPrompt: string | undefined; resumeSessionId: string | true | undefined; + branch?: boolean; onRestart: () => void; -}> = ({ version, projectRoot, initialPrompt, resumeSessionId, onRestart }) => { +}> = ({ version, projectRoot, initialPrompt, resumeSessionId, branch = false, onRestart }) => { return ( diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx index 799dec7a..55fb4cd4 100644 --- a/packages/cli/src/ui/views/PromptInput.tsx +++ b/packages/cli/src/ui/views/PromptInput.tsx @@ -73,7 +73,7 @@ export type PromptSubmission = { permissions?: UserToolPermission[]; alwaysAllows?: PermissionScope[]; planMode?: boolean; - command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit"; + command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit" | "cost" | "compact"; }; export type PromptDraft = { @@ -735,6 +735,16 @@ export const PromptInput = React.memo(function PromptInput({ clearUndoRedoStacks(); return; } + if (item.kind === "cost") { + onSubmit({ text: "/cost", imageUrls: [], command: "cost" }); + resetPromptInput(); + return; + } + if (item.kind === "compact") { + onSubmit({ text: "/compact", imageUrls: [], command: "compact" }); + resetPromptInput(); + return; + } } function submitCurrentBuffer(): void { diff --git a/packages/cli/src/ui/views/SessionList.tsx b/packages/cli/src/ui/views/SessionList.tsx index a41cae3a..52e6df2d 100644 --- a/packages/cli/src/ui/views/SessionList.tsx +++ b/packages/cli/src/ui/views/SessionList.tsx @@ -5,7 +5,7 @@ import { truncate } from "../components/MessageView/utils"; type Props = { sessions: SessionEntry[]; - onSelect: (sessionId: string) => void; + onSelect: (sessionId: string, branch: boolean) => void; onCancel: () => void; onDelete?: (sessionId: string) => void; onRename?: (sessionId: string, newName: string) => void; @@ -43,6 +43,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename } const [index, setIndex] = useState(0); const [searchQuery, setSearchQuery] = useState(""); const [confirmDeleteSessionId, setConfirmDeleteSessionId] = useState(null); + const [confirmBranchSessionId, setConfirmBranchSessionId] = useState(null); const [renameSessionId, setRenameSessionId] = useState(null); const [renameValue, setRenameValue] = useState(""); const [renameCursor, setRenameCursor] = useState(0); @@ -146,6 +147,21 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename } return; } + // If in branch confirmation mode, handle y/n + if (confirmBranchSessionId) { + if (input === "y" || input === "Y") { + onSelect(confirmBranchSessionId, true); + setConfirmBranchSessionId(null); + return; + } + if (input === "n" || input === "N" || key.escape) { + onSelect(confirmBranchSessionId, false); + setConfirmBranchSessionId(null); + return; + } + return; + } + // If in delete confirmation mode, handle confirm/cancel if (confirmDeleteSessionId) { if (key.return) { @@ -244,7 +260,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename } if (key.return) { const session = filteredSessions[safeIndex]; if (session) { - onSelect(session.id); + setConfirmBranchSessionId(session.id); } } }); @@ -311,6 +327,7 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename } const actualIndex = scrollOffset + i; const isSelected = actualIndex === safeIndex; const isConfirming = confirmDeleteSessionId === session.id; + const isBranchConfirming = confirmBranchSessionId === session.id; const isRenaming = renameSessionId === session.id; return ( @@ -332,6 +349,8 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename } )} {isConfirming ? ( [Delete? Enter=yes, Esc=no] + ) : isBranchConfirming ? ( + [是否基于此会话创建新分支?(y/n)] ) : isRenaming ? null : ( ({formatSessionStatus(session.status)}) )} @@ -367,6 +386,18 @@ export function SessionList({ sessions, onSelect, onCancel, onDelete, onRename } to cancel + ) : confirmBranchSessionId ? ( + + 是否基于此会话创建新分支? + + y + + = 是(创建分支) · + + n / Esc + + = 否(直接恢复) + ) : confirmDeleteSessionId ? ( Delete this session? diff --git a/packages/core/package.json b/packages/core/package.json index 37209705..2ddaa920 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@vegamo/deepcode-core", - "version": "0.1.34", + "version": "0.1.35", "description": "Deep Code core library — LLM session management, tool execution, and shared utilities", "license": "MIT", "type": "module", diff --git a/packages/core/src/common/code-index.ts b/packages/core/src/common/code-index.ts new file mode 100644 index 00000000..b2e00310 --- /dev/null +++ b/packages/core/src/common/code-index.ts @@ -0,0 +1,307 @@ +export type CodeEntityType = "function" | "class" | "interface" | "type" | "enum" | "import" | "const"; + +export type CodeEntity = { + type: CodeEntityType; + name: string; + signature: string; + filePath: string; + lineNumber?: number; + capturedAt: string; + sessionTurn: number; +}; + +type ScoredEntity = { entity: CodeEntity; score: number }; + +// Extraction patterns ordered by specificity (classes before functions to avoid +// partial matches on "function" inside class bodies). +const ENTITY_PATTERNS: Array<{ type: CodeEntityType; regex: RegExp }> = [ + // export class Foo / class Foo + { + type: "class", + regex: + /(?:export\s+(?:abstract\s+)?)?class\s+(\w+)(?:\s+extends\s+[\w.]+(?:\s*<[^>]*>)?)?(?:\s+implements\s+[\w,\s]+)?\s*\{?/g, + }, + // export interface Foo / interface Foo + { + type: "interface", + regex: /(?:export\s+)?interface\s+(\w+)(?:\s+extends\s+[\w,\s]+)?\s*\{?/g, + }, + // export type Foo = / type Foo = + { type: "type", regex: /(?:export\s+)?type\s+(\w+)\s*=/g }, + // export enum Foo / enum Foo + { type: "enum", regex: /(?:export\s+)?enum\s+(\w+)\s*\{?/g }, + // export async function / async function / function + { + type: "function", + regex: /(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)(?:\s*:\s*[^{]+)?/g, + }, + // arrow const: export const foo = (...) => / const foo = (...) => + { + type: "function", + regex: /(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s*)?\([^)]*\)(?:\s*:\s*[^=]+)?\s*=>/g, + }, + // import { x } from 'y' / import x from 'y' + { + type: "import", + regex: /import\s+(?:(?:\{[^}]*\}|[\w*]+)\s*(?:,\s*(?:\{[^}]*\}|\w+))?\s*from\s*)?['"]([^'"]+)['"]/g, + }, + // const Foo = (class name is not a const) + { type: "const", regex: /(?:export\s+)?const\s+(\w+)\s*[:=]/g }, + // Python: class Foo / def foo( (allow leading whitespace for indented methods) + { type: "class", regex: /^\s*class\s+(\w+)\s*(?:\([^)]*\))?\s*:/gm }, + { type: "function", regex: /^\s*(?:async\s+)?def\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*\S+)?\s*:/gm }, +]; + +const entitiesBySession = new Map(); +const entityIdsBySession = new Map>(); + +function entityKey(entity: CodeEntity): string { + return `${entity.filePath}:${entity.type}:${entity.name}`; +} + +export class CodeIndex { + extractFromToolOutput(output: string, filePath: string, sessionTurn: number, sessionId: string): void { + if (!output || !filePath) { + return; + } + + const now = new Date().toISOString(); + + let sessionEntities = entitiesBySession.get(sessionId); + if (!sessionEntities) { + sessionEntities = []; + entitiesBySession.set(sessionId, sessionEntities); + } + + let sessionEntityIds = entityIdsBySession.get(sessionId); + if (!sessionEntityIds) { + sessionEntityIds = new Set(); + entityIdsBySession.set(sessionId, sessionEntityIds); + } + + for (const { type, regex } of ENTITY_PATTERNS) { + // Clone regex to reset lastIndex for each iteration + const re = new RegExp(regex.source, regex.flags); + let match: RegExpExecArray | null; + + while ((match = re.exec(output)) !== null) { + const name = match[1]; + if (!name || isKeywordExcluded(name)) { + continue; + } + + // Find the line number + const matchPos = match.index; + const precedingLines = output.slice(0, matchPos).split("\n"); + const lineNumber = precedingLines.length; + + // Capture the full match line as signature + const lineStart = output.lastIndexOf("\n", matchPos) + 1; + const lineEnd = output.indexOf("\n", matchPos); + const signature = (lineEnd === -1 ? output.slice(lineStart) : output.slice(lineStart, lineEnd)).trim(); + + const entity: CodeEntity = { + type, + name, + signature: signature.length > 200 ? signature.slice(0, 197) + "..." : signature, + filePath, + lineNumber, + capturedAt: now, + sessionTurn, + }; + + const key = entityKey(entity); + if (sessionEntityIds.has(key)) { + continue; // Deduplicate + } + + sessionEntityIds.add(key); + sessionEntities.push(entity); + } + } + } + + search( + sessionId: string, + query: string, + options: { filePath?: string; type?: CodeEntityType; limit?: number } = {} + ): CodeEntity[] { + const entities = entitiesBySession.get(sessionId); + if (!entities || entities.length === 0) { + return []; + } + + const limit = options.limit ?? 5; + const lowerQuery = query.toLowerCase(); + const scored: ScoredEntity[] = []; + + for (const entity of entities) { + if (options.filePath && !entity.filePath.includes(options.filePath)) { + continue; + } + if (options.type && entity.type !== options.type) { + continue; + } + + let score = 0; + if (entity.name.toLowerCase() === lowerQuery) { + score += 100; + } else if (entity.name.toLowerCase().includes(lowerQuery)) { + score += 50; + } + if (entity.signature.toLowerCase().includes(lowerQuery)) { + score += 20; + } + if (entity.filePath.toLowerCase().includes(lowerQuery)) { + score += 10; + } + + if (score > 0) { + scored.push({ entity, score }); + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit).map((s) => s.entity); + } + + getByFile(sessionId: string, filePath: string): CodeEntity[] { + const entities = entitiesBySession.get(sessionId); + if (!entities) { + return []; + } + return entities.filter((e) => e.filePath === filePath); + } + + renderForInjection(sessionId: string, maxTokens = 500): string { + const entities = entitiesBySession.get(sessionId); + if (!entities || entities.length === 0) { + return ""; + } + + // Group by file + const byFile = new Map(); + for (const entity of entities) { + const list = byFile.get(entity.filePath) ?? []; + list.push(entity); + byFile.set(entity.filePath, list); + } + + const lines: string[] = []; + let estimatedTokens = 0; + const tokenBudget = maxTokens; + + for (const [filePath, fileEntities] of byFile) { + const header = `### ${filePath}`; + const headerTokens = estimateTokens(header); + if (estimatedTokens + headerTokens > tokenBudget) { + break; + } + lines.push(header); + estimatedTokens += headerTokens; + + for (const entity of fileEntities) { + const line = `- ${entity.type} \`${entity.name}\``; + const lineTokens = estimateTokens(line); + if (estimatedTokens + lineTokens > tokenBudget) { + lines.push("- ... (truncated)"); + return lines.join("\n"); + } + lines.push(line); + estimatedTokens += lineTokens; + } + } + + return lines.join("\n"); + } + + pruneFromTurn(sessionId: string, turn: number): void { + const entities = entitiesBySession.get(sessionId); + if (!entities) { + return; + } + const kept = entities.filter((e) => e.sessionTurn <= turn); + entitiesBySession.set(sessionId, kept); + + const ids = entityIdsBySession.get(sessionId); + if (ids) { + const keptIds = new Set(); + for (const e of kept) { + keptIds.add(entityKey(e)); + } + entityIdsBySession.set(sessionId, keptIds); + } + } + + clearSession(sessionId: string): void { + entitiesBySession.delete(sessionId); + entityIdsBySession.delete(sessionId); + } +} + +function isKeywordExcluded(name: string): boolean { + const excluded = new Set([ + "if", + "for", + "while", + "do", + "switch", + "case", + "try", + "catch", + "throw", + "return", + "break", + "continue", + "new", + "delete", + "typeof", + "instanceof", + "void", + "this", + "super", + "true", + "false", + "null", + "undefined", + "import", + "export", + "default", + "from", + "as", + "class", + "function", + "const", + "let", + "var", + "async", + "await", + "yield", + "static", + "get", + "set", + "public", + "private", + "protected", + "constructor", + "extends", + "implements", + "interface", + "type", + "enum", + "namespace", + "module", + "declare", + "abstract", + "readonly", + ]); + return excluded.has(name); +} + +function estimateTokens(text: string): number { + let tokens = 0; + for (const char of text) { + tokens += /[\u3400-\u9fff\uf900-\ufaff]/u.test(char) ? 0.6 : 0.3; + } + return Math.ceil(tokens); +} diff --git a/packages/core/src/common/context-manager.ts b/packages/core/src/common/context-manager.ts new file mode 100644 index 00000000..d2c3f784 --- /dev/null +++ b/packages/core/src/common/context-manager.ts @@ -0,0 +1,295 @@ +import type { SessionMessage } from "../session"; +import { CodeIndex } from "./code-index"; +import type { MemoryCategory } from "./conversation-memory"; +import { ConversationMemory } from "./conversation-memory"; + +const SOFT_COMPACT_MAX_LENGTH = 2000; + +export type RecallResult = { + category: "code" | "fact"; + entries: Array<{ + type?: string; + name?: string; + signature?: string; + filePath?: string; + summary?: string; + detail?: string; + category?: string; + }>; +}; + +export class ContextManager { + private codeIndex = new CodeIndex(); + private memory = new ConversationMemory(); + private turnCounters = new Map(); + + // ---- Lifecycle ---- + + onToolMessage(sessionId: string, message: SessionMessage): void { + const turn = this.nextTurn(sessionId); + + if (message.role !== "tool" || typeof message.content !== "string") { + return; + } + + const result = parseToolResult(message.content); + if (!result) { + return; + } + + const toolName = result.name as string; + + if (toolName === "read") { + const filePath = extractFilePath(result.metadata); + if (filePath && result.output) { + this.codeIndex.extractFromToolOutput(result.output, filePath, turn, sessionId); + } + } else if (toolName === "write" || toolName === "edit") { + const filePath = extractFilePath(result.metadata); + if (filePath) { + const filePaths: string[] = []; + if (typeof result.metadata?.file_path === "string") { + filePaths.push(result.metadata.file_path); + } + this.memory.addFact(sessionId, { + category: "milestone", + summary: `${toolName} ${filePath}`, + relatedFiles: filePaths, + turn, + }); + + // For edit, try to extract from diff content (new_string) + if (result.metadata?.diff_preview) { + const diffPreview = String(result.metadata.diff_preview); + this.codeIndex.extractFromToolOutput(diffPreview, filePath, turn, sessionId); + } + } + } else if (toolName === "bash") { + if (result.ok === false && result.error) { + this.memory.addFact(sessionId, { + category: "error_fix", + summary: result.error.slice(0, 200), + turn, + }); + } + } + } + + onAssistantMessage(sessionId: string, content: string | null): void { + if (!content) { + return; + } + const turn = this.nextTurn(sessionId); + + // Detect decision markers + const decisionPatterns = [ + /(?:I(?:'ll| will)|let(?:'s| us)) ([\w\s-]{20,120})[.!]/gi, + /(?:decided|chose|opted) to ([\w\s-]{20,120})[.!]/gi, + ]; + + for (const pattern of decisionPatterns) { + let match: RegExpExecArray | null; + while ((match = pattern.exec(content)) !== null) { + const summary = match[1]?.trim(); + if (summary && summary.length >= 20) { + this.memory.addFact(sessionId, { + category: "decision", + summary: summary.slice(0, 200), + turn, + }); + } + } + } + + // Detect fixed/resolved errors + const fixPattern = + /(?:fixed|resolved|solved)\s+(?:the\s+)?(?:issue|error|bug|problem)[:\s]*([\w\s-]{15,120})[.!]/gi; + let fixMatch: RegExpExecArray | null; + while ((fixMatch = fixPattern.exec(content)) !== null) { + const summary = fixMatch[1]?.trim(); + if (summary) { + this.memory.addFact(sessionId, { + category: "error_fix", + summary: `Fixed: ${summary.slice(0, 180)}`, + turn, + }); + } + } + } + + // ---- Compaction ---- + + async buildCompactionInjection(sessionId: string): Promise { + const parts: string[] = []; + + const codeLines = this.codeIndex.renderForInjection(sessionId, 500); + if (codeLines) { + parts.push("## Code Index (entities observed in this session)"); + parts.push(codeLines); + } + + const memoryLines = this.memory.renderForInjection(sessionId, 10); + if (memoryLines) { + parts.push(memoryLines); + } + + if (parts.length === 0) { + return ""; + } + + return `\n${parts.join("\n\n")}\n`; + } + + // ---- Soft compaction (keep code blocks) ---- + + buildSoftCompactedToolResult(rawOutput: string): string { + if (!rawOutput || rawOutput.length <= SOFT_COMPACT_MAX_LENGTH) { + return rawOutput; + } + + // Keep the first part and extract code blocks + const codeBlocks = extractCodeBlocks(rawOutput); + const head = rawOutput.slice(0, Math.floor(SOFT_COMPACT_MAX_LENGTH * 0.6)).trim(); + const tail = rawOutput.slice(-Math.floor(SOFT_COMPACT_MAX_LENGTH * 0.2)).trim(); + + const parts: string[] = [head]; + if (codeBlocks.length > 0) { + parts.push(`\n// ... ${codeBlocks.length} code block(s) preserved ...\n`); + for (const block of codeBlocks.slice(0, 3)) { + parts.push(block.slice(0, 500)); + } + } + parts.push(`\n... (${rawOutput.length} chars total, truncated)\n`); + parts.push(tail); + + return parts.join("\n"); + } + + // ---- Recall / Search ---- + + recall( + sessionId: string, + query: string, + options: { category?: string; filePath?: string; limit?: number } = {} + ): RecallResult { + const limit = options.limit ?? 5; + + if (options.category === "code" || !options.category || options.category === "all") { + const codeEntities = this.codeIndex.search(sessionId, query, { + filePath: options.filePath, + type: undefined, + limit, + }); + if (codeEntities.length > 0) { + return { + category: "code", + entries: codeEntities.map((e) => ({ + type: e.type, + name: e.name, + signature: e.signature, + filePath: e.filePath, + })), + }; + } + } + + if (options.category === "fact" || !options.category || options.category === "all") { + const facts = this.memory.search(sessionId, query, { + category: options.category as MemoryCategory | undefined, + limit, + }); + if (facts.length > 0) { + return { + category: "fact", + entries: facts.map((f) => ({ + summary: f.summary, + detail: f.detail, + category: f.category, + })), + }; + } + } + + return { category: options.category === "fact" ? "fact" : "code", entries: [] }; + } + + // ---- Query ---- + + getMemory(): ConversationMemory { + return this.memory; + } + + getCodeIndex(): CodeIndex { + return this.codeIndex; + } + + clearSession(sessionId: string): void { + this.codeIndex.clearSession(sessionId); + this.memory.clearSession(sessionId); + this.turnCounters.delete(sessionId); + } + + // ---- Internal ---- + + private nextTurn(sessionId: string): number { + const current = this.turnCounters.get(sessionId) ?? 0; + const next = current + 1; + this.turnCounters.set(sessionId, next); + return next; + } +} + +// ---- Helpers ---- + +type ParsedToolResult = { + ok?: boolean; + name?: string; + output?: string; + error?: string; + metadata?: Record; +} | null; + +function parseToolResult(content: string): ParsedToolResult { + try { + const parsed = JSON.parse(content); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as ParsedToolResult; + } + } catch { + // Not JSON — skip + } + return null; +} + +function extractFilePath(metadata: unknown): string | null { + if (!metadata || typeof metadata !== "object") { + return null; + } + const meta = metadata as Record; + + // read tool: metadata.snippet.filePath + const snippet = meta.snippet; + if (snippet && typeof snippet === "object" && !Array.isArray(snippet)) { + const filePath = (snippet as Record).filePath; + if (typeof filePath === "string") { + return filePath; + } + } + + // write/edit tool: metadata.file_path + if (typeof meta.file_path === "string") { + return meta.file_path; + } + + return null; +} + +function extractCodeBlocks(text: string): string[] { + const blocks: string[] = []; + const re = /```[\s\S]*?```/g; + let match: RegExpExecArray | null; + while ((match = re.exec(text)) !== null) { + blocks.push(match[0]); + } + return blocks; +} diff --git a/packages/core/src/common/conversation-memory.ts b/packages/core/src/common/conversation-memory.ts new file mode 100644 index 00000000..32d2cd65 --- /dev/null +++ b/packages/core/src/common/conversation-memory.ts @@ -0,0 +1,152 @@ +export type MemoryCategory = "decision" | "error_fix" | "user_feedback" | "milestone" | "pending"; + +export type MemoryFact = { + id: string; + category: MemoryCategory; + summary: string; + detail?: string; + relatedFiles?: string[]; + relatedEntities?: string[]; + createdAt: string; + turn: number; +}; + +type ScoredFact = { fact: MemoryFact; score: number }; + +const factsBySession = new Map(); +let factIdCounter = 0; + +function nextFactId(): string { + factIdCounter += 1; + return `fact_${factIdCounter}`; +} + +export class ConversationMemory { + addFact(sessionId: string, fact: Omit): MemoryFact { + const entry: MemoryFact = { + ...fact, + id: nextFactId(), + createdAt: new Date().toISOString(), + }; + + let sessionFacts = factsBySession.get(sessionId); + if (!sessionFacts) { + sessionFacts = []; + factsBySession.set(sessionId, sessionFacts); + } + sessionFacts.push(entry); + return entry; + } + + search(sessionId: string, query: string, options: { category?: MemoryCategory; limit?: number } = {}): MemoryFact[] { + const sessionFacts = factsBySession.get(sessionId); + if (!sessionFacts || sessionFacts.length === 0) { + return []; + } + + const limit = options.limit ?? 5; + const lowerQuery = query.toLowerCase(); + const scored: ScoredFact[] = []; + + for (const fact of sessionFacts) { + if (options.category && fact.category !== options.category) { + continue; + } + + let score = 0; + if (fact.summary.toLowerCase().includes(lowerQuery)) { + score += 50; + } + if (fact.detail?.toLowerCase().includes(lowerQuery)) { + score += 30; + } + if (fact.relatedFiles?.some((f) => f.toLowerCase().includes(lowerQuery))) { + score += 20; + } + if (fact.relatedEntities?.some((e) => e.toLowerCase().includes(lowerQuery))) { + score += 15; + } + + if (score > 0) { + scored.push({ fact, score }); + } + } + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit).map((s) => s.fact); + } + + getPending(sessionId: string): MemoryFact[] { + const sessionFacts = factsBySession.get(sessionId); + if (!sessionFacts) { + return []; + } + return sessionFacts.filter((f) => f.category === "pending"); + } + + getRecentErrors(sessionId: string, count = 5): MemoryFact[] { + const sessionFacts = factsBySession.get(sessionId); + if (!sessionFacts) { + return []; + } + return sessionFacts + .filter((f) => f.category === "error_fix") + .slice(-count) + .reverse(); + } + + renderForInjection(sessionId: string, maxFacts = 10): string { + const sessionFacts = factsBySession.get(sessionId); + if (!sessionFacts || sessionFacts.length === 0) { + return ""; + } + + const lines: string[] = []; + + // Group by category + const decisions = sessionFacts.filter((f) => f.category === "decision"); + const errors = sessionFacts.filter((f) => f.category === "error_fix"); + const pending = sessionFacts.filter((f) => f.category === "pending"); + + let count = 0; + + if (decisions.length > 0) { + lines.push("## Key Decisions"); + for (const fact of decisions) { + if (count >= maxFacts) { + break; + } + lines.push(`- ${fact.summary}`); + count += 1; + } + } + + if (errors.length > 0) { + lines.push("\n## Errors & Fixes"); + for (const fact of errors.slice(-3)) { + if (count >= maxFacts) { + break; + } + lines.push(`- ${fact.summary}`); + count += 1; + } + } + + if (pending.length > 0) { + lines.push("\n## Pending"); + for (const fact of pending) { + if (count >= maxFacts) { + break; + } + lines.push(`- ${fact.summary}`); + count += 1; + } + } + + return lines.join("\n"); + } + + clearSession(sessionId: string): void { + factsBySession.delete(sessionId); + } +} diff --git a/packages/core/src/common/cost-calculator.ts b/packages/core/src/common/cost-calculator.ts new file mode 100644 index 00000000..fb5167b0 --- /dev/null +++ b/packages/core/src/common/cost-calculator.ts @@ -0,0 +1,240 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { getProjectCode } from "../session"; +import type { ModelUsage } from "../session"; +import type { ResolvedPricingConfig } from "../settings"; +import { DEFAULT_PRICING } from "../settings"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Cost breakdown for an API request or aggregated usage. */ +export type CostBreakdown = { + /** Cost from uncached input tokens (USD). */ + inputCost: number; + /** Cost from cached input tokens (USD). */ + inputCacheHitCost: number; + /** Cost from output tokens (USD). */ + outputCost: number; + /** Total cost (sum of the above). */ + totalCost: number; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const TOKENS_PER_MILLION = 1_000_000; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Compute USD cost from raw token counts using the given pricing config. + * + * Cache-miss tokens are computed as `promptTokens - cachedPromptTokens` + * (clamped to >= 0). If the underlying usage carries `prompt_cache_hit_tokens` + * and `prompt_cache_miss_tokens` fields, the caller can split them before + * calling this function. + */ +export function computeCost( + promptTokens: number, + completionTokens: number, + cachedPromptTokens: number, + pricing: ResolvedPricingConfig +): CostBreakdown { + const safePrompt = Math.max(0, promptTokens); + const safeCompletion = Math.max(0, completionTokens); + const safeCached = Math.max(0, Math.min(cachedPromptTokens, safePrompt)); + const cacheMissPrompt = safePrompt - safeCached; + + const inputCost = (cacheMissPrompt / TOKENS_PER_MILLION) * pricing.inputPerMillion; + const inputCacheHitCost = (safeCached / TOKENS_PER_MILLION) * pricing.inputCacheHitPerMillion; + const outputCost = (safeCompletion / TOKENS_PER_MILLION) * pricing.outputPerMillion; + + return { + inputCost: roundUSD(inputCost), + inputCacheHitCost: roundUSD(inputCacheHitCost), + outputCost: roundUSD(outputCost), + totalCost: roundUSD(inputCost + inputCacheHitCost + outputCost), + }; +} + +/** Extract cost from a ModelUsage object. */ +export function computeCostFromUsage(usage: ModelUsage, pricing: ResolvedPricingConfig): CostBreakdown { + const promptTokens = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0; + const completionTokens = typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0; + + // Prefer explicit cache fields when available + const cachedPromptTokens = typeof usage.prompt_cache_hit_tokens === "number" ? usage.prompt_cache_hit_tokens : 0; + + return computeCost(promptTokens, completionTokens, cachedPromptTokens, pricing); +} + +function roundUSD(value: number): number { + // Round to 6 decimal places (microprecision) then remove trailing zeros via Number. + return Number(Math.round(value * 1_000_000) / 1_000_000); +} + +function sumCostBreakdowns(a: CostBreakdown, b: CostBreakdown): CostBreakdown { + return { + inputCost: roundUSD(a.inputCost + b.inputCost), + inputCacheHitCost: roundUSD(a.inputCacheHitCost + b.inputCacheHitCost), + outputCost: roundUSD(a.outputCost + b.outputCost), + totalCost: roundUSD(a.totalCost + b.totalCost), + }; +} + +// ---- Sessions-index shape ---- + +type SessionsIndexMinimal = { + version: number; + entries: Array<{ + id: string; + usage: ModelUsage | null; + usagePerModel?: Record | null; + }>; + totalCost?: number; +}; + +// --------------------------------------------------------------------------- +// CostCalculator +// --------------------------------------------------------------------------- + +/** + * Computes API costs based on token usage and configurable pricing. + * + * Usage: + * ```ts + * const calc = new CostCalculator("/path/to/project"); + * const cost = calc.calculateCost(15000, 5000, 2000); + * const sessionCost = calc.getSessionCost("session-id"); + * ``` + */ +export class CostCalculator { + private readonly projectRoot: string; + private readonly pricing: ResolvedPricingConfig; + + constructor(projectRoot: string, pricing?: Partial) { + this.projectRoot = path.resolve(projectRoot); + this.pricing = { + inputPerMillion: pricing?.inputPerMillion ?? DEFAULT_PRICING.inputPerMillion, + inputCacheHitPerMillion: pricing?.inputCacheHitPerMillion ?? DEFAULT_PRICING.inputCacheHitPerMillion, + outputPerMillion: pricing?.outputPerMillion ?? DEFAULT_PRICING.outputPerMillion, + }; + } + + // ---- Public API ---- + + /** + * Calculate the cost for a single API request. + * + * @param promptTokens - Total prompt (input) tokens. + * @param completionTokens - Completion (output) tokens. + * @param cachedTokens - Prompt tokens served from cache (hit). + * @returns Cost breakdown in USD. + */ + calculateCost(promptTokens: number, completionTokens: number, cachedTokens = 0): CostBreakdown { + return computeCost(promptTokens, completionTokens, cachedTokens, this.pricing); + } + + /** + * Calculate cost from a ModelUsage object (as returned by the API). + */ + calculateCostFromUsage(usage: ModelUsage): CostBreakdown { + return computeCostFromUsage(usage, this.pricing); + } + + /** + * Return the total cost for a specific session, or the aggregate cost + * across all sessions when no sessionId is provided. + * + * Returns null if the session does not exist. + */ + getSessionCost(sessionId?: string): CostBreakdown | null { + const index = this.loadSessionsIndex(); + if (!sessionId) { + // Aggregate across all sessions + let total: CostBreakdown = { inputCost: 0, inputCacheHitCost: 0, outputCost: 0, totalCost: 0 }; + for (const entry of index.entries) { + if (entry.usage) { + total = sumCostBreakdowns(total, computeCostFromUsage(entry.usage, this.pricing)); + } + } + return total; + } + + const entry = index.entries.find((e) => e.id === sessionId); + if (!entry || !entry.usage) { + return null; + } + return computeCostFromUsage(entry.usage, this.pricing); + } + + /** + * Get total cost across all sessions, including per-model breakdowns. + */ + getTotalCost(): { + total: CostBreakdown; + byModel: Record; + } { + const index = this.loadSessionsIndex(); + const total: CostBreakdown = { inputCost: 0, inputCacheHitCost: 0, outputCost: 0, totalCost: 0 }; + const byModel: Record = {}; + + for (const entry of index.entries) { + if (entry.usage) { + const sessionCost = computeCostFromUsage(entry.usage, this.pricing); + total.totalCost = roundUSD(total.totalCost + sessionCost.totalCost); + total.inputCost = roundUSD(total.inputCost + sessionCost.inputCost); + total.inputCacheHitCost = roundUSD(total.inputCacheHitCost + sessionCost.inputCacheHitCost); + total.outputCost = roundUSD(total.outputCost + sessionCost.outputCost); + } + + if (entry.usagePerModel) { + for (const [model, modelUsage] of Object.entries(entry.usagePerModel)) { + const modelCost = computeCostFromUsage(modelUsage, this.pricing); + const existing = byModel[model]; + byModel[model] = existing ? sumCostBreakdowns(existing, modelCost) : modelCost; + } + } + } + + // Prefer the pre-computed totalCost from the index if available + if (typeof index.totalCost === "number" && index.totalCost > total.totalCost) { + total.totalCost = index.totalCost; + } + + return { total, byModel }; + } + + /** + * Return the current pricing config. + */ + getPricing(): ResolvedPricingConfig { + return { ...this.pricing }; + } + + // ---- Internal ---- + + private getSessionsIndexPath(): string { + const projectCode = getProjectCode(this.projectRoot); + return path.join(os.homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); + } + + private loadSessionsIndex(): SessionsIndexMinimal { + const indexPath = this.getSessionsIndexPath(); + if (!fs.existsSync(indexPath)) { + return { version: 1, entries: [] }; + } + try { + const raw = fs.readFileSync(indexPath, "utf8"); + return JSON.parse(raw) as SessionsIndexMinimal; + } catch { + return { version: 1, entries: [] }; + } + } +} diff --git a/packages/core/src/common/file-history.ts b/packages/core/src/common/file-history.ts index c137fe6f..e031e048 100644 --- a/packages/core/src/common/file-history.ts +++ b/packages/core/src/common/file-history.ts @@ -149,6 +149,30 @@ export class GitFileHistory { } } + /** + * Branch a session's checkpoint history into a new session ID. + * Copies the git ref from the parent session to a new branch so the + * branched session inherits the parent's full checkpoint history. + */ + branchSession(parentSessionId: string, newSessionId: string): string | undefined { + const parentRef = this.getSessionBranchRef(parentSessionId); + const newRef = this.getSessionBranchRef(newSessionId); + if (!parentRef || !newRef || !fs.existsSync(this.gitDir)) { + return undefined; + } + + try { + const parentHash = this.runGit(["rev-parse", "--verify", `${parentRef}^{commit}`]).trim(); + if (!isCommitHash(parentHash)) { + return undefined; + } + this.runGit(["update-ref", newRef, parentHash]); + return parentHash; + } catch { + return undefined; + } + } + canRestore(sessionId: string, checkpointHash: string): boolean { if (!isCommitHash(checkpointHash)) { return false; diff --git a/packages/core/src/common/hooks-manager.ts b/packages/core/src/common/hooks-manager.ts new file mode 100644 index 00000000..aec5d086 --- /dev/null +++ b/packages/core/src/common/hooks-manager.ts @@ -0,0 +1,227 @@ +import { spawn } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resolveShellPath } from "./shell-utils"; + +export type HookPhase = + | "pre-bash" + | "post-bash" + | "pre-read" + | "post-read" + | "pre-write" + | "post-write" + | "pre-edit" + | "post-edit" + | "on-error" + | "on-compaction"; + +export type HookContext = { + tool: string; + sessionId: string; + projectRoot: string; + filePath?: string; + command?: string; + ok?: boolean; + error?: string; +}; + +export type HookResult = { + phase: HookPhase; + ok: boolean; + output: string; + blocked: boolean; + exitCode: number | null; + durationMs: number; +}; + +const HOOK_TIMEOUT_MS = 30_000; +const HOOK_DIRS = [".deepcode/hooks", ".agents/hooks"]; + +function getHookRoots(projectRoot: string): Array<{ root: string; label: string }> { + const homeDir = os.homedir(); + return [ + ...HOOK_DIRS.map((dir) => ({ + root: path.join(projectRoot, dir), + label: `project:${dir}`, + })), + ...HOOK_DIRS.map((dir) => ({ + root: path.join(homeDir, dir), + label: `user:${dir}`, + })), + ]; +} + +function findHookScript(projectRoot: string, phase: HookPhase): string | null { + for (const { root } of getHookRoots(projectRoot)) { + const scriptPath = path.join(root, `${phase}.js`); + if (fs.existsSync(scriptPath)) { + return scriptPath; + } + // Also accept .sh + const shPath = path.join(root, `${phase}.sh`); + if (fs.existsSync(shPath)) { + return shPath; + } + } + return null; +} + +function resolveRunner(scriptPath: string): { command: string; args: string[] } { + const ext = path.extname(scriptPath); + if (ext === ".js") { + return { command: process.execPath, args: [scriptPath] }; + } + if (ext === ".sh") { + const shellPath = resolveShellPath(); + return { command: shellPath, args: [scriptPath] }; + } + return { command: scriptPath, args: [] }; +} + +export class HooksManager { + private enabled: boolean; + + constructor(options: { enabled?: boolean } = {}) { + this.enabled = options.enabled ?? true; + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + } + + async executeHook(phase: HookPhase, projectRoot: string, context: HookContext): Promise { + const startedAt = Date.now(); + const emptyResult: HookResult = { + phase, + ok: true, + output: "", + blocked: false, + exitCode: null, + durationMs: 0, + }; + + if (!this.enabled) { + return emptyResult; + } + + const scriptPath = findHookScript(projectRoot, phase); + if (!scriptPath) { + return emptyResult; + } + + const { command, args } = resolveRunner(scriptPath); + const hookEnv: NodeJS.ProcessEnv = { + ...process.env, + DEEPCODE_HOOK_PHASE: phase, + DEEPCODE_TOOL: context.tool, + DEEPCODE_SESSION_ID: context.sessionId, + DEEPCODE_PROJECT_ROOT: context.projectRoot, + ...(context.filePath ? { DEEPCODE_FILE: context.filePath } : {}), + ...(context.command ? { DEEPCODE_COMMAND: context.command } : {}), + }; + + const contextJson = JSON.stringify({ + phase, + tool: context.tool, + sessionId: context.sessionId, + projectRoot: context.projectRoot, + filePath: context.filePath ?? null, + command: context.command ?? null, + ok: context.ok ?? null, + error: context.error ?? null, + timestamp: new Date().toISOString(), + }); + + try { + const result = await this.runHookProcess(command, args, contextJson, hookEnv, projectRoot); + + return { + phase, + ok: result.exitCode === 0, + output: result.output, + blocked: result.exitCode === 2, // Convention: exit code 2 = block + exitCode: result.exitCode, + durationMs: Date.now() - startedAt, + }; + } catch (error) { + return { + phase, + ok: false, + output: error instanceof Error ? error.message : String(error), + blocked: false, + exitCode: -1, + durationMs: Date.now() - startedAt, + }; + } + } + + private runHookProcess( + command: string, + args: string[], + stdinJson: string, + env: NodeJS.ProcessEnv, + cwd: string + ): Promise<{ output: string; exitCode: number | null }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + timeout: HOOK_TIMEOUT_MS, + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + const settle = (exitCode: number | null, error?: string) => { + if (settled) return; + settled = true; + if (error) { + reject(new Error(error)); + } else { + resolve({ output: stdout + (stderr ? `\n${stderr}` : ""), exitCode }); + } + }; + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8").slice(0, 8000); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8").slice(0, 2000); + }); + + child.on("error", (err) => settle(null, err.message)); + child.on("close", (code) => settle(typeof code === "number" ? code : null)); + + // Write context to stdin + child.stdin?.write(stdinJson); + child.stdin?.end(); + }); + } + + /** Run pre-hook; if it blocks (exit code 2), return the block reason. */ + async runPreHook( + phase: HookPhase, + projectRoot: string, + context: HookContext + ): Promise<{ blocked: boolean; reason?: string }> { + if (!this.enabled) return { blocked: false }; + + const result = await this.executeHook(phase, projectRoot, context); + if (result.blocked) { + return { + blocked: true, + reason: result.output.trim() || `Operation blocked by ${phase} hook.`, + }; + } + return { blocked: false }; + } + + /** Run post-hook (fire-and-forget, result is informational). */ + async runPostHook(phase: HookPhase, projectRoot: string, context: HookContext): Promise { + await this.executeHook(phase, projectRoot, context); + } +} diff --git a/packages/core/src/common/shell-sessions.ts b/packages/core/src/common/shell-sessions.ts new file mode 100644 index 00000000..a8323381 --- /dev/null +++ b/packages/core/src/common/shell-sessions.ts @@ -0,0 +1,216 @@ +import { spawn } from "child_process"; +import { randomUUID } from "crypto"; +import { resolveShellPath, buildShellEnv, toNativeCwd } from "./shell-utils"; + +export type ShellSession = { + id: string; + name: string; // user-assigned name (e.g., "build", "test") + cwd: string; + env: Record; + createdAt: string; + commandCount: number; +}; + +export type ShellExecResult = { + ok: boolean; + output: string; + exitCode: number | null; + signal: string | null; + truncated: boolean; + cwd: string; + durationMs: number; +}; + +const SHELL_TIMEOUT_MS = 120_000; +const MAX_OUTPUT_CHARS = 30_000; +const MAX_SESSIONS = 8; + +const sessionsByName = new Map>(); + +function getSessionMap(sessionId: string): Map { + let map = sessionsByName.get(sessionId); + if (!map) { + map = new Map(); + sessionsByName.set(sessionId, map); + } + return map; +} + +export class ShellSessionManager { + createSession( + sessionId: string, + name: string, + options: { cwd?: string; env?: Record } = {} + ): ShellSession { + const map = getSessionMap(sessionId); + + // Enforce max session limit + if (map.size >= MAX_SESSIONS) { + const oldest = [...map.entries()].sort((a, b) => a[1].createdAt.localeCompare(b[1].createdAt))[0]; + if (oldest) { + map.delete(oldest[0]); + } + } + + const shell: ShellSession = { + id: randomUUID(), + name, + cwd: options.cwd ?? process.cwd(), + env: options.env ?? {}, + createdAt: new Date().toISOString(), + commandCount: 0, + }; + map.set(name, shell); + return shell; + } + + getSession(sessionId: string, name: string): ShellSession | null { + return getSessionMap(sessionId).get(name) ?? null; + } + + listSessions(sessionId: string): ShellSession[] { + return [...getSessionMap(sessionId).values()]; + } + + setEnv(sessionId: string, name: string, key: string, value: string): boolean { + const shell = this.getSession(sessionId, name); + if (!shell) return false; + shell.env[key] = value; + return true; + } + + setCwd(sessionId: string, name: string, cwd: string): boolean { + const shell = this.getSession(sessionId, name); + if (!shell) return false; + shell.cwd = cwd; + return true; + } + + async execute( + sessionId: string, + name: string, + command: string, + options: { timeoutMs?: number } = {} + ): Promise { + const shell = this.getSession(sessionId, name); + if (!shell) { + return { + ok: false, + output: `Shell session "${name}" not found. Create it first with the bash tool.`, + exitCode: 1, + signal: null, + truncated: false, + cwd: "", + durationMs: 0, + }; + } + + const startedAt = Date.now(); + const timeoutMs = options.timeoutMs ?? SHELL_TIMEOUT_MS; + const marker = `__DEEPCODE_SH_${randomUUID().slice(0, 8)}__`; + const shellPath = resolveShellPath(); + + const wrappedCommand = buildWrappedCommand(command, marker, shell.env); + + return new Promise((resolve) => { + const child = spawn(shellPath, ["-c", wrappedCommand], { + cwd: shell.cwd, + env: buildShellEnv(shellPath, shell.env), + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + const settle = (exitCode: number | null, signal: string | null, error?: string) => { + if (settled) return; + settled = true; + + if (error) { + resolve({ + ok: false, + output: error, + exitCode, + signal, + truncated: false, + cwd: shell.cwd, + durationMs: Date.now() - startedAt, + }); + return; + } + + const { output: cleaned, cwd: newCwd } = stripMarker(stdout, marker); + const combined = stderr ? `${cleaned}\n${stderr}` : cleaned; + const truncated = combined.length > MAX_OUTPUT_CHARS; + const output = truncated ? combined.slice(0, MAX_OUTPUT_CHARS) : combined; + + if (newCwd) { + shell.cwd = newCwd; + } + shell.commandCount += 1; + + resolve({ + ok: exitCode === 0 && signal === null, + output, + exitCode, + signal, + truncated, + cwd: shell.cwd, + durationMs: Date.now() - startedAt, + }); + }; + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + + child.on("error", (err) => settle(-1, null, err.message)); + child.on("close", (code, signal) => settle(typeof code === "number" ? code : null, signal ?? null)); + }); + } + + removeSession(sessionId: string, name: string): boolean { + return getSessionMap(sessionId).delete(name); + } + + clearAll(sessionId: string): void { + sessionsByName.delete(sessionId); + } +} + +function buildWrappedCommand(command: string, marker: string, env: Record): string { + const exports = Object.entries(env) + .map(([k, v]) => `export ${k}='${v.replace(/'/g, "'\\''")}'`) + .join("; "); + + const parts = [ + exports, + command, + `__DEEPCODE_SH_STATUS__=$?`, + `printf '%s%s\\n' "${marker}" "$PWD"`, + "exit $__DEEPCODE_SH_STATUS__", + ].filter(Boolean); + + return `{ ${parts.join("; ")}; } < /dev/null`; +} + +function stripMarker(stdout: string, marker: string): { output: string; cwd: string | null } { + if (!stdout) return { output: "", cwd: null }; + + const lines = stdout.split(/\r?\n/); + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].startsWith(marker)) { + const shellCwd = lines[i].slice(marker.length).trim(); + const cwd = shellCwd ? toNativeCwd(shellCwd) : null; + lines.splice(i, 1); + return { output: lines.join("\n"), cwd }; + } + } + return { output: stdout, cwd: null }; +} diff --git a/packages/core/src/common/sub-agent-runner.ts b/packages/core/src/common/sub-agent-runner.ts new file mode 100644 index 00000000..fb3d040d --- /dev/null +++ b/packages/core/src/common/sub-agent-runner.ts @@ -0,0 +1,290 @@ +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import type { ToolExecutor } from "../tools/executor"; +import { getTools, getRuntimeContext, type ToolDefinition } from "../prompt"; +import { buildThinkingRequestOptions } from "./openai-thinking"; +import type { CreateOpenAIClient } from "./tool-types"; + +export type SubAgentTask = { + description: string; + tools?: string[]; // allowed tool names (default: ["read", "bash"]) + context?: string; // additional system prompt context + maxIterations?: number; // default 12 + timeoutMs?: number; // default 120000 +}; + +export type SubAgentResult = { + taskIndex: number; + ok: boolean; + summary: string; + filesRead: string[]; + filesModified: string[]; + errors: string[]; + iterations: number; + tokensUsed: number; + durationMs: number; +}; + +type SubAgentRunOptions = { + projectRoot: string; + createOpenAIClient: CreateOpenAIClient; + toolExecutor: ToolExecutor; + signal?: AbortSignal; +}; + +const DEFAULT_TOOLS = ["read", "bash"]; +const DEFAULT_MAX_ITERATIONS = 12; +const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_TOOL_OUTPUT_LENGTH = 6000; + +function buildSubAgentSystemPrompt(task: SubAgentTask, projectRoot: string): string { + return `You are a specialized sub-agent. Execute the following task and report back. + +## Task +${task.description} + +## Rules +1. **Focus only on this task** — do NOT modify files unrelated to the task. +2. **Read before edit** — always read a file before editing or writing to it. +3. **Minimal changes** — make the smallest change that satisfies the task. +4. **Report results** — after completing the task, provide a clear summary of what you did, which files you read, and which files you modified. +5. **No conversation** — do not ask questions. If something is unclear, make your best guess and proceed. +6. **Stop after completing** — do not continue beyond this task.${task.context ? `\n\n## Additional Context\n${task.context}` : ""} + +${getRuntimeContext(projectRoot)}`; +} + +export class SubAgentRunner { + constructor() {} + + async runTasks(tasks: SubAgentTask[], options: SubAgentRunOptions): Promise { + return Promise.all(tasks.map((task, index) => this.runSingleTask(index, task, options))); + } + + private async runSingleTask( + taskIndex: number, + task: SubAgentTask, + options: SubAgentRunOptions + ): Promise { + const startedAt = Date.now(); + const filesRead = new Set(); + const filesModified = new Set(); + const errors: string[] = []; + let tokensUsed = 0; + let iterations = 0; + + const { createOpenAIClient, toolExecutor, projectRoot, signal } = options; + const { client, model, baseURL, temperature, thinkingEnabled, reasoningEffort } = createOpenAIClient(); + + if (!client) { + return { + taskIndex, + ok: false, + summary: "API client not available.", + filesRead: [], + filesModified: [], + errors: ["No API client configured"], + iterations: 0, + tokensUsed: 0, + durationMs: Date.now() - startedAt, + }; + } + + const maxIterations = task.maxIterations ?? DEFAULT_MAX_ITERATIONS; + const timeoutMs = task.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const allowedTools = task.tools ?? DEFAULT_TOOLS; + + // Build initial messages + const systemPrompt = buildSubAgentSystemPrompt(task, projectRoot); + const messages: ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: task.description }, + ]; + + // Get only the allowed tool definitions + const allTools = getTools({ model, webSearchEnabled: true }); + const filteredTools = allTools.filter((t) => allowedTools.includes(t.function.name)); + + try { + for (iterations = 0; iterations < maxIterations; iterations++) { + if (signal?.aborted) { + errors.push("Aborted"); + break; + } + + if (Date.now() - startedAt > timeoutMs) { + errors.push(`Timed out after ${timeoutMs}ms`); + break; + } + + const thinkingOptions = buildThinkingRequestOptions(thinkingEnabled, baseURL, reasoningEffort); + const response = await this.createChatCompletion( + client, + model, + messages, + filteredTools, + thinkingOptions, + temperature, + signal + ); + + if (!response) { + errors.push("No response from LLM"); + break; + } + + const choice = response.choices?.[0]?.message; + if (!choice) { + break; + } + + const content = typeof choice.content === "string" ? choice.content : ""; + const toolCalls = (choice as { tool_calls?: unknown[] }).tool_calls; + + if (response.usage) { + tokensUsed += (response.usage as { total_tokens?: number }).total_tokens ?? 0; + } + + // Append assistant message + messages.push({ + role: "assistant", + content: content || null, + tool_calls: toolCalls, + } as ChatCompletionMessageParam); + + if (!toolCalls || toolCalls.length === 0) { + // No more tool calls — task complete + break; + } + + // Execute tool calls via the sub-agent's own ToolExecutor + const parsedCalls = toolCalls + .map((tc) => this.parseForToolExecutor(tc)) + .filter((tc): tc is { id: string; type: "function"; function: { name: string; arguments: string } } => + Boolean(tc) + ); + + const executions = await toolExecutor.executeToolCalls(`subagent-${taskIndex}`, parsedCalls, { + shouldStop: () => signal?.aborted ?? false, + }); + + // Process results + for (const execution of executions) { + const result = execution.result; + const toolMeta = result.metadata; + + // Track files + if (result.name === "read" && toolMeta?.snippet) { + const fp = (toolMeta.snippet as { filePath?: string }).filePath; + if (fp) filesRead.add(fp); + } + if (result.name === "write" || result.name === "edit") { + const fp = toolMeta?.file_path; + if (typeof fp === "string") filesModified.add(fp); + if (typeof fp === "string") filesRead.add(fp); // also count as read + } + + if (!result.ok && result.error) { + errors.push(`[${result.name}] ${result.error.slice(0, 300)}`); + } + + // Append tool result (truncated) + const output = + execution.content.length > MAX_TOOL_OUTPUT_LENGTH + ? execution.content.slice(0, MAX_TOOL_OUTPUT_LENGTH) + + `\n... (truncated ${execution.content.length} chars)` + : execution.content; + + messages.push({ + role: "tool", + content: output, + tool_call_id: execution.toolCallId, + } as ChatCompletionMessageParam); + } + } + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + + // Build summary + const summaryParts: string[] = []; + + if (filesRead.size > 0) { + summaryParts.push(`Files read: ${[...filesRead].join(", ")}`); + } + if (filesModified.size > 0) { + summaryParts.push(`Files modified: ${[...filesModified].join(", ")}`); + } + if (errors.length > 0) { + summaryParts.push(`Errors: ${errors.join("; ")}`); + } + + const lastAssistantMsg = [...messages].reverse().find((m) => m.role === "assistant"); + const lastContent = typeof lastAssistantMsg?.content === "string" ? lastAssistantMsg.content : ""; + const summary = lastContent.slice(0, 500) || summaryParts.join(". ") || "Task completed."; + + return { + taskIndex, + ok: errors.length === 0, + summary, + filesRead: [...filesRead], + filesModified: [...filesModified], + errors, + iterations, + tokensUsed, + durationMs: Date.now() - startedAt, + }; + } + + private async createChatCompletion( + client: NonNullable["client"]>, + model: string, + messages: ChatCompletionMessageParam[], + tools: ToolDefinition[], + thinkingOptions: Record, + temperature: number | undefined, + signal?: AbortSignal + ): Promise<{ + choices?: Array<{ message?: { content?: string | null; tool_calls?: unknown[] } }>; + usage?: unknown; + } | null> { + try { + const response = await client.chat.completions.create( + { + model, + messages, + tools: tools.length > 0 ? tools : undefined, + ...(temperature !== undefined ? { temperature } : {}), + ...thinkingOptions, + }, + { signal } + ); + return response as unknown as { + choices?: Array<{ message?: { content?: string | null; tool_calls?: unknown[] } }>; + usage?: unknown; + }; + } catch (error) { + if ((error as { name?: string }).name === "AbortError") { + return null; + } + throw error; + } + } + + private parseForToolExecutor( + toolCall: unknown + ): { id: string; type: "function"; function: { name: string; arguments: string } } | null { + if (!toolCall || typeof toolCall !== "object") return null; + const tc = toolCall as { id?: unknown; type?: unknown; function?: { name?: unknown; arguments?: unknown } }; + if (typeof tc.id !== "string") return null; + if (!tc.function || typeof tc.function !== "object") return null; + if (typeof tc.function.name !== "string") return null; + return { + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: typeof tc.function.arguments === "string" ? tc.function.arguments : "", + }, + }; + } +} diff --git a/packages/core/src/common/task-plan-manager.ts b/packages/core/src/common/task-plan-manager.ts new file mode 100644 index 00000000..2c247f97 --- /dev/null +++ b/packages/core/src/common/task-plan-manager.ts @@ -0,0 +1,151 @@ +import type { TaskPlan, TaskStep, TaskStepInput, TaskStepFallback, TaskStepVerification } from "./task-plan"; +import { createTaskPlan, formatPlanMarkdown } from "./task-plan"; + +const plansBySession = new Map(); + +export class TaskPlanManager { + hasActivePlan(sessionId: string): boolean { + return plansBySession.has(sessionId); + } + + createPlan(sessionId: string, stepInputs: TaskStepInput[]): TaskPlan { + const plan = createTaskPlan(sessionId, stepInputs); + plan.state = "executing"; + plansBySession.set(sessionId, plan); + return plan; + } + + startStep(sessionId: string, stepIndex: number, checkpointHash: string): void { + const plan = plansBySession.get(sessionId); + if (!plan || plan.steps[stepIndex] === undefined) { + return; + } + plan.steps[stepIndex].status = "in_progress"; + plan.steps[stepIndex].checkpointHash = checkpointHash; + plan.currentStepIndex = stepIndex; + } + + completeStep(sessionId: string, stepIndex: number): void { + const plan = plansBySession.get(sessionId); + if (!plan || plan.steps[stepIndex] === undefined) { + return; + } + plan.steps[stepIndex].status = "completed"; + + // Advance to next pending step + const nextIndex = plan.steps.findIndex((step, i) => i > stepIndex && step.status === "pending"); + if (nextIndex !== -1) { + plan.currentStepIndex = nextIndex; + } else { + plan.state = "completed"; + } + } + + failStep(sessionId: string, stepIndex: number): void { + const plan = plansBySession.get(sessionId); + if (!plan || plan.steps[stepIndex] === undefined) { + return; + } + plan.steps[stepIndex].status = "failed"; + plan.state = "failed"; + } + + // ---- Verification ---- + + needsVerification(sessionId: string): boolean { + const step = this.getCurrentStep(sessionId); + if (!step) { + return false; + } + if (step.status === "completed") { + return false; + } + return step.verification !== null; + } + + getVerification(sessionId: string): TaskStepVerification | null { + const step = this.getCurrentStep(sessionId); + return step?.verification ?? null; + } + + markVerifying(sessionId: string): void { + const step = this.getCurrentStep(sessionId); + if (step && step.status === "in_progress") { + step.status = "verifying"; + } + } + + // ---- Fallback & retry ---- + + getFallback(sessionId: string): TaskStepFallback | null { + const step = this.getCurrentStep(sessionId); + if (!step || step.status !== "failed") { + return null; + } + return step.fallback; + } + + shouldRetry(sessionId: string): boolean { + const step = this.getCurrentStep(sessionId); + if (!step) { + return false; + } + return step.retryCount < step.maxRetries; + } + + incrementRetry(sessionId: string): void { + const step = this.getCurrentStep(sessionId); + if (step) { + step.retryCount += 1; + step.status = "pending"; + } + const plan = plansBySession.get(sessionId); + if (plan) { + plan.state = "executing"; + } + } + + skipFailedStep(sessionId: string): void { + const plan = plansBySession.get(sessionId); + if (!plan) { + return; + } + const step = this.getCurrentStep(sessionId); + if (step) { + step.status = "completed"; + } + const nextIndex = plan.steps.findIndex((s, i) => i > plan.currentStepIndex && s.status === "pending"); + if (nextIndex !== -1) { + plan.currentStepIndex = nextIndex; + plan.state = "executing"; + } else { + plan.state = "completed"; + } + } + + // ---- Query ---- + + getPlan(sessionId: string): TaskPlan | null { + return plansBySession.get(sessionId) ?? null; + } + + getCurrentStep(sessionId: string): TaskStep | null { + const plan = plansBySession.get(sessionId); + if (!plan) { + return null; + } + return plan.steps[plan.currentStepIndex] ?? null; + } + + getPlanMarkdown(sessionId: string): string { + const plan = plansBySession.get(sessionId); + if (!plan) { + return ""; + } + return formatPlanMarkdown(plan); + } + + clearPlan(sessionId: string): void { + plansBySession.delete(sessionId); + } +} diff --git a/packages/core/src/common/task-plan.ts b/packages/core/src/common/task-plan.ts new file mode 100644 index 00000000..3aafca5f --- /dev/null +++ b/packages/core/src/common/task-plan.ts @@ -0,0 +1,99 @@ +export type TaskStepStatus = "pending" | "in_progress" | "verifying" | "completed" | "failed"; + +export type VerificationType = "command" | "file_exists" | "test_pass" | "manual"; + +export type FallbackType = "rollback_and_retry" | "rollback_and_skip" | "ask_user"; + +export type TaskStepVerification = { + type: VerificationType; + command?: string; + expected?: string; +}; + +export type TaskStepFallback = { + type: FallbackType; + alternativeApproach?: string; +}; + +export type TaskStep = { + index: number; + description: string; + status: TaskStepStatus; + verification: TaskStepVerification | null; + fallback: TaskStepFallback; + checkpointHash?: string; + retryCount: number; + maxRetries: number; +}; + +export type TaskPlanState = "planning" | "executing" | "completed" | "failed"; + +export type TaskPlan = { + sessionId: string; + steps: TaskStep[]; + currentStepIndex: number; + state: TaskPlanState; + createdAt: string; +}; + +export type TaskStepInput = { + description: string; + verification?: TaskStepVerification; + fallback?: TaskStepFallback; + maxRetries?: number; +}; + +export function createTaskStep(index: number, input: TaskStepInput): TaskStep { + return { + index, + description: input.description, + status: "pending", + verification: input.verification ?? null, + fallback: input.fallback ?? { type: "ask_user" }, + retryCount: 0, + maxRetries: input.maxRetries ?? 2, + }; +} + +export function createTaskPlan(sessionId: string, stepInputs: TaskStepInput[]): TaskPlan { + return { + sessionId, + steps: stepInputs.map((input, i) => createTaskStep(i, input)), + currentStepIndex: 0, + state: "planning", + createdAt: new Date().toISOString(), + }; +} + +export function formatPlanMarkdown(plan: TaskPlan): string { + const lines: string[] = []; + + for (const step of plan.steps) { + const marker = statusMarker(step.status); + const retry = step.retryCount > 0 ? ` (retry ${step.retryCount}/${step.maxRetries})` : ""; + const verify = step.verification ? ` → verify: \`${step.verification.command ?? step.verification.type}\`` : ""; + lines.push(`${marker} ${step.description}${retry}${verify}`); + } + + if (plan.state === "failed") { + lines.push(""); + lines.push(`Plan failed at step ${plan.currentStepIndex + 1}.`); + } + + return lines.join("\n"); +} + +function statusMarker(status: TaskStepStatus): string { + switch (status) { + case "completed": + return "[x]"; + case "in_progress": + return "[>]"; + case "verifying": + return "[~]"; + case "failed": + return "[!]"; + default: + return "[ ]"; + } +} diff --git a/packages/core/src/common/tool-types.ts b/packages/core/src/common/tool-types.ts index 1d664a76..ed12daef 100644 --- a/packages/core/src/common/tool-types.ts +++ b/packages/core/src/common/tool-types.ts @@ -1,5 +1,7 @@ import type OpenAI from "openai"; import type { ReasoningEffort } from "../settings"; +import type { TaskPlanManager } from "./task-plan-manager"; +import type { ContextManager } from "./context-manager"; export type CreateOpenAIClient = () => { client: OpenAI | null; @@ -30,6 +32,8 @@ export type ToolExecutionContext = { projectRoot: string; toolCall: ToolCall; createOpenAIClient?: CreateOpenAIClient; + taskPlanManager?: TaskPlanManager; + contextManager?: ContextManager; onProcessStart?: (processId: string | number, command: string) => void; onProcessExit?: (processId: string | number) => void; onProcessStdout?: (processId: string | number, chunk: string) => void; diff --git a/packages/core/src/common/usage-tracker.ts b/packages/core/src/common/usage-tracker.ts new file mode 100644 index 00000000..dd328b84 --- /dev/null +++ b/packages/core/src/common/usage-tracker.ts @@ -0,0 +1,233 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { getProjectCode } from "../session"; +import type { ModelUsage, SessionEntry } from "../session"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Aggregated token counts for a single session or across sessions. */ +export type UsageSummary = { + /** Total prompt (input) tokens consumed. */ + promptTokens: number; + /** Total completion (output) tokens consumed. */ + completionTokens: number; + /** Sum of prompt + completion tokens. */ + totalTokens: number; + /** Number of API requests made. */ + totalRequests: number; +}; + +/** Token usage relative to the model's maximum context window. */ +export type UsagePercentage = { + /** Tokens consumed so far. */ + usedTokens: number; + /** Maximum context window size in tokens. */ + maxContextTokens: number; + /** Percentage of max context consumed (0–100). */ + percentage: number; +}; + +export type UsageSnapshot = { + /** Per-session usage summaries keyed by session id. */ + sessions: Record; + /** Aggregated usage across all sessions. */ + total: UsageSummary; + /** Usage broken down by model name. */ + byModel: Record; +}; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Default maximum context window size (1M tokens for DeepSeek V4 family). */ +export const DEFAULT_MAX_CONTEXT_TOKENS = 1_000_000; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extractUsageSummary(usage: ModelUsage | null | undefined): UsageSummary { + if (!usage) { + return { promptTokens: 0, completionTokens: 0, totalTokens: 0, totalRequests: 0 }; + } + const promptTokens = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0; + const completionTokens = typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0; + const totalTokens = typeof usage.total_tokens === "number" ? usage.total_tokens : promptTokens + completionTokens; + const totalRequests = typeof usage.total_reqs === "number" ? usage.total_reqs : 0; + return { promptTokens, completionTokens, totalTokens, totalRequests }; +} + +function sumUsageSummaries(a: UsageSummary, b: UsageSummary): UsageSummary { + return { + promptTokens: a.promptTokens + b.promptTokens, + completionTokens: a.completionTokens + b.completionTokens, + totalTokens: a.totalTokens + b.totalTokens, + totalRequests: a.totalRequests + b.totalRequests, + }; +} + +function mergeModelUsage(target: Record, model: string, summary: UsageSummary): void { + const existing = target[model]; + target[model] = existing ? sumUsageSummaries(existing, summary) : { ...summary }; +} + +// --------------------------------------------------------------------------- +// UsageTracker +// --------------------------------------------------------------------------- + +/** + * Tracks aggregated token usage across all sessions for a project. + * + * Reads from the persisted sessions-index.json to compute cumulative + * prompt / completion / total tokens, request counts, and per-model breakdowns. + * + * Usage: + * ```ts + * const tracker = new UsageTracker("/path/to/project"); + * const usage = tracker.getUsage(); // UsagePercentage + * const remaining = tracker.getRemaining(); // number + * const snapshot = tracker.getSnapshot(); // UsageSnapshot + * ``` + */ +export class UsageTracker { + private readonly projectRoot: string; + private readonly maxContextTokens: number; + + constructor(projectRoot: string, maxContextTokens?: number) { + this.projectRoot = path.resolve(projectRoot); + this.maxContextTokens = + maxContextTokens && Number.isFinite(maxContextTokens) && maxContextTokens > 0 + ? Math.floor(maxContextTokens) + : DEFAULT_MAX_CONTEXT_TOKENS; + } + + // ---- Public API ---- + + /** + * Returns token usage as a percentage of the model's maximum context window. + */ + getUsage(): UsagePercentage { + const total = this.getTotalUsage(); + const usedTokens = total.totalTokens; + const maxContextTokens = this.maxContextTokens; + const percentage = + maxContextTokens > 0 ? Math.min(100, Math.round((usedTokens / maxContextTokens) * 10000) / 100) : 0; + return { usedTokens, maxContextTokens, percentage }; + } + + /** + * Returns the estimated number of tokens remaining before hitting the + * configured maximum context window. + */ + getRemaining(): number { + const { usedTokens } = this.getUsage(); + return Math.max(0, this.maxContextTokens - usedTokens); + } + + /** + * Returns aggregated usage across all sessions. + */ + getTotalUsage(): UsageSummary { + return this.getSnapshot().total; + } + + /** + * Returns usage for a specific session, or null if not found. + */ + getSessionUsage(sessionId: string): UsageSummary | null { + return this.getSnapshot().sessions[sessionId] ?? null; + } + + /** + * Returns usage broken down by model name. + */ + getUsageByModel(): Record { + return this.getSnapshot().byModel; + } + + /** + * Returns a complete usage snapshot including per-session, total, + * and per-model breakdowns. + */ + getSnapshot(): UsageSnapshot { + const index = this.loadSessionsIndex(); + const sessions: Record = {}; + const byModel: Record = {}; + let total: UsageSummary = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + totalRequests: 0, + }; + + for (const entry of index.entries) { + const sessionSummary = extractUsageSummary(entry.usage); + sessions[entry.id] = sessionSummary; + total = sumUsageSummaries(total, sessionSummary); + + // Per-model breakdown + if (entry.usagePerModel) { + for (const [model, modelUsage] of Object.entries(entry.usagePerModel)) { + mergeModelUsage(byModel, model, extractUsageSummary(modelUsage)); + } + } + } + + // Aggregate top-level fields if present (for pre-computed values) + if (typeof index.totalPromptTokens === "number") { + total.promptTokens = Math.max(total.promptTokens, index.totalPromptTokens); + } + if (typeof index.totalCompletionTokens === "number") { + total.completionTokens = Math.max(total.completionTokens, index.totalCompletionTokens); + } + if (typeof index.totalTokens === "number") { + total.totalTokens = Math.max(total.totalTokens, index.totalTokens); + } + + return { sessions, total, byModel }; + } + + // ---- Internal ---- + + private getSessionsIndexPath(): string { + const projectCode = getProjectCode(this.projectRoot); + return path.join(os.homedir(), ".deepcode", "projects", projectCode, "sessions-index.json"); + } + + private loadSessionsIndex(): SessionsIndexWithAggregates { + const indexPath = this.getSessionsIndexPath(); + if (!fs.existsSync(indexPath)) { + return { version: 1, entries: [], originalPath: this.projectRoot }; + } + try { + const raw = fs.readFileSync(indexPath, "utf8"); + return JSON.parse(raw) as SessionsIndexWithAggregates; + } catch { + return { version: 1, entries: [], originalPath: this.projectRoot }; + } + } +} + +// ---- SessionsIndex augmented type ---- + +/** + * Extended sessions-index.json shape that optionally stores + * pre-computed aggregate token counts at the top level. + */ +export type SessionsIndexWithAggregates = { + version: number; + entries: SessionEntry[]; + originalPath: string; + /** Pre-computed total prompt tokens across all sessions. */ + totalPromptTokens?: number; + /** Pre-computed total completion tokens across all sessions. */ + totalCompletionTokens?: number; + /** Pre-computed total tokens across all sessions. */ + totalTokens?: number; + /** Pre-computed total cost (USD) across all sessions. */ + totalCost?: number; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9749ff4b..de024ee0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ export { getProjectSettingsPath, DEFAULT_MODEL, DEFAULT_BASE_URL, + DEFAULT_PRICING, } from "./settings"; export type { DeepcodingSettings, @@ -29,6 +30,8 @@ export type { StatusLineSettings, ResolvedStatusLineSettings, StatusLineProviderConfig, + PricingConfig, + ResolvedPricingConfig, } from "./settings"; // Session @@ -136,3 +139,11 @@ export type { // State types export type { FileState, FileSnippet, FileLineEnding } from "./common/state"; export type { FileReadMetadata } from "./common/file-utils"; + +// Usage tracker +export { UsageTracker, DEFAULT_MAX_CONTEXT_TOKENS } from "./common/usage-tracker"; +export type { UsageSummary, UsagePercentage, UsageSnapshot } from "./common/usage-tracker"; + +// Cost calculator +export { CostCalculator, computeCost, computeCostFromUsage } from "./common/cost-calculator"; +export type { CostBreakdown } from "./common/cost-calculator"; diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts index 62171457..3a78606a 100644 --- a/packages/core/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -579,6 +579,50 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe type: "string", description: "Optional short reason for changing the plan.", }, + steps: { + type: "array", + description: + "Optional structured execution steps with verification and fallback strategies. Use for multi-step tasks that require automated verification.", + items: { + type: "object", + properties: { + description: { type: "string", description: "Step description." }, + verification: { + type: "object", + description: "How to verify this step succeeded.", + properties: { + type: { + type: "string", + enum: ["command", "file_exists", "test_pass", "manual"], + }, + command: { type: "string", description: "Shell command to run for verification." }, + expected: { type: "string", description: "Expected output or behavior." }, + }, + required: ["type"], + additionalProperties: false, + }, + fallback: { + type: "object", + description: "What to do if this step fails after max retries.", + properties: { + type: { + type: "string", + enum: ["rollback_and_retry", "rollback_and_skip", "ask_user"], + }, + alternativeApproach: { type: "string", description: "Alternative approach to try on retry." }, + }, + required: ["type"], + additionalProperties: false, + }, + maxRetries: { + type: "number", + description: "Max retry attempts before invoking fallback (default 2).", + }, + }, + required: ["description"], + additionalProperties: false, + }, + }, }, required: ["plan"], additionalProperties: false, @@ -674,6 +718,76 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe }, ]; + tools.push({ + type: "function", + function: { + name: "Recall", + description: + "Search the conversation history and code index for specific information. Use this when you need to recall function signatures, class definitions, past decisions, or errors from earlier in the conversation.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "Search query — keyword, function name, or concept.", + }, + category: { + type: "string", + enum: ["code", "decision", "error", "fact", "all"], + description: "Category to search: code (entities), decision, error, fact, or all (default).", + }, + filePath: { + type: "string", + description: "Optional file path to narrow search results.", + }, + limit: { + type: "number", + description: "Maximum number of results (default 5, max 20).", + }, + }, + required: ["query"], + additionalProperties: false, + }, + }, + }); + + tools.push({ + type: "function", + function: { + name: "Delegate", + description: + "Delegate tasks to sub-agents that execute independently with isolated context. Use for parallel code search, analysis, or multi-file refactoring.", + parameters: { + type: "object", + properties: { + tasks: { + type: "array", + description: "List of tasks to delegate.", + items: { + type: "object", + properties: { + description: { type: "string", description: "What the sub-agent should do." }, + tools: { + type: "array", + items: { type: "string" }, + description: "Allowed tools (default: read, bash).", + }, + context: { type: "string", description: "Additional system prompt context." }, + maxIterations: { type: "number", description: "Max LLM iterations (default 12)." }, + timeoutMs: { type: "number", description: "Timeout in ms (default 120000)." }, + }, + required: ["description"], + additionalProperties: false, + }, + }, + parallel: { type: "boolean", description: "Run tasks in parallel (default true)." }, + }, + required: ["tasks"], + additionalProperties: false, + }, + }, + }); + tools.push({ type: "function", function: { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 3add448e..c99f9714 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -30,12 +30,18 @@ import { } from "./tools/executor"; import { McpManager } from "./mcp/mcp-manager"; import type { McpServerConfig, PermissionScope, PermissionSettings } from "./settings"; +import { DEFAULT_PRICING } from "./settings"; import { logApiError } from "./common/error-logger"; import { logOpenAIChatCompletionDebug, normalizeDebugError } from "./common/debug-logger"; import { describeLlmError, getLlmErrorDetails } from "./common/llm-error"; import { killProcessTree } from "./common/process-tree"; import { GitFileHistory, type FileHistoryCheckpointResult } from "./common/file-history"; import { clearSessionState, getSnippet, rebuildSessionStateFromHistory } from "./common/state"; +import { TaskPlanManager } from "./common/task-plan-manager"; +import type { TaskPlan } from "./common/task-plan"; +import { ContextManager } from "./common/context-manager"; +import { HooksManager } from "./common/hooks-manager"; +import { ShellSessionManager } from "./common/shell-sessions"; import { appendProjectPermissionAllows, buildPermissionToolExecution, @@ -67,7 +73,7 @@ const MAX_PROJECT_CODE_LENGTH = 64; const PROJECT_CODE_HASH_LENGTH = 16; const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000; const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024; -const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024; +const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 768 * 1024; const PLAN_MODE_ON_STATUS_MESSAGE = " └ Set Plan Mode on. Awaiting ."; const PLAN_MODE_OFF_STATUS_MESSAGE = " └ Set Plan Mode off."; const PLAN_MODE_FORCE_ASK_SCOPES = [ @@ -190,6 +196,24 @@ function getTotalTokens(usage: ModelUsage | null | undefined): number { return typeof totalTokens === "number" ? totalTokens : 0; } +function roundUSD(value: number): number { + return Number(Math.round(value * 1_000_000) / 1_000_000); +} + +function computeSessionEntryCost(usage: ModelUsage): number { + const promptTokens = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0; + const completionTokens = typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0; + const cachedHitTokens = typeof usage.prompt_cache_hit_tokens === "number" ? usage.prompt_cache_hit_tokens : 0; + const cacheMissTokens = Math.max(0, promptTokens - cachedHitTokens); + + const TOKENS_PER_MILLION = 1_000_000; + const inputCost = (cacheMissTokens / TOKENS_PER_MILLION) * DEFAULT_PRICING.inputPerMillion; + const cacheHitCost = (cachedHitTokens / TOKENS_PER_MILLION) * DEFAULT_PRICING.inputCacheHitPerMillion; + const outputCost = (completionTokens / TOKENS_PER_MILLION) * DEFAULT_PRICING.outputPerMillion; + + return roundUSD(inputCost + cacheHitCost + outputCost); +} + export type SessionStatus = | "failed" | "pending" @@ -243,12 +267,23 @@ export type SessionEntry = { processes: Map | null; // {pid: process info} askPermissions?: AskPermissionRequest[]; planMode?: boolean; + taskPlan?: TaskPlan; + /** When this session was branched from another, the parent session ID. */ + parentSessionId?: string | null; }; export type SessionsIndex = { version: 1; entries: SessionEntry[]; originalPath: string; + /** Pre-computed total prompt tokens across all sessions. */ + totalPromptTokens?: number; + /** Pre-computed total completion tokens across all sessions. */ + totalCompletionTokens?: number; + /** Pre-computed total tokens (prompt + completion) across all sessions. */ + totalTokens?: number; + /** Pre-computed total cost (USD) across all sessions. */ + totalCost?: number; }; export type SessionMessageRole = "system" | "user" | "assistant" | "tool"; @@ -353,6 +388,10 @@ export class SessionManager { private readonly liveProcessKeys = new Set(); private readonly toolExecutor: ToolExecutor; private readonly mcpManager = new McpManager(); + private readonly taskPlanManager = new TaskPlanManager(); + private readonly contextManager = new ContextManager(); + private readonly hooksManager = new HooksManager(); + private readonly shellSessionManager = new ShellSessionManager(); private mcpToolDefinitions: ToolDefinition[] = []; private readonly messageConverter: OpenAIMessageConverter; @@ -365,7 +404,15 @@ export class SessionManager { this.onLlmStreamProgress = options.onLlmStreamProgress; this.onMcpStatusChanged = options.onMcpStatusChanged; this.onProcessStdout = options.onProcessStdout; - this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); + this.toolExecutor = new ToolExecutor( + this.projectRoot, + this.createOpenAIClient, + this.mcpManager, + this.taskPlanManager, + this.contextManager, + this.hooksManager, + this.shellSessionManager + ); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); this.messageConverter = new OpenAIMessageConverter({ renderInitPrompt: () => this.renderInitCommandPrompt(), @@ -1352,6 +1399,7 @@ ${agentInstructions} this.updateSessionEntry(sessionId, (entry) => ({ ...entry, toolCalls: pendingToolCallMessage.toolCalls, + taskPlan: this.taskPlanManager.getPlan(sessionId) ?? undefined, status: "waiting_for_user", updateTime: new Date().toISOString(), })); @@ -1429,6 +1477,7 @@ ${agentInstructions} } this.appendSessionMessage(sessionId, assistantMessage); this.onAssistantMessage(assistantMessage, true); + this.contextManager.onAssistantMessage(sessionId, content); let waitingForUser = false; const responseUsage = response.usage ?? null; @@ -1446,6 +1495,7 @@ ${agentInstructions} status: "ask_permission", failReason: null, askPermissions: permissionPlan.askPermissions, + taskPlan: this.taskPlanManager.getPlan(sessionId) ?? undefined, updateTime: new Date().toISOString(), })); return; @@ -1472,6 +1522,7 @@ ${agentInstructions} status: refusal ? "failed" : waitingForUser ? "waiting_for_user" : toolCalls ? "processing" : "completed", failReason: refusal ? refusal : entry.failReason, askPermissions: undefined, + taskPlan: this.taskPlanManager.getPlan(sessionId) ?? undefined, updateTime: new Date().toISOString(), })); @@ -1605,6 +1656,28 @@ ${agentInstructions} }, }; sessionMessages.splice(endIndex, 0, summaryMessage); + + // Inject context index after the summary for "lossless memory" + const contextIndex = await this.contextManager.buildCompactionInjection(sessionId); + if (contextIndex) { + const indexMessage: SessionMessage = { + id: crypto.randomUUID(), + sessionId, + role: "system", + content: contextIndex, + contentParams: null, + messageParams: null, + compacted: false, + visible: false, + createTime: now, + updateTime: now, + meta: { + isSummary: true, + }, + }; + sessionMessages.splice(endIndex + 1, 0, indexMessage); + } + this.saveSessionMessages(sessionId, sessionMessages); } @@ -1757,6 +1830,81 @@ ${agentInstructions} return true; } + /** + * Create a new session branched from an existing parent session. + * Copies the parent's message history and checkpoint (git branch) into a + * new session with a fresh ID, preserving the parent reference. + * Returns the new session ID, or null if the parent does not exist. + */ + createBranchSession(parentSessionId: string): string | null { + const parentEntry = this.getSession(parentSessionId); + if (!parentEntry) { + return null; + } + + const newSessionId = crypto.randomUUID(); + const now = new Date().toISOString(); + + // 1. Copy the git branch (checkpoint history) + const fileHistory = this.getFileHistory(); + const parentInitialized = fileHistory.ensureSession(parentSessionId); + if (parentInitialized) { + fileHistory.branchSession(parentSessionId, newSessionId); + } + + // 2. Copy the message file + const parentMessagePath = this.getSessionMessagesPath(parentSessionId); + const newMessagePath = this.getSessionMessagesPath(newSessionId); + if (fs.existsSync(parentMessagePath)) { + this.ensureProjectDir(); + fs.copyFileSync(parentMessagePath, newMessagePath); + } + + // 3. Create a new session entry with parent reference + const branchSummary = `${parentEntry.summary ?? "Untitled"} (branched)`; + const index = this.loadSessionsIndex(); + const entry: SessionEntry = { + id: newSessionId, + summary: branchSummary, + assistantReply: parentEntry.assistantReply, + assistantThinking: parentEntry.assistantThinking, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage: parentEntry.usage, + usagePerModel: parentEntry.usagePerModel, + activeTokens: parentEntry.activeTokens, + createTime: now, + updateTime: now, + processes: null, + planMode: parentEntry.planMode, + parentSessionId, + }; + 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 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)); + index.entries = keptEntries; + this.saveSessionsIndex(index); + for (const dropped of droppedEntries) { + this.cleanupSessionResources(dropped.id, { + removeMessages: true, + processIds: this.getProcessIds(dropped.processes ?? null), + }); + } + + return newSessionId; + } + /** * Rename a session by updating its summary (display title). * Returns true if the session was found and renamed, false otherwise. @@ -1823,9 +1971,7 @@ ${agentInstructions} const now = new Date().toISOString(); const latestAssistant = [...keptMessages].reverse().find((message) => message.role === "assistant"); const latestAssistantParams = latestAssistant?.messageParams as - | { tool_calls?: unknown[]; reasoning_content?: string } - | null - | undefined; + { tool_calls?: unknown[]; reasoning_content?: string } | null | undefined; this.updateSessionEntry(sessionId, (entry) => ({ ...entry, @@ -1992,6 +2138,7 @@ ${agentInstructions} private saveSessionsIndex(index: SessionsIndex): void { const { sessionsIndexPath } = this.getProjectStorage(); this.ensureProjectDir(); + const aggregates = this.computeAggregatedUsage(index.entries); const normalized = { version: 1, entries: index.entries.map((entry) => ({ @@ -1999,10 +2146,40 @@ ${agentInstructions} processes: this.serializeProcesses(entry.processes), })), originalPath: this.projectRoot, + ...aggregates, }; fs.writeFileSync(sessionsIndexPath, JSON.stringify(normalized, null, 2), "utf8"); } + private computeAggregatedUsage(entries: SessionEntry[]): { + totalPromptTokens?: number; + totalCompletionTokens?: number; + totalTokens?: number; + totalCost?: number; + } { + let totalPromptTokens = 0; + let totalCompletionTokens = 0; + let totalTokens = 0; + let totalCost = 0; + + for (const entry of entries) { + const usage = entry.usage; + if (usage) { + totalPromptTokens += typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0; + totalCompletionTokens += typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0; + totalTokens += typeof usage.total_tokens === "number" ? usage.total_tokens : 0; + totalCost += computeSessionEntryCost(usage); + } + } + + return { + totalPromptTokens, + totalCompletionTokens, + totalTokens, + totalCost: roundUSD(totalCost), + }; + } + private getSessionMessagesPath(sessionId: string): string { const { projectDir } = this.getProjectStorage(); return path.join(projectDir, `${sessionId}.jsonl`); @@ -2037,6 +2214,7 @@ ${agentInstructions} clearSessionState(sessionId); clearSessionWorkingDir(sessionId); + this.contextManager.clearSession(sessionId); const controller = this.sessionControllers.get(sessionId); if (controller && !controller.signal.aborted) { controller.abort(); @@ -2349,6 +2527,7 @@ ${agentInstructions} const toolMessage = this.buildToolMessage(sessionId, execution.toolCallId, execution.content, toolFunction); this.appendSessionMessage(sessionId, toolMessage); this.onAssistantMessage(toolMessage, true); + this.contextManager.onToolMessage(sessionId, toolMessage); for (const followUpMessage of execution.result.followUpMessages ?? []) { if (followUpMessage.role !== "system") { @@ -2781,6 +2960,7 @@ ${agentInstructions} processes: this.deserializeProcesses(value.processes), askPermissions: normalizeAskPermissions(value.askPermissions), planMode: value.planMode === true, + parentSessionId: typeof value.parentSessionId === "string" ? value.parentSessionId : null, }; } diff --git a/packages/core/src/settings.ts b/packages/core/src/settings.ts index 5dab3b5a..0402690a 100644 --- a/packages/core/src/settings.ts +++ b/packages/core/src/settings.ts @@ -12,6 +12,23 @@ export type DeepcodingEnv = Record & { REASONING_EFFORT?: string; DEBUG_LOG_ENABLED?: string; TELEMETRY_ENABLED?: string; + MAX_CONTEXT_TOKENS?: string; +}; + +/** Token pricing per 1M tokens, in USD. */ +export type PricingConfig = { + /** Price per 1M input tokens (cache miss). Default 0.14. */ + inputPerMillion?: number; + /** Price per 1M input tokens (cache hit). Default 0.0028. */ + inputCacheHitPerMillion?: number; + /** Price per 1M output tokens. Default 0.28. */ + outputPerMillion?: number; +}; + +export type ResolvedPricingConfig = { + inputPerMillion: number; + inputCacheHitPerMillion: number; + outputPerMillion: number; }; export type ReasoningEffort = "high" | "max"; @@ -94,6 +111,11 @@ export type DeepcodingSettings = { permissions?: PermissionSettings; enabledSkills?: EnabledSkillsSettings; statusline?: StatusLineSettings; + hooks?: { enabled?: boolean }; + /** Maximum context window size in tokens (default 1,000,000). */ + maxContextTokens?: number; + /** Token pricing for cost calculation (default: DeepSeek V4 rates). */ + pricing?: PricingConfig; }; export type ResolvedDeepcodingSettings = { @@ -112,6 +134,8 @@ export type ResolvedDeepcodingSettings = { permissions: Required; enabledSkills: EnabledSkillsSettings; statusline: ResolvedStatusLineSettings; + maxContextTokens: number; + pricing: ResolvedPricingConfig; }; export type ModelConfigSelection = { @@ -152,6 +176,57 @@ function parseTemperature(value: unknown): number | undefined { return raw; } +function parseNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +/** Default maximum context window size in tokens. */ +const DEFAULT_MAX_CONTEXT_TOKENS = 1_000_000; + +function parseMaxContextTokens(value: unknown): number { + const parsed = parseNumber(value); + if (parsed !== undefined && parsed > 0) { + return Math.floor(parsed); + } + return DEFAULT_MAX_CONTEXT_TOKENS; +} + +/** Default DeepSeek V4 pricing per 1M tokens, in USD. */ +export const DEFAULT_PRICING: ResolvedPricingConfig = { + inputPerMillion: 0.14, + inputCacheHitPerMillion: 0.0028, + outputPerMillion: 0.28, +}; + +function resolvePricing(config: PricingConfig | null | undefined): ResolvedPricingConfig { + const inputPerMillion = + typeof config?.inputPerMillion === "number" && + Number.isFinite(config.inputPerMillion) && + config.inputPerMillion >= 0 + ? config.inputPerMillion + : DEFAULT_PRICING.inputPerMillion; + const inputCacheHitPerMillion = + typeof config?.inputCacheHitPerMillion === "number" && + Number.isFinite(config.inputCacheHitPerMillion) && + config.inputCacheHitPerMillion >= 0 + ? config.inputCacheHitPerMillion + : DEFAULT_PRICING.inputCacheHitPerMillion; + const outputPerMillion = + typeof config?.outputPerMillion === "number" && + Number.isFinite(config.outputPerMillion) && + config.outputPerMillion >= 0 + ? config.outputPerMillion + : DEFAULT_PRICING.outputPerMillion; + return { inputPerMillion, inputCacheHitPerMillion, outputPerMillion }; +} + function trimString(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } @@ -531,6 +606,16 @@ export function resolveSettingsSources( trimString(userSettings?.webSearchTool) || ""; + const maxContextTokens = parseMaxContextTokens( + parseNumber(systemEnv.MAX_CONTEXT_TOKENS) ?? + projectSettings?.maxContextTokens ?? + projectEnv.MAX_CONTEXT_TOKENS ?? + userSettings?.maxContextTokens ?? + userEnv.MAX_CONTEXT_TOKENS + ); + + const pricing = resolvePricing(projectSettings?.pricing ?? userSettings?.pricing); + return { env, apiKey: trimString(env.API_KEY) || undefined, @@ -547,6 +632,8 @@ export function resolveSettingsSources( permissions: mergePermissions(userSettings, projectSettings), enabledSkills: mergeEnabledSkills(userSettings, projectSettings), statusline: mergeStatusLine(userSettings, projectSettings), + maxContextTokens, + pricing, }; } diff --git a/packages/core/src/tests/context-manager.test.ts b/packages/core/src/tests/context-manager.test.ts new file mode 100644 index 00000000..907577ea --- /dev/null +++ b/packages/core/src/tests/context-manager.test.ts @@ -0,0 +1,260 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { CodeIndex } from "../common/code-index"; +import type { CodeEntity } from "../common/code-index"; +import { ConversationMemory } from "../common/conversation-memory"; +import { ContextManager } from "../common/context-manager"; + +const sessionId = "context-test-session"; + +// ---- CodeIndex Tests ---- + +test("CodeIndex extracts TypeScript functions", () => { + const code = ` +export async function handleRecallTool( + args: Record, + context: ToolExecutionContext +): Promise { + return executeValidatedTool("Recall", recallSchema, args, context, async (input) => { +`; + + const index = new CodeIndex(); + index.extractFromToolOutput(code, "src/tools/recall-handler.ts", 1, sessionId); + + const entities = index.getByFile(sessionId, "src/tools/recall-handler.ts"); + const functions = entities.filter((e) => e.type === "function"); + assert.ok(functions.length >= 1, "should find at least one function"); + assert.equal(functions[0]!.name, "handleRecallTool"); +}); + +test("CodeIndex extracts classes and interfaces", () => { + const code = ` +export class CodeIndex { + extractFromToolOutput() {} + search() {} +} + +export type CodeEntity = { + type: CodeEntityType; + name: string; +}; + +export interface SearchOptions { + filePath?: string; + limit?: number; +} +`; + + const index = new CodeIndex(); + index.extractFromToolOutput(code, "src/code-index.ts", 1, sessionId); + + const entities = index.getByFile(sessionId, "src/code-index.ts"); + const classes = entities.filter((e) => e.type === "class"); + const types = entities.filter((e) => e.type === "type"); + const interfaces = entities.filter((e) => e.type === "interface"); + + assert.equal(classes.length, 1); + assert.equal(classes[0]!.name, "CodeIndex"); + assert.equal(types.length, 1); + assert.equal(types[0]!.name, "CodeEntity"); + assert.equal(interfaces.length, 1); + assert.equal(interfaces[0]!.name, "SearchOptions"); +}); + +test("CodeIndex extracts Python classes and functions", () => { + const code = ` +class UserService: + def __init__(self, db: Database) -> None: + self.db = db + + async def create_user(self, name: str, email: str) -> User: + pass +`; + + const index = new CodeIndex(); + index.extractFromToolOutput(code, "src/services.py", 1, sessionId); + + const entities = index.getByFile(sessionId, "src/services.py"); + const classes = entities.filter((e) => e.type === "class"); + const functions = entities.filter((e) => e.type === "function"); + + assert.equal(classes.length, 1); + assert.equal(classes[0]!.name, "UserService"); + assert.equal(functions[0]!.name, "__init__"); + assert.equal(functions[1]!.name, "create_user"); +}); + +test("CodeIndex search returns ranked results", () => { + const index = new CodeIndex(); + index.extractFromToolOutput("function handleRecallTool() {}", "src/a.ts", 1, sessionId); + index.extractFromToolOutput("function handleRecallTool() {}", "src/a.ts", 1, sessionId); // duplicate + index.extractFromToolOutput("class RecallHandler {}", "src/b.ts", 1, sessionId); + + const results = index.search(sessionId, "recall"); + assert.ok(results.length >= 2); + // Most relevant should be the function with matching name + assert.equal(results[0]!.name, "handleRecallTool"); +}); + +test("CodeIndex search filters by filePath", () => { + const index = new CodeIndex(); + index.extractFromToolOutput("function foo() {}", "src/a.ts", 1, sessionId); + index.extractFromToolOutput("function bar() {}", "src/b.ts", 1, sessionId); + + const results = index.search(sessionId, "foo", { filePath: "a.ts" }); + assert.equal(results.length, 1); + assert.equal(results[0]!.name, "foo"); +}); + +test("CodeIndex excludes reserved keywords", () => { + const code = ` +if (true) return; +for (const x of items) break; +class ValidClass {} +`; + + const index = new CodeIndex(); + index.extractFromToolOutput(code, "src/test.ts", 1, sessionId); + + const entities = index.getByFile(sessionId, "src/test.ts"); + // Only ValidClass should be extracted (no if, for, return, break, const) + assert.equal(entities.length, 1); + assert.equal(entities[0]!.name, "ValidClass"); +}); + +test("CodeIndex renderForInjection groups by file", () => { + const index = new CodeIndex(); + index.extractFromToolOutput("class A {}", "src/a.ts", 1, sessionId); + index.extractFromToolOutput("class B {}", "src/b.ts", 1, sessionId); + + const text = index.renderForInjection(sessionId, 200); + assert.ok(text.includes("### src/a.ts")); + assert.ok(text.includes("### src/b.ts")); + assert.ok(text.includes("class `A`")); + assert.ok(text.includes("class `B`")); +}); + +// ---- ConversationMemory Tests ---- + +test("ConversationMemory addFact and search", () => { + const mem = new ConversationMemory(); + mem.addFact(sessionId, { category: "decision", summary: "Use regex-based extraction", turn: 1 }); + mem.addFact(sessionId, { category: "error_fix", summary: "Fixed null pointer in UserService", turn: 2 }); + + const results = mem.search(sessionId, "regex"); + assert.equal(results.length, 1); + assert.equal(results[0]!.summary, "Use regex-based extraction"); +}); + +test("ConversationMemory category filter", () => { + const mem = new ConversationMemory(); + mem.addFact(sessionId, { category: "error_fix", summary: "Fixed bug", turn: 1 }); + mem.addFact(sessionId, { category: "decision", summary: "Chose approach A", turn: 2 }); + + const errors = mem.search(sessionId, "bug", { category: "error_fix" }); + assert.equal(errors.length, 1); + assert.equal(errors[0]!.summary, "Fixed bug"); +}); + +test("ConversationMemory getPending", () => { + const mem = new ConversationMemory(); + mem.addFact(sessionId, { category: "pending", summary: "Write tests", turn: 1 }); + mem.addFact(sessionId, { category: "pending", summary: "Update docs", turn: 2 }); + mem.addFact(sessionId, { category: "decision", summary: "Use TypeScript", turn: 3 }); + + const pending = mem.getPending(sessionId); + assert.equal(pending.length, 2); +}); + +test("ConversationMemory renderForInjection", () => { + const mem = new ConversationMemory(); + mem.addFact(sessionId, { category: "decision", summary: "Use regex extraction", turn: 1 }); + mem.addFact(sessionId, { category: "error_fix", summary: "Fixed type error", turn: 2 }); + mem.addFact(sessionId, { category: "pending", summary: "Add more tests", turn: 3 }); + + const text = mem.renderForInjection(sessionId, 10); + assert.ok(text.includes("## Key Decisions")); + assert.ok(text.includes("## Errors & Fixes")); + assert.ok(text.includes("## Pending")); +}); + +// ---- ContextManager Tests ---- + +test("ContextManager buildCompactionInjection returns structured markup", async () => { + const manager = new ContextManager(); + + // Simulate a read tool result + const readMsg = { + id: "msg-1", + sessionId, + role: "tool" as const, + content: JSON.stringify({ + ok: true, + name: "read", + output: "class Foo { bar() {} }", + metadata: { snippet: { filePath: "src/foo.ts", startLine: 1, endLine: 10 } }, + }), + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + }; + manager.onToolMessage(sessionId, readMsg); + + // Add a decision + manager.onAssistantMessage( + sessionId, + "I decided to use regex-based extraction for code entities. This approach is simpler and faster than tree-sitter." + ); + + const injection = await manager.buildCompactionInjection(sessionId); + assert.ok(injection.includes("")); + assert.ok(injection.includes("## Code Index")); + assert.ok(injection.includes("class `Foo`")); + assert.ok(injection.includes("## Key Decisions")); +}); + +test("ContextManager recall searches code and facts", () => { + const manager = new ContextManager(); + + const readMsg = { + id: "msg-1", + sessionId, + role: "tool" as const, + content: JSON.stringify({ + ok: true, + name: "read", + output: "function createUser(name: string): User {}", + metadata: { snippet: { filePath: "src/user.ts", startLine: 1, endLine: 10 } }, + }), + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + }; + manager.onToolMessage(sessionId, readMsg); + + manager.onAssistantMessage(sessionId, "I decided to create a UserService class for all user operations."); + + const codeResults = manager.recall(sessionId, "createUser"); + assert.equal(codeResults.category, "code"); + assert.ok(codeResults.entries.length >= 1); + assert.equal(codeResults.entries[0]!.name, "createUser"); + + const factResults = manager.recall(sessionId, "UserService"); + assert.ok(factResults.entries.length >= 1); +}); + +test("ContextManager buildSoftCompactedToolResult preserves code blocks", () => { + const manager = new ContextManager(); + const long = "x".repeat(1000) + "\n```\nconst x = 1;\n```\n" + "y".repeat(1000); + + const compacted = manager.buildSoftCompactedToolResult(long); + assert.ok(compacted.includes("code block(s) preserved")); + assert.ok(compacted.includes("const x = 1")); + assert.ok(compacted.includes("truncated")); +}); diff --git a/packages/core/src/tests/cost-calculator.test.ts b/packages/core/src/tests/cost-calculator.test.ts new file mode 100644 index 00000000..1eaf1e4c --- /dev/null +++ b/packages/core/src/tests/cost-calculator.test.ts @@ -0,0 +1,426 @@ +import { afterEach, describe, 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 { CostCalculator, computeCost, computeCostFromUsage } from "../common/cost-calculator"; +import { DEFAULT_PRICING } from "../settings"; +import { getProjectCode } from "../session"; +import type { ModelUsage, SessionEntry } from "../session"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempDirs: string[] = []; + +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cost-calc-test-")); + tempDirs.push(dir); + return dir; +} + +function makeUsage(overrides: Partial = {}): ModelUsage { + return { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + total_reqs: 1, + ...overrides, + }; +} + +function makeSessionEntry(id: string, usage: ModelUsage | null): SessionEntry { + return { + id, + summary: `Session ${id.slice(0, 8)}`, + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage, + usagePerModel: null, + activeTokens: 0, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + processes: null, + }; +} + +function writeSessionsIndex( + homeDir: string, + projectRoot: string, + entries: SessionEntry[], + aggregates?: { totalCost?: number } +): void { + const projectCode = getProjectCode(projectRoot); + const projectDir = path.join(homeDir, ".deepcode", "projects", projectCode); + fs.mkdirSync(projectDir, { recursive: true }); + const indexPath = path.join(projectDir, "sessions-index.json"); + const index = { + version: 1, + entries, + originalPath: projectRoot, + ...aggregates, + }; + fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), "utf8"); +} + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +// --------------------------------------------------------------------------- +// computeCost (pure function) +// --------------------------------------------------------------------------- + +describe("computeCost", () => { + test("zero tokens returns zero cost", () => { + const cost = computeCost(0, 0, 0, DEFAULT_PRICING); + assert.equal(cost.totalCost, 0); + assert.equal(cost.inputCost, 0); + assert.equal(cost.inputCacheHitCost, 0); + assert.equal(cost.outputCost, 0); + }); + + test("1M prompt tokens (cache miss) costs $0.14", () => { + const cost = computeCost(1_000_000, 0, 0, DEFAULT_PRICING); + assert.equal(cost.totalCost, 0.14); + assert.equal(cost.inputCost, 0.14); + assert.equal(cost.outputCost, 0); + }); + + test("1M completion tokens costs $0.28", () => { + const cost = computeCost(0, 1_000_000, 0, DEFAULT_PRICING); + assert.equal(cost.totalCost, 0.28); + assert.equal(cost.outputCost, 0.28); + assert.equal(cost.inputCost, 0); + }); + + test("1M cached prompt tokens costs $0.0028", () => { + const cost = computeCost(1_000_000, 0, 1_000_000, DEFAULT_PRICING); + assert.equal(cost.totalCost, 0.0028); + assert.equal(cost.inputCacheHitCost, 0.0028); + assert.equal(cost.inputCost, 0); + }); + + test("mixed input: half cache hit, half miss", () => { + // 500K cache hit + 500K cache miss = 1M total prompt + const cost = computeCost(1_000_000, 0, 500_000, DEFAULT_PRICING); + // miss: 500K → $0.07, hit: 500K → $0.0014 + assert.equal(cost.inputCost, 0.07); + assert.equal(cost.inputCacheHitCost, 0.0014); + assert.equal(cost.totalCost, 0.0714); + }); + + test("full request: 10K prompt (2K cached) + 5K completion", () => { + const cost = computeCost(10_000, 5_000, 2_000, DEFAULT_PRICING); + // miss: 8000/1M * 0.14 = 0.00112 + // hit: 2000/1M * 0.0028 = 0.0000056 + // out: 5000/1M * 0.28 = 0.0014 + // total = 0.00112 + 0.0000056 + 0.0014 = 0.0025256 + assert.equal(cost.inputCost, 0.00112); + assert.equal(cost.inputCacheHitCost, 0.000006); // rounded to 6 decimals: 0.0000056 → 0.000006 + assert.equal(cost.outputCost, 0.0014); + assert.equal(cost.totalCost, 0.002526); // 0.0025256 → 0.002526 + }); + + test("cached tokens clamped to prompt tokens", () => { + // cached > prompt → clamped to 1M + const cost = computeCost(1_000_000, 0, 2_000_000, DEFAULT_PRICING); + assert.equal(cost.inputCost, 0); + assert.equal(cost.inputCacheHitCost, 0.0028); + }); + + test("negative values clamped to zero", () => { + const cost = computeCost(-100, -200, -50, DEFAULT_PRICING); + assert.equal(cost.totalCost, 0); + }); + + test("custom pricing overrides defaults", () => { + const customPricing = { + inputPerMillion: 1.0, + inputCacheHitPerMillion: 0.1, + outputPerMillion: 2.0, + }; + const cost = computeCost(1_000_000, 1_000_000, 500_000, customPricing); + // miss: 500K/1M * 1.0 = 0.5 + // hit: 500K/1M * 0.1 = 0.05 + // out: 1M/1M * 2.0 = 2.0 + // total = 2.55 + assert.equal(cost.inputCost, 0.5); + assert.equal(cost.inputCacheHitCost, 0.05); + assert.equal(cost.outputCost, 2.0); + assert.equal(cost.totalCost, 2.55); + }); +}); + +// --------------------------------------------------------------------------- +// computeCostFromUsage +// --------------------------------------------------------------------------- + +describe("computeCostFromUsage", () => { + test("uses prompt_cache_hit_tokens when available", () => { + const usage: ModelUsage = { + prompt_tokens: 10_000, + completion_tokens: 5_000, + total_tokens: 15_000, + prompt_cache_hit_tokens: 3_000, + prompt_cache_miss_tokens: 7_000, + total_reqs: 1, + }; + const cost = computeCostFromUsage(usage, DEFAULT_PRICING); + // miss: 7000/1M * 0.14 = 0.00098 + // hit: 3000/1M * 0.0028 = 0.000008 + assert.equal(cost.inputCost, 0.00098); + assert.equal(cost.inputCacheHitCost, 0.000008); + assert.equal(cost.totalCost, 0.002388); + }); + + test("treats all prompt as cache miss when no cache hit info", () => { + const usage: ModelUsage = { + prompt_tokens: 10_000, + completion_tokens: 5_000, + total_tokens: 15_000, + total_reqs: 1, + }; + const cost = computeCostFromUsage(usage, DEFAULT_PRICING); + assert.equal(cost.inputCost, 0.0014); + assert.equal(cost.inputCacheHitCost, 0); + assert.equal(cost.outputCost, 0.0014); + }); +}); + +// --------------------------------------------------------------------------- +// CostCalculator class +// --------------------------------------------------------------------------- + +describe("CostCalculator", () => { + test("calculateCost with default pricing", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const calc = new CostCalculator(projectRoot); + const cost = calc.calculateCost(1_000_000, 0, 0); + + assert.equal(cost.totalCost, 0.14); + }); + + test("calculateCost respects custom pricing", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const calc = new CostCalculator(projectRoot, { + inputPerMillion: 2.0, + outputPerMillion: 3.0, + }); + // 1M prompt + 1M completion = 2 + 3 = 5 + const cost = calc.calculateCost(1_000_000, 1_000_000); + assert.equal(cost.inputCost, 2.0); + assert.equal(cost.outputCost, 3.0); + assert.equal(cost.totalCost, 5.0); + }); + + test("calculateCostFromUsage with ModelUsage", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const calc = new CostCalculator(projectRoot); + const usage: ModelUsage = { + prompt_tokens: 100_000, + completion_tokens: 50_000, + total_tokens: 150_000, + prompt_cache_hit_tokens: 20_000, + total_reqs: 1, + }; + const cost = calc.calculateCostFromUsage(usage); + // miss: 80K/1M * 0.14 = 0.0112 + // hit: 20K/1M * 0.0028 = 0.000056 + // out: 50K/1M * 0.28 = 0.014 + // total = 0.025256 + assert.equal(cost.totalCost, 0.025256); + }); + + test("getSessionCost with no sessions returns zero cost", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + writeSessionsIndex(homeDir, projectRoot, []); + + const calc = new CostCalculator(projectRoot); + const cost = calc.getSessionCost(); // aggregate all + + assert.ok(cost); + assert.equal(cost.totalCost, 0); + }); + + test("getSessionCost aggregates across multiple sessions", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", makeUsage({ prompt_tokens: 1_000_000, completion_tokens: 0, total_tokens: 1_000_000 })), + makeSessionEntry("s2", makeUsage({ prompt_tokens: 0, completion_tokens: 1_000_000, total_tokens: 1_000_000 })), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const calc = new CostCalculator(projectRoot); + const cost = calc.getSessionCost(); // aggregate + + assert.ok(cost); + // s1: 0.14 input, s2: 0.28 output => total 0.42 + assert.equal(cost.totalCost, 0.42); + }); + + test("getSessionCost for a specific session", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", makeUsage({ prompt_tokens: 1_000_000, completion_tokens: 0, total_tokens: 1_000_000 })), + makeSessionEntry("s2", makeUsage({ prompt_tokens: 0, completion_tokens: 500_000, total_tokens: 500_000 })), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const calc = new CostCalculator(projectRoot); + + const s1Cost = calc.getSessionCost("s1"); + assert.ok(s1Cost); + assert.equal(s1Cost.totalCost, 0.14); + + const s2Cost = calc.getSessionCost("s2"); + assert.ok(s2Cost); + assert.equal(s2Cost.totalCost, 0.14); + + const missingCost = calc.getSessionCost("nonexistent"); + assert.equal(missingCost, null); + }); + + test("getTotalCost returns total and byModel breakdown", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries: SessionEntry[] = [ + { + ...makeSessionEntry("s1", makeUsage({ prompt_tokens: 1_000_000, completion_tokens: 0 })), + usagePerModel: { "model-a": makeUsage({ prompt_tokens: 1_000_000, completion_tokens: 0, total_reqs: 1 }) }, + }, + { + ...makeSessionEntry("s2", makeUsage({ prompt_tokens: 0, completion_tokens: 1_000_000 })), + usagePerModel: { "model-b": makeUsage({ prompt_tokens: 0, completion_tokens: 1_000_000, total_reqs: 1 }) }, + }, + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const calc = new CostCalculator(projectRoot); + const { total, byModel } = calc.getTotalCost(); + + assert.equal(total.totalCost, 0.42); + assert.ok(byModel["model-a"]); + assert.equal(byModel["model-a"].totalCost, 0.14); + assert.ok(byModel["model-b"]); + assert.equal(byModel["model-b"].totalCost, 0.28); + }); + + test("getTotalCost prefers pre-computed totalCost", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [makeSessionEntry("s1", makeUsage({ prompt_tokens: 1_000, completion_tokens: 500 }))]; + // Pre-computed value larger than what entries would produce + writeSessionsIndex(homeDir, projectRoot, entries, { totalCost: 99.99 }); + + const calc = new CostCalculator(projectRoot); + const { total } = calc.getTotalCost(); + + assert.equal(total.totalCost, 99.99); + }); + + test("getPricing returns the resolved pricing config", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const calc = new CostCalculator(projectRoot, { inputPerMillion: 1.5 }); + const pricing = calc.getPricing(); + + assert.equal(pricing.inputPerMillion, 1.5); + assert.equal(pricing.inputCacheHitPerMillion, DEFAULT_PRICING.inputCacheHitPerMillion); + assert.equal(pricing.outputPerMillion, DEFAULT_PRICING.outputPerMillion); + }); + + test("handles missing sessions-index.json gracefully", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const calc = new CostCalculator(projectRoot); + const cost = calc.getSessionCost(); + + assert.ok(cost); + assert.equal(cost.totalCost, 0); + }); +}); + +// --------------------------------------------------------------------------- +// DEFAULT_PRICING validation +// --------------------------------------------------------------------------- + +describe("DEFAULT_PRICING", () => { + test("matches DeepSeek V4 official rates", () => { + assert.equal(DEFAULT_PRICING.inputPerMillion, 0.14); + assert.equal(DEFAULT_PRICING.inputCacheHitPerMillion, 0.0028); + assert.equal(DEFAULT_PRICING.outputPerMillion, 0.28); + + // Cache hit should be exactly 2% of cache miss (0.0028 / 0.14 = 0.02) + const ratio = DEFAULT_PRICING.inputCacheHitPerMillion / DEFAULT_PRICING.inputPerMillion; + assert.ok(Math.abs(ratio - 0.02) < 1e-10, `ratio ${ratio} should be ~0.02`); + + // Output should be 2x input (0.28 / 0.14 = 2) + const outInRatio = DEFAULT_PRICING.outputPerMillion / DEFAULT_PRICING.inputPerMillion; + assert.equal(outInRatio, 2); + }); +}); diff --git a/packages/core/src/tests/integration.test.ts b/packages/core/src/tests/integration.test.ts new file mode 100644 index 00000000..f4f99f1a --- /dev/null +++ b/packages/core/src/tests/integration.test.ts @@ -0,0 +1,659 @@ +/** + * Integration tests for the full context-usage pipeline: + * + * API call → Token accumulation → Cost calculation + * → sessions-index.json persistence → UsageTracker reading + * → ContextBar thresholds → /cost command output + * → /compact command → session-state recovery + * + * These tests verify the end-to-end correctness of every piece built + * across the previous tasks (usage-tracker, cost-calculator, cost-calculator + * via computeSessionEntryCost inside SessionManager, ContextBar, /cost, + * and /compact). + */ + +import { after, afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +// ---- core imports ---- +import { SessionManager, getProjectCode } from "../session"; +import { UsageTracker } from "../common/usage-tracker"; +import { CostCalculator, computeCost, computeCostFromUsage } from "../common/cost-calculator"; +import { DEFAULT_PRICING } from "../settings"; +import type { ModelUsage, SessionEntry, SessionMessage } from "../session"; +import type { CostBreakdown } from "../common/cost-calculator"; + +// ---- helpers duplicated from session.test.ts to keep this file standalone ---- + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempDirs: string[] = []; + +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function createChatResponse(content: string, usage: Record): unknown { + return { + choices: [{ message: { content } }], + usage, + }; +} + +function isSkillMatchingRequest(request: any): boolean { + return ( + request && + (request.response_format?.type === "json_object" || + (Array.isArray(request.messages) && + request.messages.some((m: any) => typeof m?.content === "string" && m.content.includes("candidate skills")))) + ); +} + +function createSkillMatchingResponse(skillNames: string[] = []): unknown { + return { + choices: [{ message: { content: JSON.stringify({ skillNames }) } }], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + }; +} + +function createMockedClientSessionManager(projectRoot: string, responses: unknown[]): SessionManager { + const remaining = [...responses]; + + return createMockedClientSessionManagerWithClient(projectRoot, { + chat: { + completions: { + create: async (request: any) => { + if (isSkillMatchingRequest(request)) { + return createSkillMatchingResponse(); + } + const response = remaining.shift(); + assert.ok(response, "expected a queued chat response but none remained"); + return response; + }, + }, + }, + }); +} + +function createMockedClientSessionManagerWithClient(projectRoot: string, client: unknown): SessionManager { + return new SessionManager({ + projectRoot, + createOpenAIClient: () => ({ + client: client as any, + model: "test-model", + baseURL: "https://api.test.invalid", + temperature: undefined, + thinkingEnabled: false, + reasoningEffort: "max", + debugLogEnabled: false, + telemetryEnabled: false, + notify: "", + env: {}, + }), + getResolvedSettings: () => ({ + model: "test-model", + }), + renderMarkdown: (text: string) => text, + onAssistantMessage: () => {}, + }); +} + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +// --------------------------------------------------------------------------- +// Part A – Token accumulation + cost calculation via SessionManager +// --------------------------------------------------------------------------- + +describe("Integration: Token accumulation → Cost calculation → Persistence", () => { + test("single API call accumulates usage and costs correctly", async () => { + const workspace = createTempDir("integ-usage-workspace-"); + const home = createTempDir("integ-usage-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("hello", { + prompt_tokens: 10_000, + completion_tokens: 5_000, + total_tokens: 15_000, + prompt_cache_hit_tokens: 2_000, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + const sessionId = await manager.createSession({ text: "test prompt" }); + + const session = manager.getSession(sessionId); + assert.ok(session); + assert.ok(session.usage); + + // Token accumulation + assert.equal(session.usage.prompt_tokens, 10_000); + assert.equal(session.usage.completion_tokens, 5_000); + assert.equal(session.usage.total_tokens, 15_000); + assert.equal(session.usage.prompt_cache_hit_tokens, 2_000); + + // Cost from the pure computeCost function + const cost = computeCost(10_000, 5_000, 2_000, DEFAULT_PRICING); + // miss: 8000/1M * 0.14 = 0.00112 + // hit: 2000/1M * 0.0028 = 0.000006 (rounded) + // out: 5000/1M * 0.28 = 0.0014 + // total = 0.002526 + assert.equal(cost.inputCost, 0.00112); + assert.equal(cost.outputCost, 0.0014); + assert.equal(cost.totalCost, 0.002526); + + // sessions-index.json should have the pre-computed totalCost + const tracker = new UsageTracker(workspace); + const snapshot = tracker.getSnapshot(); + assert.ok(snapshot.sessions[sessionId]); + assert.equal(snapshot.sessions[sessionId].promptTokens, 10_000); + assert.equal(snapshot.sessions[sessionId].totalTokens, 15_000); + }); + + test("multiple API calls accumulate usage and cost additively", async () => { + const workspace = createTempDir("integ-multi-workspace-"); + const home = createTempDir("integ-multi-home-"); + setHomeDir(home); + + // Two turns: response A + response B + const responses = [ + createChatResponse("first", { + prompt_tokens: 100_000, + completion_tokens: 50_000, + total_tokens: 150_000, + prompt_cache_hit_tokens: 20_000, + }), + createChatResponse("second", { + prompt_tokens: 200_000, + completion_tokens: 100_000, + total_tokens: 300_000, + prompt_cache_hit_tokens: 50_000, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + + const sessionId = await manager.createSession({ text: "" }); + // First turn already consumed by createSession + // Second turn needs a reply + await manager.replySession(sessionId, { text: "continue" }); + + const session = manager.getSession(sessionId); + assert.ok(session?.usage); + + // Accumulated tokens: 100k+200k prompt, 50k+100k completion + assert.equal(session.usage.prompt_tokens, 300_000); + assert.equal(session.usage.completion_tokens, 150_000); + assert.equal(session.usage.total_tokens, 450_000); + assert.equal(session.usage.prompt_cache_hit_tokens, 70_000); + + // Cost check + const cost = computeCost(300_000, 150_000, 70_000, DEFAULT_PRICING); + // miss: 230000/1M * 0.14 = 0.0322 + // hit: 70000/1M * 0.0028 = 0.000196 + // out: 150000/1M * 0.28 = 0.042 + // total = 0.074396 + assert.equal(cost.totalCost, 0.074396); + + // Persistence: the sessions-index should be readable by UsageTracker + const tracker = new UsageTracker(workspace); + const total = tracker.getTotalUsage(); + assert.equal(total.promptTokens, 300_000); + assert.equal(total.totalTokens, 450_000); + }); + + test("sessions-index.json totalCost field is persisted and readable", async () => { + const workspace = createTempDir("integ-persist-workspace-"); + const home = createTempDir("integ-persist-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("ok", { + prompt_tokens: 1_000_000, + completion_tokens: 500_000, + total_tokens: 1_500_000, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + await manager.createSession({ text: "" }); + + // Read the sessions-index.json directly from disk + const code = getProjectCode(workspace); + const indexPath = path.join(home, ".deepcode", "projects", code, "sessions-index.json"); + + assert.ok(fs.existsSync(indexPath), "sessions-index.json should exist"); + + const raw = JSON.parse(fs.readFileSync(indexPath, "utf8")); + assert.equal(typeof raw.version, "number"); + assert.ok(Array.isArray(raw.entries)); + assert.equal(raw.entries.length, 1); + + // totalCost should be pre-computed and persisted + assert.ok(typeof raw.totalCost === "number", "totalCost should be a number"); + assert.ok(raw.totalCost > 0, "totalCost should be positive"); + + // 1M prompt + 500k completion + // cost = 1M/1M*0.14 + 500k/1M*0.28 = 0.14 + 0.14 = 0.28 + assert.equal(raw.totalCost, 0.28); + + // Also readable via CostCalculator + const calc = new CostCalculator(workspace); + const { total } = calc.getTotalCost(); + assert.equal(total.totalCost, 0.28); + }); +}); + +// --------------------------------------------------------------------------- +// Part B – ContextBar thresholds +// --------------------------------------------------------------------------- + +describe("Integration: ContextBar threshold logic", () => { + function computeContextUsage(totalTokens: number, maxContextTokens: number) { + if (maxContextTokens <= 0) return null; + const percentage = Math.min(100, Math.round((totalTokens / maxContextTokens) * 100)); + return { + totalTokens, + maxContextTokens, + percentage, + level: percentage >= 90 ? "red" : percentage >= 70 ? "yellow" : "normal", + }; + } + + test("below 70% returns normal level", () => { + const result = computeContextUsage(500_000, 1_000_000); + assert.ok(result); + assert.equal(result.percentage, 50); + assert.equal(result.level, "normal"); + }); + + test("at 70% exactly returns yellow level", () => { + const result = computeContextUsage(700_000, 1_000_000); + assert.ok(result); + assert.equal(result.percentage, 70); + assert.equal(result.level, "yellow"); + }); + + test("at 75% returns yellow level", () => { + const result = computeContextUsage(750_000, 1_000_000); + assert.ok(result); + assert.equal(result.level, "yellow"); + }); + + test("at 90% exactly returns red level", () => { + const result = computeContextUsage(900_000, 1_000_000); + assert.ok(result); + assert.equal(result.percentage, 90); + assert.equal(result.level, "red"); + }); + + test("at 95% returns red level", () => { + const result = computeContextUsage(950_000, 1_000_000); + assert.ok(result); + assert.equal(result.level, "red"); + }); + + test("at 100% returns red level, percentage capped at 100", () => { + const result = computeContextUsage(1_200_000, 1_000_000); + assert.ok(result); + assert.equal(result.percentage, 100); + assert.equal(result.level, "red"); + }); + + test("zero tokens returns normal level", () => { + const result = computeContextUsage(0, 1_000_000); + assert.ok(result); + assert.equal(result.percentage, 0); + assert.equal(result.level, "normal"); + }); + + test("formatLargeNumber formats tokens correctly", () => { + // Test the formatter used in ContextBar + function formatLargeNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${Math.round(n / 1000)}k`; + return String(n); + } + + assert.equal(formatLargeNumber(0), "0"); + assert.equal(formatLargeNumber(500), "500"); + assert.equal(formatLargeNumber(1_500), "2k"); + assert.equal(formatLargeNumber(45_200), "45k"); + assert.equal(formatLargeNumber(999_999), "1000k"); + assert.equal(formatLargeNumber(1_000_000), "1.0M"); + assert.equal(formatLargeNumber(1_500_000), "1.5M"); + assert.equal(formatLargeNumber(10_000_000), "10.0M"); + }); +}); + +// --------------------------------------------------------------------------- +// Part C – /cost command output formatting +// --------------------------------------------------------------------------- + +describe("Integration: /cost command report formatting", () => { + function buildCostReport(data: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + inputCost: number; + outputCost: number; + totalCost: number; + maxContextTokens: number; + }): string { + function fmt(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${Math.round(n / 1000).toLocaleString("en-US")}k`; + return n.toLocaleString("en-US"); + } + const pct = data.maxContextTokens > 0 ? `${((data.totalTokens / data.maxContextTokens) * 100).toFixed(2)}%` : "N/A"; + const usage = + data.maxContextTokens > 0 ? `${fmt(data.totalTokens)}/${fmt(data.maxContextTokens)}` : fmt(data.totalTokens); + + return [ + "📊 Session Cost", + "────────────────", + `Input tokens : ${fmt(data.promptTokens)}`, + `Output tokens : ${fmt(data.completionTokens)}`, + `Total tokens : ${fmt(data.totalTokens)}`, + `Input cost : $${data.inputCost.toFixed(4)}`, + `Output cost : $${data.outputCost.toFixed(4)}`, + `Total cost : $${data.totalCost.toFixed(4)}`, + `Context usage : ${pct} (${usage})`, + ].join("\n"); + } + + test("report for a typical session", () => { + const report = buildCostReport({ + promptTokens: 45_200, + completionTokens: 12_300, + totalTokens: 57_500, + inputCost: 0.0063, + outputCost: 0.0034, + totalCost: 0.0097, + maxContextTokens: 1_000_000, + }); + + assert.ok(report.includes("📊 Session Cost")); + assert.ok(report.includes("Input tokens : 45k")); + assert.ok(report.includes("Output tokens : 12k")); + assert.ok(report.includes("Total tokens : 58k")); + assert.ok(report.includes("Input cost : $0.0063")); + assert.ok(report.includes("Output cost : $0.0034")); + assert.ok(report.includes("Total cost : $0.0097")); + assert.ok(report.includes("Context usage : 5.75% (58k/1.0M)")); + }); + + test("report for a heavy session (90+%)", () => { + const report = buildCostReport({ + promptTokens: 700_000, + completionTokens: 220_000, + totalTokens: 920_000, + inputCost: 0.098, + outputCost: 0.0616, + totalCost: 0.1596, + maxContextTokens: 1_000_000, + }); + + assert.ok(report.includes("Total tokens : 920k")); + assert.ok(report.includes("Context usage : 92.00% (920k/1.0M)")); + assert.ok(report.includes("Total cost : $0.1596")); + }); + + test("report with no max context shows N/A", () => { + const report = buildCostReport({ + promptTokens: 1000, + completionTokens: 500, + totalTokens: 1500, + inputCost: 0.00014, + outputCost: 0.00014, + totalCost: 0.00028, + maxContextTokens: 0, + }); + + assert.ok(report.includes("Context usage : N/A"), "should show N/A when context is 0"); + }); +}); + +// --------------------------------------------------------------------------- +// Part D – /compact command + session recovery +// --------------------------------------------------------------------------- + +describe("Integration: /compact command and session recovery", () => { + test("compacting a session reduces activeTokens", async () => { + const workspace = createTempDir("integ-compact-workspace-"); + const home = createTempDir("integ-compact-home-"); + setHomeDir(home); + + // Build 3 responses so we accumulate a decent conversation + const responses = [ + createChatResponse("turn 1", { + prompt_tokens: 100, + completion_tokens: 50, + total_tokens: 150, + }), + createChatResponse("turn 2", { + prompt_tokens: 200, + completion_tokens: 100, + total_tokens: 300, + }), + createChatResponse("turn 3", { + prompt_tokens: 300, + completion_tokens: 150, + total_tokens: 450, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + + const sessionId = await manager.createSession({ text: "hello" }); + + // Before compaction, verify usage exists + let session = manager.getSession(sessionId); + assert.ok(session?.usage); + const tokensBefore = session.usage.total_tokens; + assert.equal(tokensBefore, 150); + + // Do a second reply + await manager.replySession(sessionId, { text: "more" }); + session = manager.getSession(sessionId); + const tokensAfterTwo = session?.usage?.total_tokens; + assert.ok(tokensAfterTwo); + assert.ok(tokensAfterTwo >= tokensBefore, "tokens should accumulate"); + + // Trigger compaction explicitly + await manager.compactSession(sessionId); + + // After compaction the session should still be valid + session = manager.getSession(sessionId); + assert.ok(session, "session should still exist after compaction"); + + // Messages should have some compacted entries + const messages = manager.listSessionMessages(sessionId); + const compactedCount = messages.filter((m) => m.compacted).length; + assert.ok(compactedCount > 0, "some messages should be compacted"); + + // Session index should still be readable + const tracker = new UsageTracker(workspace); + const snapshot = tracker.getSnapshot(); + assert.ok(snapshot.total.totalTokens >= 0); + }); + + test("session persists correctly after compaction", async () => { + const workspace = createTempDir("integ-compact-persist-workspace-"); + const home = createTempDir("integ-compact-persist-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("msg", { + prompt_tokens: 50_000, + completion_tokens: 25_000, + total_tokens: 75_000, + }), + // The compact call itself will make an API request + createChatResponse("summary", { + prompt_tokens: 5_000, + completion_tokens: 1_000, + total_tokens: 6_000, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + + const sessionId = await manager.createSession({ text: "test" }); + + // Trigger compaction + await manager.compactSession(sessionId); + + // sessions-index.json must still be valid JSON + const code = getProjectCode(workspace); + const indexPath = path.join(home, ".deepcode", "projects", code, "sessions-index.json"); + + assert.ok(fs.existsSync(indexPath)); + const raw = JSON.parse(fs.readFileSync(indexPath, "utf8")); + assert.ok(Array.isArray(raw.entries)); + + // The session entry should be present + const entry = raw.entries.find((e: any) => e.id === sessionId); + assert.ok(entry, "session entry should still be in the index"); + assert.ok(typeof entry.usage?.total_tokens === "number"); + }); +}); + +// --------------------------------------------------------------------------- +// Part E – End-to-end: CostCalculator reads from SessionManager output +// --------------------------------------------------------------------------- + +describe("Integration: CostCalculator reads SessionManager-persisted data", () => { + test("CostCalculator matches SessionManager usage after multi-turn session", async () => { + const workspace = createTempDir("integ-e2e-workspace-"); + const home = createTempDir("integ-e2e-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("turn 1", { + prompt_tokens: 100_000, + completion_tokens: 50_000, + total_tokens: 150_000, + prompt_cache_hit_tokens: 10_000, + }), + createChatResponse("turn 2", { + prompt_tokens: 200_000, + completion_tokens: 80_000, + total_tokens: 280_000, + prompt_cache_hit_tokens: 30_000, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + + const sessionId = await manager.createSession({ text: "" }); + await manager.replySession(sessionId, { text: "" }); + + const session = manager.getSession(sessionId); + assert.ok(session?.usage); + + // Now read the same data through CostCalculator + const calc = new CostCalculator(workspace); + const sessionCost = calc.getSessionCost(sessionId); + assert.ok(sessionCost); + + // Cross-check: compute cost directly from SessionManager's usage + const directCost = computeCostFromUsage(session.usage!, DEFAULT_PRICING); + assert.equal(sessionCost.totalCost, directCost.totalCost); + assert.equal(sessionCost.inputCost, directCost.inputCost); + assert.equal(sessionCost.outputCost, directCost.outputCost); + + // The totalCost in sessions-index should match + const { total } = calc.getTotalCost(); + assert.equal(total.totalCost, sessionCost.totalCost); + }); +}); + +// --------------------------------------------------------------------------- +// Part F – UsageTracker snapshot completeness +// --------------------------------------------------------------------------- + +describe("Integration: UsageTracker provides complete snapshot for UI", () => { + test("snapshot includes per-session, total, and byModel data", async () => { + const workspace = createTempDir("integ-snapshot-workspace-"); + const home = createTempDir("integ-snapshot-home-"); + setHomeDir(home); + + const responses = [ + createChatResponse("msg", { + prompt_tokens: 10_000, + completion_tokens: 5_000, + total_tokens: 15_000, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + const sessionId = await manager.createSession({ text: "" }); + + const tracker = new UsageTracker(workspace); + const snapshot = tracker.getSnapshot(); + + // Per-session + assert.ok(snapshot.sessions[sessionId]); + assert.equal(snapshot.sessions[sessionId].promptTokens, 10_000); + assert.equal(snapshot.sessions[sessionId].totalTokens, 15_000); + + // Total + assert.equal(snapshot.total.promptTokens, 10_000); + assert.equal(snapshot.total.totalTokens, 15_000); + // totalRequests may be 0 when total_reqs is not populated on per-session usage + + // By model + assert.ok(snapshot.byModel["test-model"]); + assert.equal(snapshot.byModel["test-model"].promptTokens, 10_000); + }); + + test("getUsage returns correct percentage for ContextBar", async () => { + const workspace = createTempDir("integ-pct-workspace-"); + const home = createTempDir("integ-pct-home-"); + setHomeDir(home); + + // Use half the max context + const halfMax = 500_000; + const responses = [ + createChatResponse("msg", { + prompt_tokens: halfMax, + completion_tokens: 0, + total_tokens: halfMax, + }), + ]; + const manager = createMockedClientSessionManager(workspace, responses); + await manager.createSession({ text: "" }); + + const tracker = new UsageTracker(workspace, 1_000_000); + const usage = tracker.getUsage(); + + assert.equal(usage.usedTokens, halfMax); + assert.equal(usage.maxContextTokens, 1_000_000); + assert.equal(usage.percentage, 50); + assert.equal(tracker.getRemaining(), 500_000); + }); +}); diff --git a/packages/core/src/tests/sub-agent-runner.test.ts b/packages/core/src/tests/sub-agent-runner.test.ts new file mode 100644 index 00000000..21f7f885 --- /dev/null +++ b/packages/core/src/tests/sub-agent-runner.test.ts @@ -0,0 +1,128 @@ +import { 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 { SubAgentRunner } from "../common/sub-agent-runner"; +import { ToolExecutor } from "../tools/executor"; +import { handleDelegateTool } from "../tools/delegate-handler"; +import type { ToolExecutionContext } from "../tools/executor"; + +// ---- SubAgentRunner unit tests ---- + +test("SubAgentRunner can be instantiated", () => { + const runner = new SubAgentRunner(); + assert.ok(runner instanceof SubAgentRunner); +}); + +test("SubAgentRunner returns error when no API client", async () => { + const runner = new SubAgentRunner(); + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-subagent-")); + + try { + const results = await runner.runTasks([{ description: "Find all TypeScript files" }], { + projectRoot: workspace, + createOpenAIClient: () => ({ client: null, model: "", thinkingEnabled: false }), + toolExecutor: new ToolExecutor(workspace, () => ({ client: null, model: "", thinkingEnabled: false })), + }); + + assert.equal(results.length, 1); + assert.equal(results[0]!.ok, false); + assert.ok(results[0]!.errors.includes("No API client configured")); + assert.equal(results[0]!.taskIndex, 0); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +// ---- Delegate tool handler tests ---- + +test("Delegate tool rejects empty tasks array", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-delegate-")); + + try { + const context: ToolExecutionContext = { + sessionId: "test-session", + projectRoot: workspace, + toolCall: { id: "call-1", type: "function", function: { name: "Delegate", arguments: "" } }, + }; + + const result = await handleDelegateTool({ tasks: [], parallel: true }, context); + + assert.equal(result.ok, false); + assert.ok(result.error?.includes("tasks")); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("Delegate tool rejects missing description", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-delegate-")); + + try { + const context: ToolExecutionContext = { + sessionId: "test-session", + projectRoot: workspace, + toolCall: { id: "call-1", type: "function", function: { name: "Delegate", arguments: "" } }, + }; + + const result = await handleDelegateTool({ tasks: [{ tools: ["read"] }], parallel: true }, context); + + assert.equal(result.ok, false); + assert.ok(result.error?.includes("description")); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("Delegate tool requires tasks array", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-delegate-")); + + try { + const context: ToolExecutionContext = { + sessionId: "test-session", + projectRoot: workspace, + toolCall: { id: "call-1", type: "function", function: { name: "Delegate", arguments: "" } }, + }; + + const result = await handleDelegateTool({ tasks: null }, context); + assert.equal(result.ok, false); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); + +test("Delegate tool accepts valid tasks (no API call)", async () => { + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "deepcode-delegate-")); + + try { + const context: ToolExecutionContext = { + sessionId: "test-session", + projectRoot: workspace, + toolCall: { id: "call-1", type: "function", function: { name: "Delegate", arguments: "" } }, + createOpenAIClient: () => ({ + client: null, + model: "test-model", + thinkingEnabled: false, + }), + }; + + const result = await handleDelegateTool( + { + tasks: [ + { description: "Search for function X", tools: ["read", "bash"], maxIterations: 3 }, + { description: "Search for function Y" }, + ], + parallel: false, + }, + context + ); + + // Should fail because the sub-agent runner has no actual API client + // But the schema validation should have passed + assert.equal(result.ok, false); + assert.ok(result.output?.includes("Sub-Agent Results")); + } finally { + fs.rmSync(workspace, { recursive: true, force: true }); + } +}); diff --git a/packages/core/src/tests/task-plan-manager.test.ts b/packages/core/src/tests/task-plan-manager.test.ts new file mode 100644 index 00000000..af5a0dab --- /dev/null +++ b/packages/core/src/tests/task-plan-manager.test.ts @@ -0,0 +1,186 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { TaskPlanManager } from "../common/task-plan-manager"; +import { createTaskPlan, formatPlanMarkdown } from "../common/task-plan"; +import type { TaskStepInput } from "../common/task-plan"; + +const sessionId = "test-session-1"; + +const sampleSteps: TaskStepInput[] = [ + { + description: "Add validation to UserService", + verification: { type: "command", command: "npm test -- UserService" }, + fallback: { type: "rollback_and_retry", alternativeApproach: "Use zod schema instead" }, + maxRetries: 2, + }, + { + description: "Add unit tests", + verification: { type: "command", command: "npm test" }, + fallback: { type: "ask_user" }, + }, + { + description: "Update documentation", + fallback: { type: "rollback_and_skip" }, + }, +]; + +test("createPlan initializes all steps as pending", () => { + const plan = createTaskPlan(sessionId, sampleSteps); + assert.equal(plan.steps.length, 3); + assert.equal(plan.state, "planning"); + assert.equal(plan.currentStepIndex, 0); + for (const step of plan.steps) { + assert.equal(step.status, "pending"); + } +}); + +test("TaskPlanManager lifecycle: start → verify → complete → next", () => { + const manager = new TaskPlanManager(); + manager.createPlan(sessionId, sampleSteps); + + // Start step 0 + manager.startStep(sessionId, 0, "abc123"); + const step0 = manager.getCurrentStep(sessionId)!; + assert.equal(step0.status, "in_progress"); + assert.equal(step0.checkpointHash, "abc123"); + + // Needs verification + assert.equal(manager.needsVerification(sessionId), true); + assert.equal(manager.getVerification(sessionId)?.command, "npm test -- UserService"); + + // Mark verifying + manager.markVerifying(sessionId); + assert.equal(manager.getCurrentStep(sessionId)!.status, "verifying"); + + // Complete step 0 + manager.completeStep(sessionId, 0); + + // Step 0 should be completed, and currentStepIndex advanced + const plan = manager.getPlan(sessionId)!; + assert.equal(plan.steps[0].status, "completed"); + assert.equal(plan.currentStepIndex, 1); + assert.equal(manager.getCurrentStep(sessionId)!.description, "Add unit tests"); +}); + +test("TaskPlanManager: fail → retry → fail → fallback", () => { + const manager = new TaskPlanManager(); + manager.createPlan(sessionId, sampleSteps); + manager.startStep(sessionId, 0, "def456"); + + // Fail step 0 + manager.failStep(sessionId, 0); + assert.equal(manager.getPlan(sessionId)!.state, "failed"); + + // Should retry (retryCount=0 < maxRetries=2) + assert.equal(manager.shouldRetry(sessionId), true); + + // Retry - increment and reset to pending + manager.incrementRetry(sessionId); + const step = manager.getCurrentStep(sessionId)!; + assert.equal(step.retryCount, 1); + assert.equal(step.status, "pending"); + assert.equal(manager.getPlan(sessionId)!.state, "executing"); + + // Start again, fail again + manager.startStep(sessionId, 0, "ghi789"); + manager.failStep(sessionId, 0); + + // Retry again (last attempt) + assert.equal(manager.shouldRetry(sessionId), true); + manager.incrementRetry(sessionId); + assert.equal(manager.getCurrentStep(sessionId)!.retryCount, 2); + + manager.startStep(sessionId, 0, "jkl012"); + manager.failStep(sessionId, 0); + + // No more retries + assert.equal(manager.shouldRetry(sessionId), false); + + // Fallback should be rollback_and_retry + const fallback = manager.getFallback(sessionId)!; + assert.equal(fallback.type, "rollback_and_retry"); + assert.equal(fallback.alternativeApproach, "Use zod schema instead"); +}); + +test("TaskPlanManager: skip failed step", () => { + const manager = new TaskPlanManager(); + manager.createPlan(sessionId, sampleSteps); + manager.startStep(sessionId, 0, "skip-hash"); + manager.failStep(sessionId, 0); + + // Skip to next step + manager.skipFailedStep(sessionId); + const plan = manager.getPlan(sessionId)!; + assert.equal(plan.currentStepIndex, 1); + assert.equal(plan.steps[0].status, "completed"); + assert.equal(plan.state, "executing"); +}); + +test("TaskPlanManager: plan completes when all steps done", () => { + const manager = new TaskPlanManager(); + manager.createPlan(sessionId, sampleSteps); + + manager.startStep(sessionId, 0, "c1"); + manager.completeStep(sessionId, 0); + manager.startStep(sessionId, 1, "c2"); + manager.completeStep(sessionId, 1); + manager.startStep(sessionId, 2, "c3"); + manager.completeStep(sessionId, 2); + + assert.equal(manager.getPlan(sessionId)!.state, "completed"); +}); + +test("TaskPlanManager: no verification for steps without verification config", () => { + const manager = new TaskPlanManager(); + manager.createPlan(sessionId, sampleSteps); + + // Steps 0-1 have verification, skip to step 2 (no verification) + manager.startStep(sessionId, 0, "c1"); + manager.completeStep(sessionId, 0); + manager.startStep(sessionId, 1, "c2"); + manager.completeStep(sessionId, 1); + + // Step 2 has no verification + assert.equal(manager.needsVerification(sessionId), false); + assert.equal(manager.getVerification(sessionId), null); +}); + +test("formatPlanMarkdown produces correct markdown", () => { + const plan = createTaskPlan(sessionId, sampleSteps); + plan.steps[0].status = "completed"; + plan.steps[1].status = "in_progress"; + plan.steps[1].retryCount = 1; + + const markdown = formatPlanMarkdown(plan); + const lines = markdown.split("\n"); + + assert.match(lines[0] ?? "", /^\[x\] Add validation/); + assert.match(lines[1] ?? "", /^\[>\] Add unit tests \(retry 1\/2\) → verify: `npm test`/); + assert.match(lines[2] ?? "", /^\[ \] Update documentation/); +}); + +test("hasActivePlan returns correct state", () => { + const manager = new TaskPlanManager(); + const id = "has-active-plan-test"; + assert.equal(manager.hasActivePlan(id), false); + + manager.createPlan(id, sampleSteps); + assert.equal(manager.hasActivePlan(id), true); + + manager.clearPlan(id); + assert.equal(manager.hasActivePlan(id), false); +}); + +test("getPlanMarkdown returns empty for non-existent plan", () => { + const manager = new TaskPlanManager(); + assert.equal(manager.getPlanMarkdown("no-such-session"), ""); +}); + +test("non-existent session returns null for all queries", () => { + const manager = new TaskPlanManager(); + assert.equal(manager.getPlan("nonexistent"), null); + assert.equal(manager.getCurrentStep("nonexistent"), null); + assert.equal(manager.needsVerification("nonexistent"), false); + assert.equal(manager.shouldRetry("nonexistent"), false); + assert.equal(manager.getFallback("nonexistent"), null); +}); diff --git a/packages/core/src/tests/usage-tracker.test.ts b/packages/core/src/tests/usage-tracker.test.ts new file mode 100644 index 00000000..d9d95c46 --- /dev/null +++ b/packages/core/src/tests/usage-tracker.test.ts @@ -0,0 +1,481 @@ +import { afterEach, describe, 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 { UsageTracker, DEFAULT_MAX_CONTEXT_TOKENS } from "../common/usage-tracker"; +import type { SessionsIndexWithAggregates } from "../common/usage-tracker"; +import { getProjectCode } from "../session"; +import type { SessionEntry, ModelUsage } from "../session"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempDirs: string[] = []; + +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "usage-tracker-test-")); + tempDirs.push(dir); + return dir; +} + +function makeUsage(promptTokens: number, completionTokens: number, totalReqs = 1): ModelUsage { + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + total_reqs: totalReqs, + }; +} + +function makeSessionEntry( + id: string, + usage: ModelUsage | null, + usagePerModel: Record | null = null +): SessionEntry { + return { + id, + summary: `Session ${id.slice(0, 8)}`, + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage, + usagePerModel, + activeTokens: 0, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + processes: null, + }; +} + +function writeSessionsIndex( + homeDir: string, + projectRoot: string, + entries: SessionEntry[], + aggregates?: { + totalPromptTokens?: number; + totalCompletionTokens?: number; + totalTokens?: number; + } +): void { + const projectCode = getProjectCode(projectRoot); + const projectDir = path.join(homeDir, ".deepcode", "projects", projectCode); + fs.mkdirSync(projectDir, { recursive: true }); + const indexPath = path.join(projectDir, "sessions-index.json"); + const index: SessionsIndexWithAggregates = { + version: 1, + entries, + originalPath: projectRoot, + ...aggregates, + }; + fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), "utf8"); +} + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("UsageTracker", () => { + test("getUsage returns zero for empty sessions index", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + writeSessionsIndex(homeDir, projectRoot, []); + const tracker = new UsageTracker(projectRoot); + const usage = tracker.getUsage(); + + assert.equal(usage.usedTokens, 0); + assert.equal(usage.maxContextTokens, DEFAULT_MAX_CONTEXT_TOKENS); + assert.equal(usage.percentage, 0); + }); + + test("getRemaining returns full context when no tokens used", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + writeSessionsIndex(homeDir, projectRoot, []); + const tracker = new UsageTracker(projectRoot); + + assert.equal(tracker.getRemaining(), DEFAULT_MAX_CONTEXT_TOKENS); + }); + + test("getTotalUsage aggregates usage across sessions", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", makeUsage(100, 50, 1)), + makeSessionEntry("s2", makeUsage(200, 100, 2)), + makeSessionEntry("s3", makeUsage(300, 150, 1)), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const total = tracker.getTotalUsage(); + + assert.equal(total.promptTokens, 600); + assert.equal(total.completionTokens, 300); + assert.equal(total.totalTokens, 900); + assert.equal(total.totalRequests, 4); + }); + + test("getSessionUsage returns usage for a specific session", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("session-a", makeUsage(100, 50)), + makeSessionEntry("session-b", makeUsage(200, 100)), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + + const usageA = tracker.getSessionUsage("session-a"); + assert.ok(usageA); + assert.equal(usageA.promptTokens, 100); + assert.equal(usageA.completionTokens, 50); + + const usageB = tracker.getSessionUsage("session-b"); + assert.ok(usageB); + assert.equal(usageB.promptTokens, 200); + assert.equal(usageB.completionTokens, 100); + + const usageMissing = tracker.getSessionUsage("nonexistent"); + assert.equal(usageMissing, null); + }); + + test("getUsageByModel returns per-model breakdown", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", makeUsage(100, 50), { + "deepseek-v4-pro": makeUsage(100, 50, 1), + }), + makeSessionEntry("s2", makeUsage(200, 100), { + "deepseek-v4-pro": makeUsage(150, 75, 1), + "deepseek-v4-flash": makeUsage(50, 25, 1), + }), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const byModel = tracker.getUsageByModel(); + + assert.ok(byModel["deepseek-v4-pro"]); + assert.equal(byModel["deepseek-v4-pro"].promptTokens, 250); + assert.equal(byModel["deepseek-v4-pro"].completionTokens, 125); + assert.equal(byModel["deepseek-v4-pro"].totalRequests, 2); + + assert.ok(byModel["deepseek-v4-flash"]); + assert.equal(byModel["deepseek-v4-flash"].promptTokens, 50); + assert.equal(byModel["deepseek-v4-flash"].completionTokens, 25); + assert.equal(byModel["deepseek-v4-flash"].totalRequests, 1); + }); + + test("getUsage computes correct percentage", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + // Use half the default max context + const halfMax = Math.floor(DEFAULT_MAX_CONTEXT_TOKENS / 2); + const entries = [ + makeSessionEntry("s1", { + prompt_tokens: halfMax, + completion_tokens: 0, + total_tokens: halfMax, + total_reqs: 1, + }), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const usage = tracker.getUsage(); + + assert.equal(usage.percentage, 50); + assert.equal(usage.usedTokens, halfMax); + assert.equal(usage.maxContextTokens, DEFAULT_MAX_CONTEXT_TOKENS); + }); + + test("getRemaining returns correct remaining tokens", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", { + prompt_tokens: 300_000, + completion_tokens: 200_000, + total_tokens: 500_000, + total_reqs: 1, + }), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const remaining = tracker.getRemaining(); + + assert.equal(remaining, DEFAULT_MAX_CONTEXT_TOKENS - 500_000); + }); + + test("getRemaining floors at zero when over capacity", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", { + prompt_tokens: 800_000, + completion_tokens: 400_000, + total_tokens: 1_200_000, + total_reqs: 1, + }), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const remaining = tracker.getRemaining(); + + assert.equal(remaining, 0); + }); + + test("getUsage percentage caps at 100 when over capacity", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", { + prompt_tokens: 800_000, + completion_tokens: 400_000, + total_tokens: 1_200_000, + total_reqs: 1, + }), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const usage = tracker.getUsage(); + + assert.equal(usage.percentage, 100); + assert.equal(usage.usedTokens, 1_200_000); + }); + + test("constructor accepts custom maxContextTokens", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const customMax = 500_000; + const entries = [ + makeSessionEntry("s1", { + prompt_tokens: 250_000, + completion_tokens: 0, + total_tokens: 250_000, + total_reqs: 1, + }), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot, customMax); + const usage = tracker.getUsage(); + + assert.equal(usage.maxContextTokens, customMax); + assert.equal(usage.percentage, 50); + assert.equal(tracker.getRemaining(), 250_000); + }); + + test("getSnapshot returns complete snapshot with sessions, total, and byModel", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", makeUsage(100, 50), { + "model-a": makeUsage(100, 50, 1), + }), + makeSessionEntry("s2", makeUsage(200, 100), { + "model-b": makeUsage(200, 100, 1), + }), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const snapshot = tracker.getSnapshot(); + + // Per-session + assert.ok(snapshot.sessions["s1"]); + assert.equal(snapshot.sessions["s1"].promptTokens, 100); + assert.ok(snapshot.sessions["s2"]); + assert.equal(snapshot.sessions["s2"].promptTokens, 200); + + // Total + assert.equal(snapshot.total.promptTokens, 300); + assert.equal(snapshot.total.completionTokens, 150); + assert.equal(snapshot.total.totalTokens, 450); + + // By model + assert.ok(snapshot.byModel["model-a"]); + assert.equal(snapshot.byModel["model-a"].promptTokens, 100); + assert.ok(snapshot.byModel["model-b"]); + assert.equal(snapshot.byModel["model-b"].promptTokens, 200); + }); + + test("handles sessions with null usage gracefully", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries = [ + makeSessionEntry("s1", null), + makeSessionEntry("s2", makeUsage(100, 50)), + makeSessionEntry("s3", null), + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const total = tracker.getTotalUsage(); + + assert.equal(total.promptTokens, 100); + assert.equal(total.completionTokens, 50); + assert.equal(total.totalTokens, 150); + }); + + test("handles missing sessions-index.json gracefully", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + // Don't write a sessions-index.json + + const tracker = new UsageTracker(projectRoot); + const usage = tracker.getUsage(); + + assert.equal(usage.usedTokens, 0); + assert.equal(usage.percentage, 0); + assert.equal(tracker.getRemaining(), DEFAULT_MAX_CONTEXT_TOKENS); + }); + + test("prefers top-level aggregate fields from sessions-index.json", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + // Write entries with individual usage, but also provide pre-computed + // aggregates that are larger (simulating a scenario where the index was + // updated by an external process or a newer version of the tool). + const entries = [makeSessionEntry("s1", makeUsage(100, 50))]; + writeSessionsIndex(homeDir, projectRoot, entries, { + totalPromptTokens: 5000, + totalCompletionTokens: 3000, + totalTokens: 8000, + }); + + const tracker = new UsageTracker(projectRoot); + const total = tracker.getTotalUsage(); + + // Should use the larger pre-computed value + assert.equal(total.promptTokens, 5000); + assert.equal(total.completionTokens, 3000); + assert.equal(total.totalTokens, 8000); + }); + + test("handles sessions with usagePerModel but no top-level usage", () => { + const homeDir = makeTempDir(); + const projectRoot = path.join(homeDir, "project"); + fs.mkdirSync(projectRoot, { recursive: true }); + setHomeDir(homeDir); + + const entries: SessionEntry[] = [ + { + id: "s1", + summary: "test", + assistantReply: null, + assistantThinking: null, + assistantRefusal: null, + toolCalls: null, + status: "completed", + failReason: null, + usage: null, + usagePerModel: { + "deepseek-v4-pro": makeUsage(500, 250, 2), + }, + activeTokens: 0, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + processes: null, + }, + ]; + writeSessionsIndex(homeDir, projectRoot, entries); + + const tracker = new UsageTracker(projectRoot); + const byModel = tracker.getUsageByModel(); + + assert.ok(byModel["deepseek-v4-pro"]); + assert.equal(byModel["deepseek-v4-pro"].promptTokens, 500); + assert.equal(byModel["deepseek-v4-pro"].completionTokens, 250); + assert.equal(byModel["deepseek-v4-pro"].totalRequests, 2); + + // Total should be zero since top-level usage is null + const total = tracker.getTotalUsage(); + assert.equal(total.promptTokens, 0); + assert.equal(total.totalTokens, 0); + }); + + test("DEFAULT_MAX_CONTEXT_TOKENS is 1,000,000", () => { + assert.equal(DEFAULT_MAX_CONTEXT_TOKENS, 1_000_000); + }); +}); diff --git a/packages/core/src/tools/delegate-handler.ts b/packages/core/src/tools/delegate-handler.ts new file mode 100644 index 00000000..4b87f674 --- /dev/null +++ b/packages/core/src/tools/delegate-handler.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; +import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { ToolExecutor } from "./executor"; +import { executeValidatedTool } from "../common/validate"; +import { SubAgentRunner } from "../common/sub-agent-runner"; +import type { SubAgentTask } from "../common/sub-agent-runner"; + +const taskSchema = z.object({ + description: z.string().min(1), + tools: z.array(z.string()).optional(), + context: z.string().optional(), + maxIterations: z.number().int().min(1).max(30).optional(), + timeoutMs: z.number().int().min(5000).max(300000).optional(), +}); + +const delegateSchema = z.strictObject({ + tasks: z.array(taskSchema).min(1).max(8), + parallel: z.boolean().default(true), +}); + +const MAX_SUMMARY_LENGTH = 800; + +export async function handleDelegateTool( + args: Record, + context: ToolExecutionContext +): Promise { + return executeValidatedTool("Delegate", delegateSchema, args, context, async (input) => { + const runner = new SubAgentRunner(); + + if (!context.createOpenAIClient) { + return { + ok: false, + name: "Delegate", + error: "Delegate is not available. No API client configured.", + }; + } + + // Sub-agents get a minimal tool set (read + bash) unless task overrides. + const subAgentToolExecutor = new ToolExecutor(context.projectRoot, context.createOpenAIClient); + + const tasks: SubAgentTask[] = input.tasks.map((t) => ({ + description: t.description, + tools: t.tools, + context: t.context, + maxIterations: t.maxIterations, + timeoutMs: t.timeoutMs, + })); + + const startedAt = Date.now(); + let results; + + if (input.parallel) { + results = await runner.runTasks(tasks, { + projectRoot: context.projectRoot, + createOpenAIClient: context.createOpenAIClient, + toolExecutor: subAgentToolExecutor, + }); + } else { + // Sequential execution + results = []; + for (let i = 0; i < tasks.length; i++) { + const batch = await runner.runTasks([tasks[i]], { + projectRoot: context.projectRoot, + createOpenAIClient: context.createOpenAIClient, + toolExecutor: subAgentToolExecutor, + }); + results.push(...batch); + } + } + + const aggregated = aggregateResults(results, startedAt); + + return { + ok: aggregated.errors.length === 0, + name: "Delegate", + output: aggregated.formatted, + metadata: { + results: aggregated.results.map((r) => ({ + taskIndex: r.taskIndex, + ok: r.ok, + summary: r.summary.slice(0, MAX_SUMMARY_LENGTH), + filesRead: r.filesRead, + filesModified: r.filesModified, + errors: r.errors, + iterations: r.iterations, + tokensUsed: r.tokensUsed, + durationMs: r.durationMs, + })), + totalDurationMs: aggregated.totalDurationMs, + totalTokensUsed: aggregated.totalTokensUsed, + }, + }; + }); +} + +function aggregateResults( + results: Array<{ + taskIndex: number; + ok: boolean; + summary: string; + filesRead: string[]; + filesModified: string[]; + errors: string[]; + iterations: number; + tokensUsed: number; + durationMs: number; + }>, + startedAt: number +) { + const totalDurationMs = Date.now() - startedAt; + const totalTokensUsed = results.reduce((sum, r) => sum + r.tokensUsed, 0); + const allFilesRead = new Set(results.flatMap((r) => r.filesRead)); + const allErrors = results.flatMap((r) => r.errors); + + const parts: string[] = []; + parts.push(`## Sub-Agent Results (${results.length} task(s), ${totalDurationMs}ms, ~${totalTokensUsed} tokens)\n`); + + for (const r of results) { + const status = r.ok ? "✓" : "✗"; + parts.push(`### Task ${r.taskIndex + 1}: ${status} (${r.iterations} iter, ${r.durationMs}ms)`); + parts.push(`${r.summary.slice(0, MAX_SUMMARY_LENGTH)}`); + + if (r.filesRead.length > 0) { + parts.push(`- Files read: ${r.filesRead.join(", ")}`); + } + if (r.filesModified.length > 0) { + parts.push(`- Files modified: ${r.filesModified.join(", ")}`); + } + if (r.errors.length > 0) { + parts.push(`- Errors: ${r.errors.join("; ")}`); + } + parts.push(""); + } + + // Cross-task summary + if (allFilesRead.size > 0) { + parts.push(`### Files touched: ${[...allFilesRead].join(", ")}`); + } + if (allErrors.length > 0) { + parts.push(`### Issues: ${allErrors.slice(0, 5).join("; ")}`); + } + + return { + formatted: parts.join("\n"), + results, + totalDurationMs, + totalTokensUsed, + errors: allErrors, + }; +} diff --git a/packages/core/src/tools/executor.ts b/packages/core/src/tools/executor.ts index 6af57c4c..03f9177c 100644 --- a/packages/core/src/tools/executor.ts +++ b/packages/core/src/tools/executor.ts @@ -1,11 +1,17 @@ import { handleAskUserQuestionTool } from "./ask-user-question-handler"; import { handleBashTool } from "./bash-handler"; +import { handleDelegateTool } from "./delegate-handler"; import { handleEditTool } from "./edit-handler"; import { handleReadTool } from "./read-handler"; +import { handleRecallTool } from "./recall-handler"; import { handleUpdatePlanTool } from "./update-plan-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; import type { McpManager } from "../mcp/mcp-manager"; +import type { ContextManager } from "../common/context-manager"; +import type { TaskPlanManager } from "../common/task-plan-manager"; +import type { HooksManager, HookPhase } from "../common/hooks-manager"; +import type { ShellSessionManager } from "../common/shell-sessions"; import type { CreateOpenAIClient, ToolCall, @@ -40,12 +46,28 @@ export class ToolExecutor { private readonly projectRoot: string; private readonly createOpenAIClient?: CreateOpenAIClient; private readonly mcpManager?: McpManager; + private readonly taskPlanManager?: TaskPlanManager; + private readonly contextManager?: ContextManager; + private readonly hooksManager?: HooksManager; + private readonly shellSessions?: ShellSessionManager; private readonly toolHandlers = new Map(); - constructor(projectRoot: string, createOpenAIClient?: CreateOpenAIClient, mcpManager?: McpManager) { + constructor( + projectRoot: string, + createOpenAIClient?: CreateOpenAIClient, + mcpManager?: McpManager, + taskPlanManager?: TaskPlanManager, + contextManager?: ContextManager, + hooksManager?: HooksManager, + shellSessions?: ShellSessionManager + ) { this.projectRoot = projectRoot; this.createOpenAIClient = createOpenAIClient; this.mcpManager = mcpManager; + this.taskPlanManager = taskPlanManager; + this.contextManager = contextManager; + this.hooksManager = hooksManager; + this.shellSessions = shellSessions; this.registerToolHandlers(); } @@ -83,6 +105,8 @@ export class ToolExecutor { this.toolHandlers.set("edit", handleEditTool); this.toolHandlers.set("AskUserQuestion", handleAskUserQuestionTool); this.toolHandlers.set("UpdatePlan", handleUpdatePlanTool); + this.toolHandlers.set("Recall", handleRecallTool); + this.toolHandlers.set("Delegate", handleDelegateTool); this.toolHandlers.set("WebSearch", handleWebSearchTool); } @@ -153,11 +177,32 @@ export class ToolExecutor { } try { - return await handler(parsedArgs.args, { + // Pre-hook + const prePhase = this.getPreHookPhase(handlerName); + if (prePhase && this.hooksManager) { + const preResult = await this.hooksManager.runPreHook(prePhase, this.projectRoot, { + tool: handlerName, + sessionId, + projectRoot: this.projectRoot, + filePath: this.extractFilePath(parsedArgs.args), + command: this.extractCommand(handlerName, parsedArgs.args), + }); + if (preResult.blocked) { + return { + ok: false, + name: toolName, + error: preResult.reason ?? `Blocked by ${prePhase} hook.`, + }; + } + } + + const result = await handler(parsedArgs.args, { sessionId, projectRoot: this.projectRoot, toolCall, createOpenAIClient: this.createOpenAIClient, + taskPlanManager: this.taskPlanManager, + contextManager: this.contextManager, onProcessStart: hooks?.onProcessStart, onProcessExit: hooks?.onProcessExit, onProcessStdout: hooks?.onProcessStdout, @@ -166,6 +211,22 @@ export class ToolExecutor { onBeforeFileMutation: hooks?.onBeforeFileMutation, onAfterFileMutation: hooks?.onAfterFileMutation, }); + + // Post-hook (fire and forget, don't block) + const postPhase = this.getPostHookPhase(handlerName); + if (postPhase && this.hooksManager) { + this.hooksManager.runPostHook(postPhase, this.projectRoot, { + tool: handlerName, + sessionId, + projectRoot: this.projectRoot, + filePath: this.extractFilePath(parsedArgs.args), + command: this.extractCommand(handlerName, parsedArgs.args), + ok: result.ok, + error: result.error, + }); + } + + return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { @@ -224,4 +285,47 @@ export class ToolExecutor { return JSON.stringify(payload, null, 2); } + + private getPreHookPhase(toolName: string): HookPhase | null { + switch (toolName) { + case "bash": + return "pre-bash"; + case "read": + return "pre-read"; + case "write": + return "pre-write"; + case "edit": + return "pre-edit"; + default: + return null; + } + } + + private getPostHookPhase(toolName: string): HookPhase | null { + switch (toolName) { + case "bash": + return "post-bash"; + case "read": + return "post-read"; + case "write": + return "post-write"; + case "edit": + return "post-edit"; + default: + return null; + } + } + + private extractFilePath(args: Record): string | undefined { + if (typeof args.file_path === "string") return args.file_path; + if (typeof args.filePath === "string") return args.filePath; + return undefined; + } + + private extractCommand(toolName: string, args: Record): string | undefined { + if (toolName === "bash" && typeof args.command === "string") { + return args.command.slice(0, 200); + } + return undefined; + } } diff --git a/packages/core/src/tools/recall-handler.ts b/packages/core/src/tools/recall-handler.ts new file mode 100644 index 00000000..3fc36295 --- /dev/null +++ b/packages/core/src/tools/recall-handler.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; +import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; +import { executeValidatedTool } from "../common/validate"; + +const recallSchema = z.strictObject({ + query: z.string().trim().min(1, "query must not be empty."), + category: z + .string() + .trim() + .optional() + .refine((value) => !value || ["code", "decision", "error", "fact", "all"].includes(value), { + message: "category must be one of: code, decision, error, fact, all", + }), + filePath: z.string().trim().optional(), + limit: z.number().int().min(1).max(20).default(5), +}); + +export async function handleRecallTool( + args: Record, + context: ToolExecutionContext +): Promise { + return executeValidatedTool("Recall", recallSchema, args, context, async (input) => { + const { contextManager } = context; + + if (!contextManager) { + return { + ok: false, + name: "Recall", + error: "Recall is not available. Context manager not initialized.", + }; + } + + try { + const result = contextManager.recall(context.sessionId, input.query, { + category: input.category ?? "all", + filePath: input.filePath, + limit: input.limit, + }); + + const formatted = formatRecallResult(result); + + return { + ok: true, + name: "Recall", + output: formatted, + metadata: { result }, + }; + } catch (error) { + return { + ok: false, + name: "Recall", + error: error instanceof Error ? error.message : String(error), + }; + } + }); +} + +function formatRecallResult(result: unknown): string { + const r = result as { + category?: string; + entries?: Array>; + }; + + if (!r.entries || r.entries.length === 0) { + return "No results found."; + } + + const lines: string[] = [`Found ${r.entries.length} result(s) in ${r.category ?? "all"} category:\n`]; + + for (let i = 0; i < r.entries.length; i++) { + const entry = r.entries[i]; + if (entry.signature) { + // Code entity + lines.push(`${i + 1}. **${entry.type}** \`${entry.name}\` in \`${entry.filePath}\``); + lines.push(` \`\`\`\n ${entry.signature}\n \`\`\``); + } else if (entry.summary) { + // Memory fact + lines.push(`${i + 1}. **[${entry.category}]** ${entry.summary}`); + if (entry.detail) { + lines.push(` ${entry.detail}`); + } + } + } + + return lines.join("\n"); +} diff --git a/packages/core/src/tools/update-plan-handler.ts b/packages/core/src/tools/update-plan-handler.ts index 11439784..041bf3d0 100644 --- a/packages/core/src/tools/update-plan-handler.ts +++ b/packages/core/src/tools/update-plan-handler.ts @@ -1,23 +1,55 @@ import { z } from "zod"; import type { ToolExecutionContext, ToolExecutionResult } from "./executor"; import { executeValidatedTool } from "../common/validate"; +import type { TaskStepInput } from "../common/task-plan"; + +const taskStepSchema = z.object({ + description: z.string(), + verification: z + .object({ + type: z.enum(["command", "file_exists", "test_pass", "manual"]), + command: z.string().optional(), + expected: z.string().optional(), + }) + .optional(), + fallback: z + .object({ + type: z.enum(["rollback_and_retry", "rollback_and_skip", "ask_user"]), + alternativeApproach: z.string().optional(), + }) + .optional(), + maxRetries: z.number().int().min(0).max(5).default(2), +}); const updatePlanSchema = z.strictObject({ plan: z.string().trim().min(1, "plan must not be empty."), explanation: z.string().trim().optional(), + steps: z.array(taskStepSchema).optional(), }); export async function handleUpdatePlanTool( args: Record, _context: ToolExecutionContext ): Promise { - return executeValidatedTool("UpdatePlan", updatePlanSchema, args, _context, async (input) => ({ - ok: true, - name: "UpdatePlan", - output: "Plan updated.", - metadata: { - plan: input.plan, - ...(input.explanation ? { explanation: input.explanation } : {}), - }, - })); + return executeValidatedTool("UpdatePlan", updatePlanSchema, args, _context, async (input) => { + if (input.steps && input.steps.length > 0 && _context.taskPlanManager) { + const stepInputs: TaskStepInput[] = input.steps.map((step) => ({ + description: step.description, + verification: step.verification, + fallback: step.fallback, + maxRetries: step.maxRetries, + })); + _context.taskPlanManager.createPlan(_context.sessionId, stepInputs); + } + + return { + ok: true, + name: "UpdatePlan", + output: "Plan updated.", + metadata: { + plan: input.plan, + ...(input.explanation ? { explanation: input.explanation } : {}), + }, + }; + }); } diff --git a/packages/core/templates/skills/bundled/deepcode-self-refer/references/hooks-example.js b/packages/core/templates/skills/bundled/deepcode-self-refer/references/hooks-example.js new file mode 100644 index 00000000..6028ecc6 --- /dev/null +++ b/packages/core/templates/skills/bundled/deepcode-self-refer/references/hooks-example.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +/* global require, process, console */ +/** + * Example pre-write hook — prevents accidental writes to sensitive paths. + * + * Receives context via stdin JSON: + * { phase, tool, sessionId, projectRoot, filePath, ... } + * + * Exit codes: + * 0 = allow + * 2 = block (return reason via stdout) + * + * Place in: .deepcode/hooks/pre-write.js or ~/.deepcode/hooks/pre-write.js + */ + +const SENSITIVE_PATTERNS = [/\.env$/i, /\.git\/config$/, /package-lock\.json$/, /\/dist\//, /\/node_modules\//]; + +let context; +try { + const raw = require("fs").readFileSync(0, "utf8"); + context = JSON.parse(raw); +} catch { + // No valid JSON — allow + process.exit(0); +} + +const filePath = context.filePath || ""; +for (const pattern of SENSITIVE_PATTERNS) { + if (pattern.test(filePath)) { + console.log(`Blocked: writing to sensitive path "${filePath}" is not allowed.`); + process.exit(2); + } +} + +process.exit(0); diff --git a/packages/core/templates/tools/delegate.md b/packages/core/templates/tools/delegate.md new file mode 100644 index 00000000..3f0fb90b --- /dev/null +++ b/packages/core/templates/tools/delegate.md @@ -0,0 +1,46 @@ +## Delegate + +Delegate tasks to sub-agents that execute independently. + +Usage: + +- Use this tool to parallelize work: break a large task into smaller independent sub-tasks. +- Each sub-agent runs in an isolated context — it cannot see the main agent's conversation history. +- Sub-agents have access to `read` and `bash` tools by default. You can expand to `write`/`edit` if needed. +- Results include: summary, files read, files modified, errors, iterations, tokens used. +- Use `parallel: true` (default) for independent tasks, `parallel: false` for dependent sequential tasks. +- Max 8 tasks per call, up to 12 iterations per sub-agent. + +### When to Delegate + +- **Code search**: "Find all usages of createUser across the codebase" +- **Independent analysis**: "Analyze UserService.ts" and "Analyze AuthService.ts" in parallel +- **Multi-file refactoring**: "Rename getCwd to getCurrentWorkingDirectory in all .ts files" +- **Exploratory research**: "Search for all TODO comments" + "Find all test files without corresponding source files" + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "tasks": { + "type": "array", + "description": "List of tasks to delegate.", + "items": { + "type": "object", + "properties": { + "description": { "type": "string", "description": "What the sub-agent should do." }, + "tools": { "type": "array", "items": { "type": "string" } }, + "context": { "type": "string" }, + "maxIterations": { "type": "number" }, + "timeoutMs": { "type": "number" } + }, + "required": ["description"] + } + }, + "parallel": { "type": "boolean", "description": "Run tasks in parallel (default true)." } + }, + "required": ["tasks"], + "additionalProperties": false +} +``` diff --git a/packages/core/templates/tools/recall.md b/packages/core/templates/tools/recall.md new file mode 100644 index 00000000..fe972e8c --- /dev/null +++ b/packages/core/templates/tools/recall.md @@ -0,0 +1,40 @@ +## Recall + +Search the conversation history and code index for specific information. + +Usage: + +- Use this tool when you need to recall specific details from earlier in the conversation that may have been compacted. +- Search for function signatures, class definitions, type declarations, and import paths. +- Search for past decisions, error messages, and fixes. +- Use `category: "code"` for code entities, `category: "fact"` for decisions/errors, or `category: "all"` (default) for everything. +- Narrow results with `filePath` for file-specific queries. +- Results are ranked by relevance. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "query": { + "description": "Search query — keyword, function name, or concept.", + "type": "string" + }, + "category": { + "description": "Category to search: code, decision, error, fact, or all (default).", + "type": "string", + "enum": ["code", "decision", "error", "fact", "all"] + }, + "filePath": { + "description": "Optional file path to narrow search results.", + "type": "string" + }, + "limit": { + "description": "Maximum number of results (default 5, max 20).", + "type": "number" + } + }, + "required": ["query"], + "additionalProperties": false +} +``` diff --git a/packages/core/templates/tools/update-plan.md b/packages/core/templates/tools/update-plan.md index 9459242e..e0cf61dc 100644 --- a/packages/core/templates/tools/update-plan.md +++ b/packages/core/templates/tools/update-plan.md @@ -12,6 +12,23 @@ Usage: - Before executing the first task and after completing each task, re-evaluate the latest conversation and project context, then revise the remaining plan if needed. - Remove tasks that are no longer relevant, and add newly discovered follow-up tasks before working on them. +### TaskPlan Execution Loop (when using `steps`) + +When you provide structured `steps`, you MUST follow this verification-driven loop: + +1. **Plan**: Generate 3-5 concrete steps, each with `verification` (a command to run) and `fallback` (what to do on failure). +2. **Execute**: Perform the code changes for the current `[>]` step. +3. **Verify**: Run the step's `verification.command` (e.g., `npm test`, `npm run typecheck`, `npm run lint`). Never skip verification. +4. **Pass → Next**: If verification passes, mark the step `[x]` and proceed to the next step. +5. **Fail → Fix → Retry**: If verification fails: + - Analyze the error output carefully. + - Fix the issue (do NOT skip or work around verification). + - If the same step fails `maxRetries` times, invoke the `fallback` strategy: + - `rollback_and_retry`: Undo all changes from this step and try the `alternativeApproach`. + - `rollback_and_skip`: Undo changes, mark skipped, and move to the next step. + - `ask_user`: Pause and ask the user for guidance. +6. **Loop** until all steps are `[x]` or the plan is `failed`. + ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -24,6 +41,41 @@ Usage: "explanation": { "description": "Optional short reason for changing the plan.", "type": "string" + }, + "steps": { + "description": "Optional structured steps with verification and fallback strategies.", + "type": "array", + "items": { + "type": "object", + "properties": { + "description": { "type": "string" }, + "verification": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["command", "file_exists", "test_pass", "manual"] + }, + "command": { "type": "string" }, + "expected": { "type": "string" } + }, + "required": ["type"] + }, + "fallback": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["rollback_and_retry", "rollback_and_skip", "ask_user"] + }, + "alternativeApproach": { "type": "string" } + }, + "required": ["type"] + }, + "maxRetries": { "type": "number" } + }, + "required": ["description"] + } } }, "required": ["plan"],