mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a25e384956 | ||
|
|
59275e77eb | ||
|
|
f7677fa856 | ||
|
|
19c2de6067 | ||
|
|
0b19ba6023 | ||
|
|
75143a718a | ||
|
|
7276f50d9e | ||
|
|
2964388585 | ||
|
|
6fcd43597e | ||
|
|
9de6af51fd | ||
|
|
ab59bd9b50 | ||
|
|
a0252e70d9 | ||
|
|
faa471b6e2 | ||
|
|
80dd10d815 | ||
|
|
109f0ec1a4 | ||
|
|
e2cd3d706c | ||
|
|
359f77c2e3 | ||
|
|
2caf1dc26b | ||
|
|
ca2b4168d5 | ||
|
|
0dabb06cb2 | ||
|
|
dace684afa | ||
|
|
86aadd1bb7 | ||
|
|
0c41159579 | ||
|
|
70cdd9d716 | ||
|
|
6589659c9d | ||
|
|
9224a33d01 | ||
|
|
ceaed842e0 | ||
|
|
8d5d834a1f | ||
|
|
5cdbf4ea38 | ||
|
|
4004e9efed | ||
|
|
8fb419f273 | ||
|
|
e855e82d5e | ||
|
|
12139bf448 | ||
|
|
1bca8a9d12 | ||
|
|
ca5cdd13de | ||
|
|
b3b074d90a | ||
|
|
bf10cd4efb | ||
|
|
0bc355d141 | ||
|
|
f9094c0fb6 | ||
|
|
8497c435f4 | ||
|
|
906dac25c6 | ||
|
|
bdeec6a510 | ||
|
|
15d01434bb | ||
|
|
45c041b781 | ||
|
|
40bf6241f9 | ||
|
|
a26494e5cc | ||
|
|
941414e87f | ||
|
|
1af57b7c62 | ||
|
|
b057710083 | ||
|
|
0096521966 | ||
|
|
1f50188c41 | ||
|
|
d00103419f | ||
|
|
1c9bbba749 | ||
|
|
b54db8b82d | ||
|
|
7c7e86d055 | ||
|
|
458583a476 | ||
|
|
f76ec25559 | ||
|
|
90e9c49654 | ||
|
|
0ea8506bf9 | ||
|
|
1c22ee5896 | ||
|
|
30857e969e | ||
|
|
0d07b421df | ||
|
|
ff9484e141 | ||
|
|
ccc8e471e3 | ||
|
|
9859136e80 | ||
|
|
7969ba2d68 | ||
|
|
16c0992672 | ||
|
|
3b8be75c7f | ||
|
|
a00f24e182 | ||
|
|
238654e6a2 | ||
|
|
643319f106 | ||
|
|
0645eccd2a | ||
|
|
3a6f0c2fd0 | ||
|
|
17314cb88d | ||
|
|
6f9cf8a028 | ||
|
|
47aecdfc75 | ||
|
|
fb037a05c1 | ||
|
|
d9cedc41b7 | ||
|
|
f978ecce52 | ||
|
|
4c28760557 | ||
|
|
ba79a51dd7 | ||
|
|
4e5cc92065 | ||
|
|
b42c0f2571 | ||
|
|
386d5e41e7 | ||
|
|
2823e6c845 | ||
|
|
cef9af16a4 | ||
|
|
49d3bcfedc | ||
|
|
d36a44ec38 | ||
|
|
cbcf89d634 | ||
|
|
6a10e30436 | ||
|
|
2c0afbc3be | ||
|
|
2ef4e56bca | ||
|
|
e26d001585 | ||
|
|
6fc2cb128e | ||
|
|
8cc64f5e7e | ||
|
|
a4412e8014 | ||
|
|
36f7abb8ec | ||
|
|
080a79bd7d | ||
|
|
0208fdf555 | ||
|
|
226f20f28f | ||
|
|
fdc76c8802 | ||
|
|
7099a00674 | ||
|
|
b470229a97 | ||
|
|
e37f6e3b88 | ||
|
|
4c72bd96ab | ||
|
|
be120e85be | ||
|
|
fdd04bc942 | ||
|
|
b7c03af9ac | ||
|
|
1961583eb6 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
context management for file reads
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix auto approve state out of sync
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Reduce system prompt size by letting Cline dynamically load MCP documentation only when needed
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added copy button to MermaidBlock component
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Feature to open basic settings & scroll a section into view with a highlight animation
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Disable notifications in browser
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add cmd+quote default keybinding to add selected context to Cline chat
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add modal for toggling MCP servers to the chat area
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Features to Relaunch browser in debug, test connection
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added option to auto-approve all commands
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Remote browser control via devtools protocol
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
factor out servers list
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Allow user to send context with an option selection
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix tool toggle auto approve
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
convert inline style to tailwind css of file `SettingsView.tsx`
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
allow enabling prompt caching for LiteLLM + Claude
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Added feature to detect installed versions of chromium and display them as a placeholder if not already explicitly configured by the user
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add stream options back to xai provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Auto focus the text field when the user uses the 'add to Cline' command
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add the ability to fetch from global cline rules files
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Icons to indicate an action is occuring outside of the users workspace
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix for headless browser mode
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Drag and drop of file/folders into cline chat
|
||||
@@ -2,4 +2,4 @@
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
TS errors
|
||||
update to NEW model
|
||||
@@ -13,7 +13,10 @@
|
||||
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] ♻️ Refactor Changes
|
||||
- [ ] 💅 Cosmetic Changes
|
||||
- [ ] 📚 Documentation update
|
||||
- [ ] 🏃 Workflow Changes
|
||||
|
||||
### Pre-flight Checklist
|
||||
|
||||
|
||||
Vendored
+13
@@ -16,6 +16,19 @@
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Run Extension (Test Mode)",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+51
@@ -14,6 +14,14 @@
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "watch:test",
|
||||
"dependsOn": ["npm: build:webview:test", "npm: dev:webview", "npm: watch:tsc", "npm: watch:esbuild:test"],
|
||||
"presentation": {
|
||||
"reveal": "never"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build:webview",
|
||||
@@ -32,6 +40,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview:test",
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
@@ -77,6 +104,30 @@
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": "$esbuild-watch",
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "never",
|
||||
"close": true
|
||||
},
|
||||
"options": {
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## [3.12.2]
|
||||
|
||||
- Add gpt-4.1
|
||||
|
||||
## [3.12.1]
|
||||
|
||||
- Use visual checkpoint indicator to make it clear when checkpoints are created
|
||||
- Big shoutout to @samuel871211 for numerous code quality improvements, refactoring contributions, and webview performance improvements!
|
||||
- Use improved context manager
|
||||
|
||||
## [3.12.0]
|
||||
|
||||
- Add favorite toggles for models when using the Cline & OpenRouter providers
|
||||
- Add auto-approve options for edits/reads outside of the workspace
|
||||
- Improve diff editing animation for large files
|
||||
- Add indicator showing number of diff edits when Cline edits a file
|
||||
- Add streaming support and reasoning effort option to xAI's Grok 3 Mini
|
||||
- Add settings button to MCP popover to easily modify installed servers
|
||||
- Fix bug where browser tool actions would show unparsed results in the chat view
|
||||
- Fix issue with new checkpoints popover hiding too quickly
|
||||
- Fix duplicate checkpoints bug
|
||||
- Improve Ollama provider with retry mechanism, timeout handling, and improved error handling (thanks suvarchal!)
|
||||
|
||||
## [3.11.0]
|
||||
|
||||
- Redesign checkpoint UI to declutter chat view by using a subtle indicator line that expands to a popover on hover, with a new date indicator for when it was created
|
||||
- Add support for xAI's provider's Grok 3 models
|
||||
- Add more robust error tracking for users opted in to telemetry (thank you for helping us make Cline better!)
|
||||
|
||||
## [3.10.1]
|
||||
|
||||
- Add CMD+' keyboard shortcut to add selected text to Cline
|
||||
- Cline now auto focuses the text field when using 'Add to Cline' shortcut
|
||||
- Add new 'Create New Task' tool to let Cline start a new task autonomously!
|
||||
- Fix Mermaid diagram issues
|
||||
- Fix Gemini provider cost calculation to take new tiered pricing structure into account
|
||||
|
||||
## [3.10.0]
|
||||
|
||||
- Add setting to let browser tool use local Chrome via remote debugging, enabling session-based browsing. Replaces sessionless Chromium, unlocking debugging and productivity workflows tied to your real browser state.
|
||||
- Add new auto-approve option to approve _ALL_ commands (use at your own risk!)
|
||||
- Add modal in the chat area to more easily enable or disable MCP servers
|
||||
- Add drag and drop of file/folders into cline chat (Thanks eljapi!)
|
||||
- Add prompt caching for LiteLLM + Claude (Thanks sammcj!)
|
||||
- Add Improved context management
|
||||
- Fix MCP auto approve toggle issues being out of sync with settings
|
||||
|
||||
## [3.9.2]
|
||||
|
||||
- Add recommended models for Cline provider
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – \#1 on OpenRouter
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
@@ -82,6 +82,7 @@ Cline has access to the following tools for various tasks:
|
||||
4. **Interaction Tools**
|
||||
- `ask_followup_question`: Ask user for clarification
|
||||
- `attempt_completion`: Present final results
|
||||
- `new_task`: Start a new task with preloaded context
|
||||
|
||||
Each tool has specific parameters and usage patterns. Here are some examples:
|
||||
|
||||
@@ -114,6 +115,21 @@ Each tool has specific parameters and usage patterns. Here are some examples:
|
||||
</execute_command>
|
||||
```
|
||||
|
||||
- Start a new task with context (new_task):
|
||||
```xml
|
||||
<new_task>
|
||||
<context>
|
||||
We've completed the backend API with these endpoints:
|
||||
- GET /api/tasks
|
||||
- POST /api/tasks
|
||||
- PUT /api/tasks/:id
|
||||
- DELETE /api/tasks/:id
|
||||
|
||||
Now we need to implement the React frontend.
|
||||
</context>
|
||||
</new_task>
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
1. **Create a New Component**
|
||||
|
||||
@@ -4,6 +4,7 @@ const path = require("path")
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
const test = process.env.IS_TEST === "true"
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').Plugin}
|
||||
@@ -68,6 +69,10 @@ const extensionConfig = {
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
logLevel: "silent",
|
||||
define: {
|
||||
"process.env.IS_DEV": JSON.stringify(!production),
|
||||
"process.env.IS_TEST": JSON.stringify(test),
|
||||
},
|
||||
plugins: [
|
||||
copyWasmFiles,
|
||||
/* add to the end of plugins array */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
العربية | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">الإسبانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">الألمانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">اليابانية</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">الصينية المبسطة</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">الصينية التقليدية</a> | <a href="https://github.com/cline/cline/blob/main/locales/pt-BR/README.md" target="_blank">البرتغالية</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – \#1 على OpenRouter
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline – \#1 auf OpenRouter
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline – #1 en OpenRouter
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Clineへの貢献
|
||||
# Cline
|
||||
|
||||
Clineへの貢献に興味をお持ちいただきありがとうございます。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline – OpenRouterでのナンバーワン
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline에 기여하기
|
||||
# Cline
|
||||
|
||||
Cline에 기여하는 것에 관심을 가져주셔서 감사합니다! 버그 수정, 기능 추가, 문서 개선 등 모든 기여는 Cline을 더욱 스마트하게 만드는 데 기여합니다. 활기차고 환영하는 커뮤니티를 유지하기 위해 모든 구성원은 [행동 강령](CODE_OF_CONDUCT.md)을 준수해야 합니다.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline - 최고의 OpenRouter
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline – #1 no OpenRouter
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cline – OpenRouter 排名第一的 AI 工具
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<a href="https://github.com/cline/cline/blob/main/README.md" target="_blank">English</a> | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | 繁體中文 | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – OpenRouter 第一名的 AI 工具
|
||||
# Cline
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
|
||||
Generated
+78
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.9.2",
|
||||
"version": "3.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.9.2",
|
||||
"version": "3.12.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.12.4",
|
||||
@@ -23,6 +23,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@sentry/browser": "^9.12.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
@@ -6856,6 +6857,81 @@
|
||||
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.12.0.tgz",
|
||||
"integrity": "sha512-GXuDEG2Ix8DmVtTkjsItWdusk2CvJ6EPWKYVqFKifxt+IAT3ZbhGZd99Rg3wdRmt9xhCNuS4QrDzDTPMPgfdCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/feedback": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.12.0.tgz",
|
||||
"integrity": "sha512-3+UxoT97QIXNSUQS4ATL1FFws0RkUb6PeaQN8CPndI6mFlqTW5tuVVLNg9Eo1seNg7R/dfk6WHCWrYN1NbFFKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.12.0.tgz",
|
||||
"integrity": "sha512-njEQosFeO/UX+gG+DMRANkPUuz6OIJLb+A1GVylhq9adUgFQydQ9Ay3v7/x1gMhdfHVP6Jeb27qkti0BWYbzBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "9.12.0",
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/replay-canvas": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.12.0.tgz",
|
||||
"integrity": "sha512-p8LuKZgWT/CoQBbDOXkSGjWWnc8WsnAayWgna8M/ZFWNITCNEM2rCuqZOyWOElIlrni+M7qoEA3jS7MZe8Ejxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/replay": "9.12.0",
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/browser": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.12.0.tgz",
|
||||
"integrity": "sha512-4xQYoZqi+VVhNvlhWiwRd57+SMr3Og4sLjuayAA+zIp1Wx/bDcIld697cugLwml/BR+mVJI2eokkgh1CBl6zag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sentry-internal/browser-utils": "9.12.0",
|
||||
"@sentry-internal/feedback": "9.12.0",
|
||||
"@sentry-internal/replay": "9.12.0",
|
||||
"@sentry-internal/replay-canvas": "9.12.0",
|
||||
"@sentry/core": "9.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry/core": {
|
||||
"version": "9.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.12.0.tgz",
|
||||
"integrity": "sha512-jOqQK/90uzHmsBvkPTj/DAEFvA5poX4ZRyC7LE1zjg4F5jdOp3+M4W3qCy0CkSTu88Zu5VWBoppCU2Bs34XEqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
||||
|
||||
+8
-2
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.9.2",
|
||||
"version": "3.12.2",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -287,8 +287,12 @@
|
||||
"compile": "npm run check-types && npm run lint && node esbuild.js",
|
||||
"watch": "npm-run-all -p watch:*",
|
||||
"watch:esbuild": "node esbuild.js --watch",
|
||||
"watch:esbuild:test": "IS_TEST=true node esbuild.js --watch",
|
||||
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
|
||||
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
|
||||
"package:test": "IS_TEST=true npm run build:webview:test && npm run check-types && npm run lint && IS_TEST=true node esbuild.js --production",
|
||||
"build:webview:test": "cd webview-ui && IS_TEST=true npm run build",
|
||||
"watch:test": "IS_TEST=true npm-run-all -p watch:tsc watch:esbuild:test",
|
||||
"compile-tests": "tsc -p ./tsconfig.test.json --outDir out",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "npm run compile-tests && npm run compile && npm run lint",
|
||||
@@ -296,8 +300,9 @@
|
||||
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"test": "vscode-test",
|
||||
"test": "npm-run-all test:unit test:integration",
|
||||
"test:ci": "node scripts/test-ci.js",
|
||||
"test:integration": "vscode-test",
|
||||
"test:unit": "TS_NODE_PROJECT='./tsconfig.unit-test.json' mocha",
|
||||
"test:coverage": "vscode-test --coverage",
|
||||
"install:all": "npm install && cd webview-ui && npm install",
|
||||
@@ -355,6 +360,7 @@
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@sentry/browser": "^9.12.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
|
||||
+2
-2
@@ -9,10 +9,10 @@ try {
|
||||
execSync("which xvfb-run", { stdio: "ignore" })
|
||||
|
||||
console.log("xvfb-run is installed. Running tests with xvfb-run...")
|
||||
execSync("xvfb-run -a npm run test", { stdio: "inherit" })
|
||||
execSync("xvfb-run -a npm run test:integration", { stdio: "inherit" })
|
||||
} else {
|
||||
console.log("Non-Linux environment detected. Running tests normally.")
|
||||
execSync("npm run test", { stdio: "inherit" })
|
||||
execSync("npm run test:integration", { stdio: "inherit" })
|
||||
}
|
||||
} catch (error) {
|
||||
if (process.platform === "linux") {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { describe, it, beforeEach, afterEach, before } from "mocha"
|
||||
import "should"
|
||||
import sinon from "sinon"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { OllamaHandler } from "../ollama"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import axios from "axios"
|
||||
|
||||
describe("OllamaHandler", () => {
|
||||
let ollamaAvailable = false
|
||||
|
||||
// Check if Ollama is running before running tests
|
||||
before(async function () {
|
||||
this.timeout(5000)
|
||||
try {
|
||||
await axios.get("http://localhost:11434/api/version", { timeout: 2000 })
|
||||
ollamaAvailable = true
|
||||
} catch (error) {
|
||||
console.log("Ollama server not available, skipping tests")
|
||||
ollamaAvailable = false
|
||||
}
|
||||
})
|
||||
let handler: OllamaHandler
|
||||
let options: ApiHandlerOptions
|
||||
let clock: sinon.SinonFakeTimers
|
||||
|
||||
beforeEach(() => {
|
||||
options = {
|
||||
ollamaModelId: "llama2",
|
||||
ollamaBaseUrl: "http://localhost:11434",
|
||||
}
|
||||
handler = new OllamaHandler(options)
|
||||
// Use fake timers for testing timeouts
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore()
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should handle successful responses", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(5000)
|
||||
// Mock the Ollama client's chat method
|
||||
const chatStub = sinon.stub(handler["client"], "chat").resolves({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Hello, world!" },
|
||||
eval_count: 10,
|
||||
prompt_eval_count: 20,
|
||||
}
|
||||
},
|
||||
} as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
const usageInfo = []
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
} else if (chunk.type === "usage") {
|
||||
usageInfo.push({
|
||||
inputTokens: chunk.inputTokens,
|
||||
outputTokens: chunk.outputTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
result.should.deepEqual(["Hello, world!"])
|
||||
usageInfo.should.deepEqual([{ inputTokens: 20, outputTokens: 10 }])
|
||||
chatStub.calledOnce.should.be.true()
|
||||
})
|
||||
|
||||
it("should handle timeout errors", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Create a handler with a very short timeout for testing
|
||||
const testHandler = new OllamaHandler(options)
|
||||
|
||||
// Replace the createMessage method with one that has a shorter timeout
|
||||
testHandler.createMessage = async function* (systemPrompt, messages) {
|
||||
try {
|
||||
// Create a promise that rejects after a short timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Ollama request timed out after 30 seconds")), 100)
|
||||
})
|
||||
|
||||
// Create a promise that never resolves
|
||||
const neverPromise = new Promise(() => {})
|
||||
|
||||
// Race them
|
||||
await Promise.race([timeoutPromise, neverPromise])
|
||||
} catch (error: any) {
|
||||
// Enhance error reporting
|
||||
console.error(`Ollama API error: ${error.message}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
// Start the request and catch the error
|
||||
let errorMessage = ""
|
||||
try {
|
||||
for await (const _ of testHandler.createMessage(systemPrompt, messages)) {
|
||||
// This should not be reached
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Check the result
|
||||
errorMessage.should.equal("Ollama request timed out after 30 seconds")
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should retry on errors when using the withRetry decorator", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Mock the Ollama client's chat method to fail on first call and succeed on second
|
||||
const chatStub = sinon.stub(handler["client"], "chat")
|
||||
|
||||
// First call throws an error
|
||||
chatStub.onFirstCall().rejects(new Error("API Error"))
|
||||
|
||||
// Second call succeeds
|
||||
chatStub.onSecondCall().resolves({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
message: { content: "Success after retry" },
|
||||
}
|
||||
},
|
||||
} as any)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
|
||||
// Add a small delay to ensure the retry mechanism has time to work
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Collect the results
|
||||
for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
result.should.deepEqual(["Success after retry"])
|
||||
chatStub.calledTwice.should.be.true()
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should handle stream processing errors", async function () {
|
||||
if (!ollamaAvailable) {
|
||||
this.skip()
|
||||
}
|
||||
this.timeout(10000)
|
||||
// Restore real timers for this test
|
||||
clock.restore()
|
||||
|
||||
// Create a handler with a custom implementation for testing
|
||||
const testHandler = new OllamaHandler(options)
|
||||
|
||||
// Replace the createMessage method with one that simulates a stream error
|
||||
testHandler.createMessage = async function* (systemPrompt, messages) {
|
||||
// First yield a successful chunk
|
||||
yield {
|
||||
type: "text",
|
||||
text: "Partial response",
|
||||
}
|
||||
|
||||
// Then throw an error in the stream
|
||||
throw new Error("Ollama stream processing error: Stream error")
|
||||
}
|
||||
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = []
|
||||
|
||||
// Collect the results and catch the error
|
||||
let errorMessage = ""
|
||||
try {
|
||||
for await (const chunk of testHandler.createMessage(systemPrompt, messages)) {
|
||||
if (chunk.type === "text") {
|
||||
result.push(chunk.text)
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message
|
||||
}
|
||||
|
||||
// Verify the results
|
||||
errorMessage.should.equal("Ollama stream processing error: Stream error")
|
||||
result.should.deepEqual(["Partial response"])
|
||||
|
||||
// Restore the fake timers for other tests
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -114,7 +114,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
break
|
||||
}
|
||||
default: {
|
||||
stream = (await this.client.messages.create({
|
||||
stream = await this.client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
@@ -123,7 +123,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
// tools,
|
||||
// tool_choice: { type: "auto" },
|
||||
stream: true,
|
||||
})) as any
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
+53
-13
@@ -4,6 +4,7 @@ import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { convertToOllamaMessages } from "../transform/ollama-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { withRetry } from "../retry"
|
||||
|
||||
export class OllamaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
@@ -14,24 +15,63 @@ export class OllamaHandler implements ApiHandler {
|
||||
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
|
||||
}
|
||||
|
||||
@withRetry({ retryAllErrors: true })
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
|
||||
|
||||
const stream = await this.client.chat({
|
||||
model: this.getModel().id,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768,
|
||||
},
|
||||
})
|
||||
for await (const chunk of stream) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.message.content,
|
||||
try {
|
||||
// Create a promise that rejects after timeout
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Ollama request timed out after 30 seconds")), 30000)
|
||||
})
|
||||
|
||||
// Create the actual API request promise
|
||||
const apiPromise = this.client.chat({
|
||||
model: this.getModel().id,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768,
|
||||
},
|
||||
})
|
||||
|
||||
// Race the API request against the timeout
|
||||
const stream = (await Promise.race([apiPromise, timeoutPromise])) as Awaited<typeof apiPromise>
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.message.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle token usage if available
|
||||
if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.prompt_eval_count || 0,
|
||||
outputTokens: chunk.eval_count || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (streamError: any) {
|
||||
console.error("Error processing Ollama stream:", streamError)
|
||||
throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Check if it's a timeout error
|
||||
if (error.message && error.message.includes("timed out")) {
|
||||
throw new Error("Ollama request timed out after 30 seconds")
|
||||
}
|
||||
|
||||
// Enhance error reporting
|
||||
const statusCode = error.status || error.statusCode
|
||||
const errorMessage = error.message || "Unknown error"
|
||||
|
||||
console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
|
||||
import type { ChatCompletionReasoningEffort } from "openai/resources/chat/completions"
|
||||
|
||||
export class OpenAiNativeHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ApiHandler } from "../index"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
|
||||
import type { ChatCompletionReasoningEffort } from "openai/resources/chat/completions"
|
||||
|
||||
export class OpenAiHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { ApiHandlerOptions, ModelInfo, requestyDefaultModelId, requestyDefaultModelInfo } from "../../shared/api"
|
||||
import { ApiHandler } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class RequestyHandler implements ApiHandler {
|
||||
@@ -24,21 +25,34 @@ export class RequestyHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const modelId = this.options.requestyModelId ?? ""
|
||||
const model = this.getModel()
|
||||
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const reasoningEffort = this.options.o3MiniReasoningEffort || "medium"
|
||||
const reasoning = { reasoning_effort: reasoningEffort }
|
||||
const reasoningArgs = model.id === "openai/o3-mini" ? reasoning : {}
|
||||
|
||||
const thinkingBudget = this.options.thinkingBudgetTokens || 0
|
||||
const thinking =
|
||||
thinkingBudget > 0
|
||||
? { thinking: { type: "enabled", budget_tokens: thinkingBudget } }
|
||||
: { thinking: { type: "disabled" } }
|
||||
const thinkingArgs = model.id.includes("claude-3-7-sonnet") ? thinking : {}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: modelId,
|
||||
model: model.id,
|
||||
max_tokens: model.info.maxTokens || undefined,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(modelId === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}),
|
||||
...reasoningArgs,
|
||||
...thinkingArgs,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
@@ -73,7 +87,7 @@ export class RequestyHandler implements ApiHandler {
|
||||
const outputTokens = usage.completion_tokens || 0
|
||||
const cacheWriteTokens = usage.prompt_tokens_details?.caching_tokens || undefined
|
||||
const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens || undefined
|
||||
const totalCost = 0 // TODO: Replace with calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
const totalCost = calculateApiCostOpenAI(model.info, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
@@ -88,9 +102,11 @@ export class RequestyHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
return {
|
||||
id: this.options.requestyModelId ?? "",
|
||||
info: openAiModelInfoSaneDefaults,
|
||||
const modelId = this.options.requestyModelId
|
||||
const modelInfo = this.options.requestyModelInfo
|
||||
if (modelId && modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
return { id: requestyDefaultModelId, info: requestyDefaultModelInfo }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
// For the following openrouter error type sources, see the docs here:
|
||||
// https://openrouter.ai/docs/api-reference/errors
|
||||
|
||||
export interface LanguageModelChatSelector {
|
||||
vendor?: string
|
||||
family?: string
|
||||
version?: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
export type OpenRouterErrorResponse = {
|
||||
error: {
|
||||
message: string
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiStream } from "../transform/stream"
|
||||
import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format"
|
||||
import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import type { LanguageModelChatSelector as LanguageModelChatSelectorFromTypes } from "./types"
|
||||
|
||||
// Cline does not update VSCode type definitions or engine requirements to maintain compatibility.
|
||||
// This declaration (as seen in src/integrations/TerminalManager.ts) provides types for the Language Model API in newer versions of VSCode.
|
||||
@@ -19,12 +20,7 @@ declare module "vscode" {
|
||||
Auto = 1,
|
||||
Required = 2,
|
||||
}
|
||||
interface LanguageModelChatSelector {
|
||||
vendor?: string
|
||||
family?: string
|
||||
version?: string
|
||||
id?: string
|
||||
}
|
||||
interface LanguageModelChatSelector extends LanguageModelChatSelectorFromTypes {}
|
||||
interface LanguageModelChatTool {
|
||||
name: string
|
||||
description: string
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, XAIModelId, ModelInfo, xaiDefaultModelId, xaiModels } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
|
||||
|
||||
export class XAIHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
@@ -18,13 +19,23 @@ export class XAIHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const modelId = this.getModel().id
|
||||
// ensure reasoning effort is either "low" or "high" for grok-3-mini
|
||||
let reasoningEffort: ChatCompletionReasoningEffort | undefined
|
||||
if (modelId.includes("3-mini")) {
|
||||
let reasoningEffort = this.options.reasoningEffort
|
||||
if (reasoningEffort && !["low", "high"].includes(reasoningEffort)) {
|
||||
reasoningEffort = undefined
|
||||
}
|
||||
}
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
model: modelId,
|
||||
max_completion_tokens: this.getModel().info.maxTokens,
|
||||
temperature: 0,
|
||||
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
reasoning_effort: reasoningEffort,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
@@ -36,6 +47,14 @@ export class XAIHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if ("reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning_content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
|
||||
@@ -20,7 +20,7 @@ export function convertAnthropicContentToGemini(content: string | Anthropic.Cont
|
||||
},
|
||||
} as InlineDataPart
|
||||
default:
|
||||
throw new Error(`Unsupported content block type: ${(block as any).type}`)
|
||||
throw new Error(`Unsupported content block type: ${block.type}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// This file contains `declare module "vscode"` so we must import it.
|
||||
import "../providers/vscode-lm"
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as vscode from "vscode"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { asObjectSafe, convertToAnthropicRole, convertToVsCodeLmMessages, convertToAnthropicMessage } from "./vscode-lm-format"
|
||||
|
||||
describe("asObjectSafe", () => {
|
||||
it("should handle falsy values", () => {
|
||||
asObjectSafe(0).should.deepEqual({})
|
||||
asObjectSafe("").should.deepEqual({})
|
||||
asObjectSafe(null).should.deepEqual({})
|
||||
asObjectSafe(undefined).should.deepEqual({})
|
||||
})
|
||||
|
||||
it("should parse valid JSON strings", () => {
|
||||
asObjectSafe('{"key": "value"}').should.deepEqual({ key: "value" })
|
||||
})
|
||||
|
||||
it("should return an empty object for invalid JSON strings", () => {
|
||||
asObjectSafe("invalid json").should.deepEqual({})
|
||||
})
|
||||
|
||||
it("should convert objects to plain objects", () => {
|
||||
const input = { prop: "value" }
|
||||
asObjectSafe(input).should.deepEqual(input)
|
||||
asObjectSafe(input).should.not.equal(input) // Should be a new object
|
||||
})
|
||||
|
||||
it("should convert arrays to plain objects", () => {
|
||||
const input = ["hello world"]
|
||||
asObjectSafe(input).should.deepEqual({ 0: "hello world" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToAnthropicRole", () => {
|
||||
it("should convert VSCode roles to Anthropic roles", () => {
|
||||
// @ts-expect-error(Testing with an invalid role)
|
||||
const unknownRole = "unknown" as vscode.LanguageModelChatMessageRole
|
||||
;(convertToAnthropicRole(vscode.LanguageModelChatMessageRole.Assistant) === "assistant").should.be.true()
|
||||
;(convertToAnthropicRole(vscode.LanguageModelChatMessageRole.User) === "user").should.be.true()
|
||||
;(convertToAnthropicRole(unknownRole) === null).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToVsCodeLmMessages", () => {
|
||||
it("should convert simple string messages", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "Hi there" },
|
||||
]
|
||||
|
||||
const result = convertToVsCodeLmMessages(anthropicMessages)
|
||||
|
||||
result.should.have.length(2)
|
||||
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.User)
|
||||
result[0].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart0 = result[0].content[0] as vscode.LanguageModelTextPart
|
||||
textPart0.should.have.property("value", "Hello")
|
||||
|
||||
result[1].role.should.equal(vscode.LanguageModelChatMessageRole.Assistant)
|
||||
result[1].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart1 = result[1].content[0] as vscode.LanguageModelTextPart
|
||||
textPart1.should.have.property("value", "Hi there")
|
||||
})
|
||||
|
||||
it("should convert complex user messages with tool results", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "User text" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-123",
|
||||
content: [{ type: "text", text: "Tool result" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToVsCodeLmMessages(anthropicMessages)
|
||||
|
||||
result.should.have.length(1)
|
||||
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.User)
|
||||
result[0].content.should.have.length(2)
|
||||
|
||||
// Check that the first content part is a ToolResultPart
|
||||
result[0].content[0].should.be.instanceof(vscode.LanguageModelToolResultPart)
|
||||
const toolResultPart = result[0].content[0] as vscode.LanguageModelToolResultPart
|
||||
toolResultPart.should.have.property("callId", "tool-123")
|
||||
|
||||
// Skip detailed testing of internal structure as it may vary
|
||||
// Just verify it's the right type with the right ID
|
||||
|
||||
// Check the second content part is a TextPart
|
||||
result[0].content[1].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart = result[0].content[1] as vscode.LanguageModelTextPart
|
||||
textPart.should.have.property("value", "User text")
|
||||
})
|
||||
|
||||
it("should convert complex assistant messages with tool calls", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Assistant text" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-123",
|
||||
name: "testTool",
|
||||
input: { param: "value" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToVsCodeLmMessages(anthropicMessages)
|
||||
|
||||
result.should.have.length(1)
|
||||
result[0].role.should.equal(vscode.LanguageModelChatMessageRole.Assistant)
|
||||
result[0].content.should.have.length(2)
|
||||
|
||||
result[0].content[0].should.be.instanceof(vscode.LanguageModelToolCallPart)
|
||||
const toolCallPart = result[0].content[0] as vscode.LanguageModelToolCallPart
|
||||
toolCallPart.should.have.property("callId", "tool-123")
|
||||
toolCallPart.should.have.property("name", "testTool")
|
||||
toolCallPart.should.have.property("input")
|
||||
toolCallPart.input.should.deepEqual({ param: "value" })
|
||||
|
||||
result[0].content[1].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart = result[0].content[1] as vscode.LanguageModelTextPart
|
||||
textPart.should.have.property("value", "Assistant text")
|
||||
})
|
||||
|
||||
it("should handle image blocks with appropriate placeholders", () => {
|
||||
const anthropicMessages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/jpeg",
|
||||
data: "base64data",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToVsCodeLmMessages(anthropicMessages)
|
||||
|
||||
result.should.have.length(1)
|
||||
result[0].content[0].should.be.instanceof(vscode.LanguageModelTextPart)
|
||||
const textPart = result[0].content[0] as vscode.LanguageModelTextPart
|
||||
textPart.should.have.property("value")
|
||||
textPart.value.should.match(/Image \(base64\): image\/jpeg not supported by VSCode LM API/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToAnthropicMessage", () => {
|
||||
it("should convert VSCode assistant messages to Anthropic format", () => {
|
||||
const vsCodeMsg = vscode.LanguageModelChatMessage.Assistant([
|
||||
new vscode.LanguageModelTextPart("Test message"),
|
||||
new vscode.LanguageModelToolCallPart("tool-id", "testTool", { param: "value" }),
|
||||
])
|
||||
|
||||
const result = convertToAnthropicMessage(vsCodeMsg)
|
||||
|
||||
result.should.have.property("role", "assistant")
|
||||
result.should.have.property("content").which.is.an.Array()
|
||||
result.content.should.have.length(2)
|
||||
|
||||
// Check properties carefully to avoid null reference errors
|
||||
if (result.content && result.content.length >= 1) {
|
||||
const textContent = result.content[0]
|
||||
if (textContent) {
|
||||
textContent.should.have.property("type", "text")
|
||||
if (textContent.type === "text") {
|
||||
textContent.should.have.property("text", "Test message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.content && result.content.length >= 2) {
|
||||
const toolContent = result.content[1]
|
||||
if (toolContent) {
|
||||
toolContent.should.have.property("type", "tool_use")
|
||||
if (toolContent.type === "tool_use") {
|
||||
toolContent.should.have.property("id", "tool-id")
|
||||
toolContent.should.have.property("name", "testTool")
|
||||
toolContent.should.have.property("input").which.deepEqual({ param: "value" })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw an error for non-assistant messages", () => {
|
||||
const vsCodeMsg = vscode.LanguageModelChatMessage.User("User message")
|
||||
|
||||
try {
|
||||
convertToAnthropicMessage(vsCodeMsg)
|
||||
throw new Error("Should have thrown an error")
|
||||
} catch (error: any) {
|
||||
error.message.should.match(/Only assistant messages are supported/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import * as vscode from "vscode"
|
||||
/**
|
||||
* Safely converts a value into a plain object.
|
||||
*/
|
||||
function asObjectSafe(value: any): object {
|
||||
export function asObjectSafe(value: any): object {
|
||||
// Handle null/undefined
|
||||
if (!value) {
|
||||
return {}
|
||||
@@ -145,7 +145,9 @@ export function convertToVsCodeLmMessages(
|
||||
return vsCodeLmMessages
|
||||
}
|
||||
|
||||
export function convertToAnthropicRole(vsCodeLmMessageRole: vscode.LanguageModelChatMessageRole): string | null {
|
||||
export function convertToAnthropicRole(
|
||||
vsCodeLmMessageRole: vscode.LanguageModelChatMessageRole,
|
||||
): Anthropic.Messages.MessageParam["role"] | null {
|
||||
switch (vsCodeLmMessageRole) {
|
||||
case vscode.LanguageModelChatMessageRole.Assistant:
|
||||
return "assistant"
|
||||
@@ -156,10 +158,8 @@ export function convertToAnthropicRole(vsCodeLmMessageRole: vscode.LanguageModel
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertToAnthropicMessage(
|
||||
vsCodeLmMessage: vscode.LanguageModelChatMessage,
|
||||
): Promise<Anthropic.Messages.Message> {
|
||||
const anthropicRole: string | null = convertToAnthropicRole(vsCodeLmMessage.role)
|
||||
export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelChatMessage): Anthropic.Messages.Message {
|
||||
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role)
|
||||
if (anthropicRole !== "assistant") {
|
||||
throw new Error("Cline <Language Model API>: Only assistant messages are supported.")
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ export async function constructNewFileContent(diffContent: string, originalConte
|
||||
;[searchMatchIndex, searchEndIndex] = blockMatch
|
||||
} else {
|
||||
throw new Error(
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
|
||||
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file or was searched out of order in the provided blocks.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const toolUseNames = [
|
||||
"plan_mode_respond",
|
||||
"load_mcp_documentation",
|
||||
"attempt_completion",
|
||||
"new_task",
|
||||
] as const
|
||||
|
||||
// Converts array of tool call names into a union type ("execute_command" | "read_file" | ...)
|
||||
@@ -49,6 +50,7 @@ export const toolParamNames = [
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
"context",
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../../api"
|
||||
import { getContextWindowInfo } from "./context-window-utils"
|
||||
|
||||
class ContextManager {
|
||||
getNewContextMessagesAndMetadata(
|
||||
apiConversationHistory: Anthropic.Messages.MessageParam[],
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
previousApiReqIndex: number,
|
||||
) {
|
||||
let updatedConversationHistoryDeletedRange = false
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
conversationHistoryDeletedRange = this.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
|
||||
updatedConversationHistoryDeletedRange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
|
||||
|
||||
return {
|
||||
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
|
||||
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
|
||||
truncatedConversationHistory: truncatedConversationHistory,
|
||||
}
|
||||
}
|
||||
|
||||
public getNextTruncationRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined,
|
||||
keep: "half" | "quarter",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of remaining user-assistant pairs
|
||||
// We first calculate half of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of remaining user-assistant pairs
|
||||
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (apiMessages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
public getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
}
|
||||
+8
-26
@@ -1,13 +1,13 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { GlobalFileNames } from "../storage/disk"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { getContextWindowInfo } from "./context-window-utils"
|
||||
import { formatResponse } from "../../prompts/responses"
|
||||
import { GlobalFileNames } from "../../storage/disk"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
import * as path from "path"
|
||||
import fs from "fs/promises"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../../api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
enum EditType {
|
||||
UNDEFINED = 0,
|
||||
@@ -125,25 +125,7 @@ export class ContextManager {
|
||||
const timestamp = previousRequest.ts
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
const { maxAllowedSize } = getContextWindowInfo(api)
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
@@ -0,0 +1,18 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
try {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
} catch (e: unknown) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
try {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ApiHandler } from "../../../api"
|
||||
import { OpenAiHandler } from "../../../api/providers/openai"
|
||||
|
||||
/**
|
||||
* Gets context window information for the given API handler
|
||||
*
|
||||
* @param api The API handler to get context window information for
|
||||
* @returns An object containing the raw context window size and the effective max allowed size
|
||||
*/
|
||||
export function getContextWindowInfo(api: ApiHandler) {
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
|
||||
// Handle special cases like DeepSeek
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
return { contextWindow, maxAllowedSize }
|
||||
}
|
||||
+8
-5
@@ -10,11 +10,14 @@ export interface FileMetadataEntry {
|
||||
user_edit_date?: number | null
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
files_in_context: FileMetadataEntry[]
|
||||
export interface ModelMetadataEntry {
|
||||
ts: number
|
||||
model_id: string
|
||||
model_provider_id: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
// Interface for the controller to avoid direct dependency
|
||||
export interface ControllerLike {
|
||||
context: vscode.ExtensionContext
|
||||
export interface TaskMetadata {
|
||||
files_in_context: FileMetadataEntry[]
|
||||
model_usage: ModelMetadataEntry[]
|
||||
}
|
||||
+5
-10
@@ -4,12 +4,11 @@ import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
import * as diskModule from "../storage/disk"
|
||||
import type { TaskMetadata, ControllerLike, FileMetadataEntry } from "./FileContextTrackerTypes"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockController: ControllerLike
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let mockWorkspace: sinon.SinonStub
|
||||
let mockFileSystemWatcher: any
|
||||
@@ -41,25 +40,21 @@ describe("FileContextTracker", () => {
|
||||
const originalCreateFileSystemWatcher = vscode.workspace.createFileSystemWatcher
|
||||
vscode.workspace.createFileSystemWatcher = function () {
|
||||
return mockFileSystemWatcher
|
||||
} as any
|
||||
}
|
||||
|
||||
// Mock controller and context
|
||||
mockContext = {
|
||||
globalStorageUri: { fsPath: "/mock/storage" },
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
mockController = {
|
||||
context: mockContext,
|
||||
}
|
||||
|
||||
// Mock disk module functions
|
||||
mockTaskMetadata = { files_in_context: [] }
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
tracker = new FileContextTracker(mockController, taskId)
|
||||
tracker = new FileContextTracker(mockContext, taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
+6
-17
@@ -1,7 +1,7 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { getTaskMetadata, saveTaskMetadata } from "../storage/disk"
|
||||
import type { FileMetadataEntry, ControllerLike } from "./FileContextTrackerTypes"
|
||||
import { getTaskMetadata, saveTaskMetadata } from "../../storage/disk"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
|
||||
// This class is responsible for tracking file operations that may result in stale context.
|
||||
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
|
||||
@@ -16,29 +16,19 @@ import type { FileMetadataEntry, ControllerLike } from "./FileContextTrackerType
|
||||
// If the full contents of a file are pass to Cline via a tool, mention, or edit, the file is marked as active.
|
||||
// If a file is modified outside of Cline, we detect and track this change to prevent stale context.
|
||||
export class FileContextTracker {
|
||||
private context: vscode.ExtensionContext
|
||||
readonly taskId: string
|
||||
private controllerRef: WeakRef<ControllerLike>
|
||||
|
||||
// File tracking and watching
|
||||
private fileWatchers = new Map<string, vscode.FileSystemWatcher>()
|
||||
private recentlyModifiedFiles = new Set<string>()
|
||||
private recentlyEditedByCline = new Set<string>()
|
||||
|
||||
constructor(controller: ControllerLike, taskId: string) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
constructor(context: vscode.ExtensionContext, taskId: string) {
|
||||
this.context = context
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
// While a task is ref'd by a controller, it will always have access to the extension context
|
||||
// This error is thrown if the controller derefs the task after e.g., aborting the task
|
||||
private context(): vscode.ExtensionContext {
|
||||
const context = this.controllerRef.deref()?.context
|
||||
if (!context) {
|
||||
throw new Error("Unable to access extension context")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Gets the current working directory or returns undefined if it cannot be determined
|
||||
private getCwd(): string | undefined {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
@@ -89,9 +79,8 @@ export class FileContextTracker {
|
||||
return
|
||||
}
|
||||
|
||||
const context = this.context()
|
||||
// Add file to metadata
|
||||
await this.addFileToFileContextTracker(context, this.taskId, filePath, operation)
|
||||
await this.addFileToFileContextTracker(this.context, this.taskId, filePath, operation)
|
||||
|
||||
// Set up file watcher for this file
|
||||
await this.setupFileWatcher(filePath)
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import { expect } from "chai"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import { ModelContextTracker } from "./ModelContextTracker"
|
||||
import * as diskModule from "../../storage/disk"
|
||||
import type { TaskMetadata } from "./ContextTrackerTypes"
|
||||
|
||||
describe("ModelContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let mockContext: vscode.ExtensionContext
|
||||
let tracker: ModelContextTracker
|
||||
let taskId: string
|
||||
let mockTaskMetadata: TaskMetadata
|
||||
let getTaskMetadataStub: sinon.SinonStub
|
||||
let saveTaskMetadataStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock controller and context
|
||||
mockContext = {
|
||||
globalStorageUri: { fsPath: "/mock/storage" },
|
||||
} as unknown as vscode.ExtensionContext
|
||||
|
||||
// Mock disk module functions
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
tracker = new ModelContextTracker(mockContext, taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should record model usage with correct data", async () => {
|
||||
// Test data
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-opus"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer to have a predictable timestamp
|
||||
const fakeNow = 1617293940000 // Some fixed timestamp
|
||||
const clock = sandbox.useFakeTimers(fakeNow)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify getTaskMetadata was called with correct parameters
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(getTaskMetadataStub.firstCall.args[1]).to.equal(taskId)
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata from the call arguments
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Verify model_usage array has one entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Verify the entry has the correct properties
|
||||
const modelUsageEntry = savedMetadata.model_usage[0]
|
||||
expect(modelUsageEntry.ts).to.equal(fakeNow)
|
||||
expect(modelUsageEntry.model_id).to.equal(modelId)
|
||||
expect(modelUsageEntry.model_provider_id).to.equal(apiProviderId)
|
||||
expect(modelUsageEntry.mode).to.equal(mode)
|
||||
} finally {
|
||||
// Restore the clock
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw an error when controller is dereferenced", async () => {
|
||||
// Create a new tracker with a controller that will be garbage collected
|
||||
const weakTracker = new ModelContextTracker(mockContext, taskId)
|
||||
|
||||
// Force the WeakRef to return null by overriding the deref method
|
||||
const weakRef = { deref: sandbox.stub().returns(null) }
|
||||
sandbox.stub(WeakRef.prototype, "deref").callsFake(() => weakRef.deref())
|
||||
|
||||
try {
|
||||
// Try to call the method - this should throw
|
||||
await weakTracker.recordModelUsage("any-provider", "any-model", "any-mode")
|
||||
|
||||
// If we get here, the test should fail
|
||||
expect.fail("Expected an error to be thrown")
|
||||
} catch (error) {
|
||||
// Verify the error message
|
||||
expect(error.message).to.equal("Unable to access extension context")
|
||||
}
|
||||
})
|
||||
|
||||
it("should append model usage to existing entries", async () => {
|
||||
// Add an existing model usage entry
|
||||
const existingTimestamp = 1617200000000
|
||||
mockTaskMetadata.model_usage = [
|
||||
{
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
},
|
||||
]
|
||||
|
||||
// Test data for new entry
|
||||
const apiProviderId = "anthropic"
|
||||
const modelId = "claude-3-sonnet"
|
||||
const mode = "act"
|
||||
|
||||
// Use a fake timer
|
||||
const newTimestamp = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(newTimestamp)
|
||||
|
||||
try {
|
||||
// Call the method being tested
|
||||
await tracker.recordModelUsage(apiProviderId, modelId, mode)
|
||||
|
||||
// Verify saveTaskMetadata was called
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Extract the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Verify model_usage array now has two entries
|
||||
expect(savedMetadata.model_usage.length).to.equal(2)
|
||||
|
||||
// Verify the existing entry is preserved
|
||||
expect(savedMetadata.model_usage[0]).to.deep.equal({
|
||||
ts: existingTimestamp,
|
||||
model_id: "existing-model",
|
||||
model_provider_id: "existing-provider",
|
||||
mode: "plan",
|
||||
})
|
||||
|
||||
// Verify the new entry has correct data
|
||||
expect(savedMetadata.model_usage[1]).to.deep.equal({
|
||||
ts: newTimestamp,
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle multiple model usages in sequence", async () => {
|
||||
// Test data for sequential calls
|
||||
const usages = [
|
||||
{ provider: "anthropic", model: "claude-3-opus", mode: "plan" },
|
||||
{ provider: "openai", model: "gpt-4", mode: "act" },
|
||||
{ provider: "anthropic", model: "claude-3-haiku", mode: "plan" },
|
||||
]
|
||||
|
||||
// Use a fake timer that advances with each call
|
||||
const startTime = 1617300000000
|
||||
const clock = sandbox.useFakeTimers(startTime)
|
||||
|
||||
try {
|
||||
// Record multiple model usages
|
||||
for (let i = 0; i < usages.length; i++) {
|
||||
const { provider, model, mode } = usages[i]
|
||||
|
||||
// Advance time by 1 second for each call
|
||||
clock.tick(1000)
|
||||
const expectedTime = startTime + (i + 1) * 1000
|
||||
|
||||
// Reset history between calls to check individual call behavior
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Reset mock metadata for each iteration to avoid accumulation
|
||||
mockTaskMetadata.model_usage = []
|
||||
|
||||
// Call the method
|
||||
await tracker.recordModelUsage(provider, model, mode)
|
||||
|
||||
// Verify interaction with disk module
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
// Get the saved metadata
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[2]
|
||||
|
||||
// Since we reset the array for each call, we should always have 1 entry
|
||||
expect(savedMetadata.model_usage.length).to.equal(1)
|
||||
|
||||
// Check the entry
|
||||
const entry = savedMetadata.model_usage[0]
|
||||
expect(entry.ts).to.equal(expectedTime)
|
||||
expect(entry.model_id).to.equal(model)
|
||||
expect(entry.model_provider_id).to.equal(provider)
|
||||
expect(entry.mode).to.equal(mode)
|
||||
}
|
||||
} finally {
|
||||
clock.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as vscode from "vscode"
|
||||
import { getTaskMetadata, saveTaskMetadata } from "../../storage/disk"
|
||||
|
||||
export class ModelContextTracker {
|
||||
readonly taskId: string
|
||||
private context: vscode.ExtensionContext
|
||||
|
||||
constructor(context: vscode.ExtensionContext, taskId: string) {
|
||||
this.context = context
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
async recordModelUsage(apiProviderId: string, modelId: string, mode: string) {
|
||||
const metadata = await getTaskMetadata(this.context, this.taskId)
|
||||
|
||||
if (!metadata.model_usage) {
|
||||
metadata.model_usage = []
|
||||
}
|
||||
|
||||
// check to see if the last entry is the same as the new one
|
||||
const lastEntry = metadata.model_usage[metadata.model_usage.length - 1]
|
||||
if (
|
||||
lastEntry &&
|
||||
lastEntry.model_id === modelId &&
|
||||
lastEntry.model_provider_id === apiProviderId &&
|
||||
lastEntry.mode === mode
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
metadata.model_usage.push({
|
||||
ts: Date.now(),
|
||||
model_id: modelId,
|
||||
model_provider_id: apiProviderId,
|
||||
mode: mode,
|
||||
})
|
||||
|
||||
await saveTaskMetadata(this.context, this.taskId, metadata)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import path from "path"
|
||||
import { GlobalFileNames } from "../../../storage/disk"
|
||||
import { fileExistsAtPath, isDirectory, readDirectory } from "../../../../utils/fs"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
import fs from "fs/promises"
|
||||
|
||||
export const getGlobalClineRules = async (globalClineRulesFilePath: string) => {
|
||||
if (await fileExistsAtPath(globalClineRulesFilePath)) {
|
||||
if (await isDirectory(globalClineRulesFilePath)) {
|
||||
try {
|
||||
const rulesFilePaths = await readDirectory(globalClineRulesFilePath)
|
||||
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(rulesFilePaths, globalClineRulesFilePath)
|
||||
const clineRulesFileInstructions = formatResponse.clineRulesGlobalDirectoryInstructions(rulesFilesTotalContent)
|
||||
return clineRulesFileInstructions
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules directory at ${globalClineRulesFilePath}`)
|
||||
}
|
||||
} else {
|
||||
console.error(`${globalClineRulesFilePath} is not a directory`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const getLocalClineRules = async (cwd: string) => {
|
||||
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
|
||||
|
||||
let clineRulesFileInstructions: string | undefined
|
||||
|
||||
if (await fileExistsAtPath(clineRulesFilePath)) {
|
||||
if (await isDirectory(clineRulesFilePath)) {
|
||||
try {
|
||||
const rulesFilePaths = await readDirectory(path.join(cwd, GlobalFileNames.clineRules))
|
||||
const rulesFilesTotalContent = await getClineRulesFilesTotalContent(rulesFilePaths, cwd)
|
||||
clineRulesFileInstructions = formatResponse.clineRulesLocalDirectoryInstructions(cwd, rulesFilesTotalContent)
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (ruleFileContent) {
|
||||
clineRulesFileInstructions = formatResponse.clineRulesLocalFileInstructions(cwd, ruleFileContent)
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clineRulesFileInstructions
|
||||
}
|
||||
|
||||
const getClineRulesFilesTotalContent = async (rulesFilePaths: string[], basePath: string) => {
|
||||
const ruleFilesTotalContent = await Promise.all(
|
||||
rulesFilePaths.map(async (filePath) => {
|
||||
const ruleFilePath = path.resolve(basePath, filePath)
|
||||
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
|
||||
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
|
||||
}),
|
||||
).then((contents) => contents.join("\n\n"))
|
||||
return ruleFilesTotalContent
|
||||
}
|
||||
+165
-122
@@ -1,10 +1,9 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import type { AxiosRequestConfig } from "axios"
|
||||
import crypto from "crypto"
|
||||
import { execa } from "execa"
|
||||
import fs from "fs/promises"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import os from "os"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
@@ -17,10 +16,12 @@ import { selectImages } from "../../integrations/misc/process-images"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { ClineAccountService } from "../../services/account/ClineAccountService"
|
||||
import { discoverChromeInstances } from "../../services/browser/BrowserDiscovery"
|
||||
import { BrowserSession } from "../../services/browser/BrowserSession"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { searchWorkspaceFiles } from "../../services/search/file-search"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
import { ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { findLast } from "../../shared/array"
|
||||
import { ChatContent } from "../../shared/ChatContent"
|
||||
import { ChatSettings } from "../../shared/ChatSettings"
|
||||
import { ExtensionMessage, ExtensionState, Invoke, Platform } from "../../shared/ExtensionMessage"
|
||||
@@ -30,9 +31,10 @@ import { TelemetrySetting } from "../../shared/TelemetrySetting"
|
||||
import { ClineCheckpointRestore, WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { searchCommits } from "../../utils/git"
|
||||
import { getWorkspacePath } from "../../utils/path"
|
||||
import { getTotalTasksSize } from "../../utils/storage"
|
||||
import { Task } from "../task"
|
||||
import { openMention } from "../mentions"
|
||||
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
|
||||
import {
|
||||
getAllExtensionState,
|
||||
getGlobalState,
|
||||
@@ -42,12 +44,7 @@ import {
|
||||
updateApiConfiguration,
|
||||
updateGlobalState,
|
||||
} from "../storage/state"
|
||||
import { WebviewProvider } from "../webview"
|
||||
import { BrowserSession } from "../../services/browser/BrowserSession"
|
||||
import { GlobalFileNames } from "../storage/disk"
|
||||
import { discoverChromeInstances } from "../../services/browser/BrowserDiscovery"
|
||||
import { searchWorkspaceFiles } from "../../services/search/file-search"
|
||||
import { getWorkspacePath } from "../../utils/path"
|
||||
import { Task } from "../task"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -56,25 +53,37 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
|
||||
*/
|
||||
|
||||
export class Controller {
|
||||
private postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined
|
||||
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private task?: Task
|
||||
workspaceTracker?: WorkspaceTracker
|
||||
mcpHub?: McpHub
|
||||
accountService?: ClineAccountService
|
||||
private latestAnnouncementId = "march-22-2025" // update to some unique identifier when we add a new announcement
|
||||
private webviewProviderRef: WeakRef<WebviewProvider>
|
||||
workspaceTracker: WorkspaceTracker
|
||||
mcpHub: McpHub
|
||||
accountService: ClineAccountService
|
||||
private latestAnnouncementId = "april-11-2025" // update to some unique identifier when we add a new announcement
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
private readonly outputChannel: vscode.OutputChannel,
|
||||
webviewProvider: WebviewProvider,
|
||||
postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined,
|
||||
) {
|
||||
this.outputChannel.appendLine("ClineProvider instantiated")
|
||||
this.webviewProviderRef = new WeakRef(webviewProvider)
|
||||
this.postMessage = postMessage
|
||||
|
||||
this.workspaceTracker = new WorkspaceTracker(this)
|
||||
this.mcpHub = new McpHub(this)
|
||||
this.accountService = new ClineAccountService(this)
|
||||
this.workspaceTracker = new WorkspaceTracker((msg) => this.postMessageToWebview(msg))
|
||||
this.mcpHub = new McpHub(
|
||||
() => ensureMcpServersDirectoryExists(),
|
||||
() => ensureSettingsDirectoryExists(this.context),
|
||||
(msg) => this.postMessageToWebview(msg),
|
||||
this.context.extension?.packageJSON?.version ?? "1.0.0",
|
||||
)
|
||||
this.accountService = new ClineAccountService(
|
||||
(msg) => this.postMessageToWebview(msg),
|
||||
async () => {
|
||||
const { apiConfiguration } = await this.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
},
|
||||
)
|
||||
|
||||
// Clean up legacy checkpoints
|
||||
cleanupLegacyCheckpoints(this.context.globalStorageUri.fsPath, this.outputChannel).catch((error) => {
|
||||
@@ -97,11 +106,8 @@ export class Controller {
|
||||
x.dispose()
|
||||
}
|
||||
}
|
||||
this.workspaceTracker?.dispose()
|
||||
this.workspaceTracker = undefined
|
||||
this.mcpHub?.dispose()
|
||||
this.mcpHub = undefined
|
||||
this.accountService = undefined
|
||||
this.workspaceTracker.dispose()
|
||||
this.mcpHub.dispose()
|
||||
this.outputChannel.appendLine("Disposed all disposables")
|
||||
|
||||
console.error("Controller disposed")
|
||||
@@ -124,12 +130,19 @@ export class Controller {
|
||||
await updateGlobalState(this.context, "userInfo", info)
|
||||
}
|
||||
|
||||
async initClineWithTask(task?: string, images?: string[]) {
|
||||
async initTask(task?: string, images?: string[], historyItem?: HistoryItem) {
|
||||
await this.clearTask() // ensures that an existing task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
|
||||
const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } =
|
||||
await getAllExtensionState(this.context)
|
||||
this.task = new Task(
|
||||
this,
|
||||
this.context,
|
||||
this.mcpHub,
|
||||
this.workspaceTracker,
|
||||
(historyItem) => this.updateTaskHistory(historyItem),
|
||||
() => this.postStateToWebview(),
|
||||
(message) => this.postMessageToWebview(message),
|
||||
(taskId) => this.reinitExistingTaskFromId(taskId),
|
||||
() => this.cancelTask(),
|
||||
apiConfiguration,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
@@ -137,29 +150,20 @@ export class Controller {
|
||||
customInstructions,
|
||||
task,
|
||||
images,
|
||||
)
|
||||
}
|
||||
|
||||
async initClineWithHistoryItem(historyItem: HistoryItem) {
|
||||
await this.clearTask()
|
||||
const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } =
|
||||
await getAllExtensionState(this.context)
|
||||
this.task = new Task(
|
||||
this,
|
||||
apiConfiguration,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
customInstructions,
|
||||
undefined,
|
||||
undefined,
|
||||
historyItem,
|
||||
)
|
||||
}
|
||||
|
||||
async reinitExistingTaskFromId(taskId: string) {
|
||||
const history = await this.getTaskWithId(taskId)
|
||||
if (history) {
|
||||
await this.initTask(undefined, undefined, history.historyItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Send any JSON serializable data to the react app
|
||||
async postMessageToWebview(message: ExtensionMessage) {
|
||||
await this.webviewProviderRef.deref()?.view?.webview.postMessage(message)
|
||||
await this.postMessage(message)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,7 +263,7 @@ export class Controller {
|
||||
// Could also do this in extension .ts
|
||||
//this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` })
|
||||
// initializing new instance of Cline will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task
|
||||
await this.initClineWithTask(message.text, message.images)
|
||||
await this.initTask(message.text, message.images)
|
||||
break
|
||||
case "apiConfiguration":
|
||||
if (message.apiConfiguration) {
|
||||
@@ -487,6 +491,9 @@ export class Controller {
|
||||
case "refreshOpenRouterModels":
|
||||
await this.refreshOpenRouterModels()
|
||||
break
|
||||
case "refreshRequestyModels":
|
||||
await this.refreshRequestyModels()
|
||||
break
|
||||
case "refreshOpenAiModels":
|
||||
const { apiConfiguration } = await getAllExtensionState(this.context)
|
||||
const openAiModels = await this.getOpenAiModels(apiConfiguration.openAiBaseUrl, apiConfiguration.openAiApiKey)
|
||||
@@ -575,7 +582,7 @@ export class Controller {
|
||||
break
|
||||
}
|
||||
case "showMcpView": {
|
||||
await this.postMessageToWebview({ type: "action", action: "mcpButtonClicked" })
|
||||
await this.postMessageToWebview({ type: "action", action: "mcpButtonClicked", tab: message.tab || undefined })
|
||||
break
|
||||
}
|
||||
case "openMcpSettings": {
|
||||
@@ -882,6 +889,27 @@ export class Controller {
|
||||
}
|
||||
break
|
||||
}
|
||||
case "toggleFavoriteModel": {
|
||||
if (message.modelId) {
|
||||
const { apiConfiguration } = await getAllExtensionState(this.context)
|
||||
const favoritedModelIds = apiConfiguration.favoritedModelIds || []
|
||||
|
||||
// Toggle favorite status
|
||||
const updatedFavorites = favoritedModelIds.includes(message.modelId)
|
||||
? favoritedModelIds.filter((id) => id !== message.modelId)
|
||||
: [...favoritedModelIds, message.modelId]
|
||||
|
||||
await updateGlobalState(this.context, "favoritedModelIds", updatedFavorites)
|
||||
|
||||
// Capture telemetry for model favorite toggle
|
||||
const isFavorited = !favoritedModelIds.includes(message.modelId)
|
||||
telemetryService.captureModelFavoritesUsage(message.modelId, isFavorited)
|
||||
|
||||
// Post state to webview without changing any other configuration
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
break
|
||||
}
|
||||
// Add more switch case statements here as more webview message commands
|
||||
// are created within the webview context (i.e. inside media/main.js)
|
||||
}
|
||||
@@ -907,6 +935,7 @@ export class Controller {
|
||||
previousModeModelInfo: newModelInfo,
|
||||
previousModeVsCodeLmModelSelector: newVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens: newThinkingBudgetTokens,
|
||||
previousModeReasoningEffort: newReasoningEffort,
|
||||
planActSeparateModelsSetting,
|
||||
} = await getAllExtensionState(this.context)
|
||||
|
||||
@@ -916,6 +945,7 @@ export class Controller {
|
||||
// Save the last model used in this mode
|
||||
await updateGlobalState(this.context, "previousModeApiProvider", apiConfiguration.apiProvider)
|
||||
await updateGlobalState(this.context, "previousModeThinkingBudgetTokens", apiConfiguration.thinkingBudgetTokens)
|
||||
await updateGlobalState(this.context, "previousModeReasoningEffort", apiConfiguration.reasoningEffort)
|
||||
switch (apiConfiguration.apiProvider) {
|
||||
case "anthropic":
|
||||
case "bedrock":
|
||||
@@ -925,6 +955,7 @@ export class Controller {
|
||||
case "openai-native":
|
||||
case "qwen":
|
||||
case "deepseek":
|
||||
case "xai":
|
||||
await updateGlobalState(this.context, "previousModeModelId", apiConfiguration.apiModelId)
|
||||
break
|
||||
case "openrouter":
|
||||
@@ -955,13 +986,21 @@ export class Controller {
|
||||
break
|
||||
case "requesty":
|
||||
await updateGlobalState(this.context, "previousModeModelId", apiConfiguration.requestyModelId)
|
||||
await updateGlobalState(this.context, "previousModeModelInfo", apiConfiguration.requestyModelInfo)
|
||||
break
|
||||
}
|
||||
|
||||
// Restore the model used in previous mode
|
||||
if (newApiProvider || newModelId || newThinkingBudgetTokens !== undefined || newVsCodeLmModelSelector) {
|
||||
if (
|
||||
newApiProvider ||
|
||||
newModelId ||
|
||||
newThinkingBudgetTokens !== undefined ||
|
||||
newReasoningEffort ||
|
||||
newVsCodeLmModelSelector
|
||||
) {
|
||||
await updateGlobalState(this.context, "apiProvider", newApiProvider)
|
||||
await updateGlobalState(this.context, "thinkingBudgetTokens", newThinkingBudgetTokens)
|
||||
await updateGlobalState(this.context, "reasoningEffort", newReasoningEffort)
|
||||
switch (newApiProvider) {
|
||||
case "anthropic":
|
||||
case "bedrock":
|
||||
@@ -971,6 +1010,7 @@ export class Controller {
|
||||
case "openai-native":
|
||||
case "qwen":
|
||||
case "deepseek":
|
||||
case "xai":
|
||||
await updateGlobalState(this.context, "apiModelId", newModelId)
|
||||
break
|
||||
case "openrouter":
|
||||
@@ -996,6 +1036,7 @@ export class Controller {
|
||||
break
|
||||
case "requesty":
|
||||
await updateGlobalState(this.context, "requestyModelId", newModelId)
|
||||
await updateGlobalState(this.context, "requestyModelInfo", newModelInfo)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1050,7 +1091,7 @@ export class Controller {
|
||||
// 'abandoned' will prevent this cline instance from affecting future cline instance gui. this may happen if its hanging on a streaming request
|
||||
this.task.abandoned = true
|
||||
}
|
||||
await this.initClineWithHistoryItem(historyItem) // clears task again, so we need to abortTask manually above
|
||||
await this.initTask(undefined, undefined, historyItem) // clears task again, so we need to abortTask manually above
|
||||
// await this.postStateToWebview() // new Cline instance will post state when it's ready. having this here sent an empty messages array to webview leading to virtuoso having to reload the entire list
|
||||
}
|
||||
}
|
||||
@@ -1063,61 +1104,6 @@ export class Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// MCP
|
||||
|
||||
async getDocumentsPath(): Promise<string> {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
const { stdout: docsPath } = await execa("powershell", [
|
||||
"-NoProfile", // Ignore user's PowerShell profile(s)
|
||||
"-Command",
|
||||
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
|
||||
])
|
||||
const trimmedPath = docsPath.trim()
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
|
||||
}
|
||||
} else if (process.platform === "linux") {
|
||||
try {
|
||||
// First check if xdg-user-dir exists
|
||||
await execa("which", ["xdg-user-dir"])
|
||||
|
||||
// If it exists, try to get XDG documents path
|
||||
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
|
||||
const trimmedPath = stdout.trim()
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
}
|
||||
} catch {
|
||||
// Log error but continue to fallback
|
||||
console.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback for all platforms
|
||||
return path.join(os.homedir(), "Documents")
|
||||
}
|
||||
|
||||
async ensureMcpServersDirectoryExists(): Promise<string> {
|
||||
const userDocumentsPath = await this.getDocumentsPath()
|
||||
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP")
|
||||
try {
|
||||
await fs.mkdir(mcpServersDir, { recursive: true })
|
||||
} catch (error) {
|
||||
return "~/Documents/Cline/MCP" // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
|
||||
}
|
||||
return mcpServersDir
|
||||
}
|
||||
|
||||
async ensureSettingsDirectoryExists(): Promise<string> {
|
||||
const settingsDir = path.join(this.context.globalStorageUri.fsPath, "settings")
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
return settingsDir
|
||||
}
|
||||
|
||||
// VSCode LM API
|
||||
|
||||
private async getVsCodeLmModels() {
|
||||
@@ -1362,13 +1348,14 @@ export class Controller {
|
||||
- Start by loading the MCP documentation.
|
||||
- Use "${mcpDetails.mcpId}" as the server name in cline_mcp_settings.json.
|
||||
- Create the directory for the new MCP server before starting installation.
|
||||
- Make sure you read the user's existing cline_mcp_settings.json file before editing it with this new mcp, to not overwrite any existing servers.
|
||||
- Use commands aligned with the user's shell and operating system best practices.
|
||||
- The following README may contain instructions that conflict with the user's OS, in which case proceed thoughtfully.
|
||||
- Once installed, demonstrate the server's capabilities by using one of its tools.
|
||||
Here is the project's README to help you get started:\n\n${mcpDetails.readmeContent}\n${mcpDetails.llmsInstallationContent}`
|
||||
|
||||
// Initialize task and show chat view
|
||||
await this.initClineWithTask(task)
|
||||
await this.initTask(task)
|
||||
await this.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "chatButtonClicked",
|
||||
@@ -1412,7 +1399,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
return []
|
||||
}
|
||||
|
||||
const config: Record<string, any> = {}
|
||||
const config: AxiosRequestConfig = {}
|
||||
if (apiKey) {
|
||||
config["headers"] = { Authorization: `Bearer ${apiKey}` }
|
||||
}
|
||||
@@ -1593,6 +1580,52 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
return models
|
||||
}
|
||||
|
||||
async refreshRequestyModels() {
|
||||
const parsePrice = (price: any) => {
|
||||
if (price) {
|
||||
return parseFloat(price) * 1_000_000
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
let models: Record<string, ModelInfo> = {}
|
||||
try {
|
||||
const apiKey = await getSecret(this.context, "requestyApiKey")
|
||||
const headers = {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
}
|
||||
const response = await axios.get("https://router.requesty.ai/v1/models", { headers })
|
||||
if (response.data?.data) {
|
||||
for (const model of response.data.data) {
|
||||
const modelInfo: ModelInfo = {
|
||||
maxTokens: model.max_output_tokens || undefined,
|
||||
contextWindow: model.context_window,
|
||||
supportsImages: model.supports_vision || undefined,
|
||||
supportsComputerUse: model.supports_computer_use || undefined,
|
||||
supportsPromptCache: model.supports_caching || undefined,
|
||||
inputPrice: parsePrice(model.input_price),
|
||||
outputPrice: parsePrice(model.output_price),
|
||||
cacheWritesPrice: parsePrice(model.caching_price),
|
||||
cacheReadsPrice: parsePrice(model.cached_price),
|
||||
description: model.description,
|
||||
}
|
||||
models[model.id] = modelInfo
|
||||
}
|
||||
console.log("Requesty models fetched", models)
|
||||
} else {
|
||||
console.error("Invalid response from Requesty API")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching Requesty models:", error)
|
||||
}
|
||||
|
||||
await this.postMessageToWebview({
|
||||
type: "requestyModels",
|
||||
requestyModels: models,
|
||||
})
|
||||
return models
|
||||
}
|
||||
|
||||
// Context menus and code actions
|
||||
|
||||
getFileMentionFromPath(filePath: string) {
|
||||
@@ -1656,9 +1689,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
|
||||
await this.initClineWithTask(
|
||||
`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`,
|
||||
)
|
||||
await this.initTask(`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`)
|
||||
|
||||
console.log("fixWithCline", code, filePath, languageId, diagnostics, problemsString)
|
||||
}
|
||||
@@ -1698,6 +1729,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
taskDirPath: string
|
||||
apiConversationHistoryFilePath: string
|
||||
uiMessagesFilePath: string
|
||||
contextHistoryFilePath: string
|
||||
taskMetadataFilePath: string
|
||||
apiConversationHistory: Anthropic.MessageParam[]
|
||||
}> {
|
||||
const history = ((await getGlobalState(this.context, "taskHistory")) as HistoryItem[] | undefined) || []
|
||||
@@ -1706,6 +1739,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks", id)
|
||||
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
|
||||
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
|
||||
const contextHistoryFilePath = path.join(taskDirPath, GlobalFileNames.contextHistory)
|
||||
const taskMetadataFilePath = path.join(taskDirPath, GlobalFileNames.taskMetadata)
|
||||
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
|
||||
if (fileExists) {
|
||||
const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8"))
|
||||
@@ -1714,6 +1749,8 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
taskDirPath,
|
||||
apiConversationHistoryFilePath,
|
||||
uiMessagesFilePath,
|
||||
contextHistoryFilePath,
|
||||
taskMetadataFilePath,
|
||||
apiConversationHistory,
|
||||
}
|
||||
}
|
||||
@@ -1728,7 +1765,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
if (id !== this.task?.taskId) {
|
||||
// non-current task
|
||||
const { historyItem } = await this.getTaskWithId(id)
|
||||
await this.initClineWithHistoryItem(historyItem) // clears existing task
|
||||
await this.initTask(undefined, undefined, historyItem) // clears existing task
|
||||
}
|
||||
await this.postMessageToWebview({
|
||||
type: "action",
|
||||
@@ -1785,22 +1822,28 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
|
||||
console.debug("cleared task")
|
||||
}
|
||||
|
||||
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath } = await this.getTaskWithId(id)
|
||||
|
||||
const {
|
||||
taskDirPath,
|
||||
apiConversationHistoryFilePath,
|
||||
uiMessagesFilePath,
|
||||
contextHistoryFilePath,
|
||||
taskMetadataFilePath,
|
||||
} = await this.getTaskWithId(id)
|
||||
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
|
||||
const updatedTaskHistory = await this.deleteTaskFromState(id)
|
||||
|
||||
// Delete the task files
|
||||
const apiConversationHistoryFileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
|
||||
if (apiConversationHistoryFileExists) {
|
||||
await fs.unlink(apiConversationHistoryFilePath)
|
||||
}
|
||||
const uiMessagesFileExists = await fileExistsAtPath(uiMessagesFilePath)
|
||||
if (uiMessagesFileExists) {
|
||||
await fs.unlink(uiMessagesFilePath)
|
||||
}
|
||||
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
|
||||
if (await fileExistsAtPath(legacyMessagesFilePath)) {
|
||||
await fs.unlink(legacyMessagesFilePath)
|
||||
for (const filePath of [
|
||||
apiConversationHistoryFilePath,
|
||||
uiMessagesFilePath,
|
||||
contextHistoryFilePath,
|
||||
taskMetadataFilePath,
|
||||
legacyMessagesFilePath,
|
||||
]) {
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (fileExists) {
|
||||
await fs.unlink(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
|
||||
|
||||
@@ -10,7 +10,7 @@ import { diagnosticsToProblemsString } from "../../integrations/diagnostics"
|
||||
import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output"
|
||||
import { getCommitInfo } from "../../utils/git"
|
||||
import { getWorkingState } from "../../utils/git"
|
||||
import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
||||
import { FileContextTracker } from "../context/context-tracking/FileContextTracker"
|
||||
|
||||
export function openMention(mention?: string): void {
|
||||
if (!mention) {
|
||||
|
||||
@@ -130,8 +130,8 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
cwd: string,
|
||||
wasRecent: boolean | 0 | undefined,
|
||||
responseText?: string,
|
||||
) => {
|
||||
return `[TASK RESUMPTION] ${
|
||||
): [string, string] => {
|
||||
const taskResumptionMessage = `[TASK RESUMPTION] ${
|
||||
mode === "plan"
|
||||
? `This task was interrupted ${agoText}. The conversation may have been incomplete. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful. However you are in PLAN MODE, so rather than continuing the task, you must respond to the user's message.`
|
||||
: `This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.`
|
||||
@@ -139,13 +139,17 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
wasRecent
|
||||
? "\n\nIMPORTANT: If the last tool use was a replace_in_file or write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
|
||||
: ""
|
||||
}${
|
||||
}`
|
||||
|
||||
const userResponseMessage = `${
|
||||
responseText
|
||||
? `\n\n${mode === "plan" ? "New message to respond to with plan_mode_respond tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
|
||||
? `${mode === "plan" ? "New message to respond to with plan_mode_respond tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
|
||||
: mode === "plan"
|
||||
? "(The user did not provide a new message. Consider asking them how they'd like you to proceed, or to switch to Act mode to continue with the task.)"
|
||||
? "(The user did not provide a new message. Consider asking them how they'd like you to proceed, or suggest to them to switch to Act mode to continue with the task.)"
|
||||
: ""
|
||||
}`
|
||||
|
||||
return [taskResumptionMessage, userResponseMessage]
|
||||
},
|
||||
|
||||
planModeInstructions: () => {
|
||||
@@ -192,7 +196,7 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file.\n\n` +
|
||||
`The file was reverted to its original state:\n\n` +
|
||||
`<file_content path="${relPath.toPosix()}">\n${originalContent}\n</file_content>\n\n` +
|
||||
`Now that you have the latest state of the file, try the operation again with fewer/more precise SEARCH blocks.\n(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback. Keep in mind, the write_to_file fallback is far from ideal, as this means you'll be re-writing the entire contents of the file just to make a few edits, which takes time and money. So let's bias towards using replace_in_file as effectively as possible)`,
|
||||
`Now that you have the latest state of the file, try the operation again with fewer, more precise SEARCH blocks. For large files especially, it may be prudent to try to limit yourself to <5 SEARCH/REPLACE blocks at a time, then wait for the user to respond with the result of the operation before following up with another replace_in_file call to make additional edits.\n(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback.)`,
|
||||
|
||||
toolAlreadyUsed: (toolName: string) =>
|
||||
`Tool [${toolName}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
|
||||
@@ -200,10 +204,13 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
clineIgnoreInstructions: (content: string) =>
|
||||
`# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${content}\n.clineignore`,
|
||||
|
||||
clineRulesDirectoryInstructions: (cwd: string, content: string) =>
|
||||
clineRulesGlobalDirectoryInstructions: (content: string) =>
|
||||
`# .clinerules/\n\nThe following is provided by a global .clinerules/ directory where the user has specified instructions:\n\n${content}`,
|
||||
|
||||
clineRulesLocalDirectoryInstructions: (cwd: string, content: string) =>
|
||||
`# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
|
||||
|
||||
clineRulesFileInstructions: (cwd: string, content: string) =>
|
||||
clineRulesLocalFileInstructions: (cwd: string, content: string) =>
|
||||
`# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
|
||||
}
|
||||
|
||||
|
||||
+42
-10
@@ -233,17 +233,27 @@ Your final result description here
|
||||
<command>Command to demonstrate result (optional)</command>
|
||||
</attempt_completion>
|
||||
|
||||
## new_task
|
||||
Description: Request to create a new task with preloaded context. The user will be presented with a preview of the context and can choose to create a new task or keep chatting in the current conversation. The user may choose to start a new task at any point.
|
||||
Parameters:
|
||||
- context: (required) The context to preload the new task with. This should include:
|
||||
* Comprehensively explain what has been accomplished in the current task - mention specific file names that are relevant
|
||||
* The specific next steps or focus for the new task - mention specific file names that are relevant
|
||||
* Any critical information needed to continue the work
|
||||
* Clear indication of how this new task relates to the overall workflow
|
||||
* This should be akin to a long handoff file, enough for a totally new developer to be able to pick up where you left off and know exactly what to do next and which files to look at.
|
||||
Usage:
|
||||
<new_task>
|
||||
<context>context to preload new task with</context>
|
||||
</new_task>
|
||||
|
||||
## plan_mode_respond
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
|
||||
Parameters:
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
|
||||
Usage:
|
||||
<plan_mode_respond>
|
||||
<response>Your response here</response>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</plan_mode_respond>
|
||||
|
||||
## load_mcp_documentation
|
||||
@@ -284,7 +294,25 @@ Usage:
|
||||
</content>
|
||||
</write_to_file>
|
||||
|
||||
## Example 3: Requesting to make targeted edits to a file
|
||||
## Example 3: Creating a new task
|
||||
|
||||
<new_task>
|
||||
<context>
|
||||
Authentication System Implementation:
|
||||
- We've implemented the basic user model with email/password
|
||||
- Password hashing is working with bcrypt
|
||||
- Login endpoint is functional with proper validation
|
||||
- JWT token generation is implemented
|
||||
|
||||
Next Steps:
|
||||
- Implement refresh token functionality
|
||||
- Add token validation middleware
|
||||
- Create password reset flow
|
||||
- Implement role-based access control
|
||||
</context>
|
||||
</new_task>
|
||||
|
||||
## Example 4: Requesting to make targeted edits to a file
|
||||
|
||||
<replace_in_file>
|
||||
<path>src/components/App.tsx</path>
|
||||
@@ -319,7 +347,7 @@ return (
|
||||
</diff>
|
||||
</replace_in_file>
|
||||
|
||||
## Example 4: Requesting to use an MCP tool
|
||||
## Example 5: Requesting to use an MCP tool
|
||||
|
||||
<use_mcp_tool>
|
||||
<server_name>weather-server</server_name>
|
||||
@@ -332,7 +360,7 @@ return (
|
||||
</arguments>
|
||||
</use_mcp_tool>
|
||||
|
||||
## Example 5: Another example of using an MCP tool (where the server name is a unique identifier such as a URL)
|
||||
## Example 6: Another example of using an MCP tool (where the server name is a unique identifier such as a URL)
|
||||
|
||||
<use_mcp_tool>
|
||||
<server_name>github.com/modelcontextprotocol/servers/tree/main/src/github</server_name>
|
||||
@@ -591,7 +619,8 @@ You accomplish a given task iteratively, breaking it down into clear steps and w
|
||||
|
||||
export function addUserInstructions(
|
||||
settingsCustomInstructions?: string,
|
||||
clineRulesFileInstructions?: string,
|
||||
globalClineRulesFileInstructions?: string,
|
||||
localClineRulesFileInstructions?: string,
|
||||
clineIgnoreInstructions?: string,
|
||||
preferredLanguageInstructions?: string,
|
||||
) {
|
||||
@@ -602,8 +631,11 @@ export function addUserInstructions(
|
||||
if (settingsCustomInstructions) {
|
||||
customInstructions += settingsCustomInstructions + "\n\n"
|
||||
}
|
||||
if (clineRulesFileInstructions) {
|
||||
customInstructions += clineRulesFileInstructions + "\n\n"
|
||||
if (globalClineRulesFileInstructions) {
|
||||
customInstructions += globalClineRulesFileInstructions + "\n\n"
|
||||
}
|
||||
if (localClineRulesFileInstructions) {
|
||||
customInstructions += localClineRulesFileInstructions + "\n\n"
|
||||
}
|
||||
if (clineIgnoreInstructions) {
|
||||
customInstructions += clineIgnoreInstructions
|
||||
|
||||
+68
-14
@@ -4,19 +4,9 @@ import fs from "fs/promises"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
export interface FileMetadataEntry {
|
||||
path: string
|
||||
record_state: "active" | "stale"
|
||||
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
|
||||
cline_read_date: number | null
|
||||
cline_edit_date: number | null
|
||||
user_edit_date?: number | null
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
files_in_context: FileMetadataEntry[]
|
||||
}
|
||||
import { TaskMetadata } from "../context/context-tracking/ContextTrackerTypes"
|
||||
import os from "os"
|
||||
import { execa } from "execa"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
@@ -28,6 +18,42 @@ export const GlobalFileNames = {
|
||||
taskMetadata: "task_metadata.json",
|
||||
}
|
||||
|
||||
export async function getDocumentsPath(): Promise<string> {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
const { stdout: docsPath } = await execa("powershell", [
|
||||
"-NoProfile", // Ignore user's PowerShell profile(s)
|
||||
"-Command",
|
||||
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
|
||||
])
|
||||
const trimmedPath = docsPath.trim()
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
|
||||
}
|
||||
} else if (process.platform === "linux") {
|
||||
try {
|
||||
// First check if xdg-user-dir exists
|
||||
await execa("which", ["xdg-user-dir"])
|
||||
|
||||
// If it exists, try to get XDG documents path
|
||||
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
|
||||
const trimmedPath = stdout.trim()
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
}
|
||||
} catch {
|
||||
// Log error but continue to fallback
|
||||
console.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback for all platforms
|
||||
return path.join(os.homedir(), "Documents")
|
||||
}
|
||||
|
||||
export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext, taskId: string): Promise<string> {
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const taskDir = path.join(globalStoragePath, "tasks", taskId)
|
||||
@@ -35,6 +61,34 @@ export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext
|
||||
return taskDir
|
||||
}
|
||||
|
||||
export async function ensureRulesDirectoryExists(): Promise<string> {
|
||||
const userDocumentsPath = await getDocumentsPath()
|
||||
const clineRulesDir = path.join(userDocumentsPath, "Cline", "Rules")
|
||||
try {
|
||||
await fs.mkdir(clineRulesDir, { recursive: true })
|
||||
} catch (error) {
|
||||
return path.join(os.homedir(), "Documents", "Cline", "Rules") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
|
||||
}
|
||||
return clineRulesDir
|
||||
}
|
||||
|
||||
export async function ensureMcpServersDirectoryExists(): Promise<string> {
|
||||
const userDocumentsPath = await getDocumentsPath()
|
||||
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP")
|
||||
try {
|
||||
await fs.mkdir(mcpServersDir, { recursive: true })
|
||||
} catch (error) {
|
||||
return "~/Documents/Cline/MCP" // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
|
||||
}
|
||||
return mcpServersDir
|
||||
}
|
||||
|
||||
export async function ensureSettingsDirectoryExists(context: vscode.ExtensionContext): Promise<string> {
|
||||
const settingsDir = path.join(context.globalStorageUri.fsPath, "settings")
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
return settingsDir
|
||||
}
|
||||
|
||||
export async function getSavedApiConversationHistory(
|
||||
context: vscode.ExtensionContext,
|
||||
taskId: string,
|
||||
@@ -96,7 +150,7 @@ export async function getTaskMetadata(context: vscode.ExtensionContext, taskId:
|
||||
} catch (error) {
|
||||
console.error("Failed to read task metadata:", error)
|
||||
}
|
||||
return { files_in_context: [] }
|
||||
return { files_in_context: [], model_usage: [] }
|
||||
}
|
||||
|
||||
export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId: string, metadata: TaskMetadata) {
|
||||
|
||||
@@ -54,6 +54,7 @@ export type GlobalStateKey =
|
||||
| "previousModeApiProvider"
|
||||
| "previousModeModelId"
|
||||
| "previousModeThinkingBudgetTokens"
|
||||
| "previousModeReasoningEffort"
|
||||
| "previousModeVsCodeLmModelSelector"
|
||||
| "previousModeModelInfo"
|
||||
| "liteLlmBaseUrl"
|
||||
@@ -61,9 +62,12 @@ export type GlobalStateKey =
|
||||
| "liteLlmUsePromptCache"
|
||||
| "qwenApiLine"
|
||||
| "requestyModelId"
|
||||
| "requestyModelInfo"
|
||||
| "togetherModelId"
|
||||
| "mcpMarketplaceCatalog"
|
||||
| "telemetrySetting"
|
||||
| "asksageApiUrl"
|
||||
| "thinkingBudgetTokens"
|
||||
| "reasoningEffort"
|
||||
| "planActSeparateModelsSetting"
|
||||
| "favoritedModelIds"
|
||||
|
||||
@@ -83,6 +83,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
requestyModelId,
|
||||
requestyModelInfo,
|
||||
togetherApiKey,
|
||||
togetherModelId,
|
||||
qwenApiKey,
|
||||
@@ -108,6 +109,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
previousModeModelInfo,
|
||||
previousModeVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens,
|
||||
previousModeReasoningEffort,
|
||||
qwenApiLine,
|
||||
liteLlmApiKey,
|
||||
telemetrySetting,
|
||||
@@ -115,8 +117,10 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
thinkingBudgetTokens,
|
||||
reasoningEffort,
|
||||
sambanovaApiKey,
|
||||
planActSeparateModelsSettingRaw,
|
||||
favoritedModelIds,
|
||||
] = await Promise.all([
|
||||
getGlobalState(context, "apiProvider") as Promise<ApiProvider | undefined>,
|
||||
getGlobalState(context, "apiModelId") as Promise<string | undefined>,
|
||||
@@ -149,6 +153,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getSecret(context, "deepSeekApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "requestyApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "requestyModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "requestyModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getSecret(context, "togetherApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "togetherModelId") as Promise<string | undefined>,
|
||||
getSecret(context, "qwenApiKey") as Promise<string | undefined>,
|
||||
@@ -174,6 +179,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getGlobalState(context, "previousModeModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "previousModeVsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
|
||||
getGlobalState(context, "previousModeThinkingBudgetTokens") as Promise<number | undefined>,
|
||||
getGlobalState(context, "previousModeReasoningEffort") as Promise<string | undefined>,
|
||||
getGlobalState(context, "qwenApiLine") as Promise<string | undefined>,
|
||||
getSecret(context, "liteLlmApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "telemetrySetting") as Promise<TelemetrySetting | undefined>,
|
||||
@@ -181,8 +187,10 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
getGlobalState(context, "asksageApiUrl") as Promise<string | undefined>,
|
||||
getSecret(context, "xaiApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "thinkingBudgetTokens") as Promise<number | undefined>,
|
||||
getGlobalState(context, "reasoningEffort") as Promise<string | undefined>,
|
||||
getSecret(context, "sambanovaApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "planActSeparateModelsSetting") as Promise<boolean | undefined>,
|
||||
getGlobalState(context, "favoritedModelIds") as Promise<string[] | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
@@ -254,6 +262,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
requestyModelId,
|
||||
requestyModelInfo,
|
||||
togetherApiKey,
|
||||
togetherModelId,
|
||||
qwenApiKey,
|
||||
@@ -267,6 +276,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
vsCodeLmModelSelector,
|
||||
o3MiniReasoningEffort,
|
||||
thinkingBudgetTokens,
|
||||
reasoningEffort,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
liteLlmApiKey,
|
||||
@@ -275,6 +285,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
sambanovaApiKey,
|
||||
favoritedModelIds,
|
||||
},
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
@@ -288,6 +299,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
previousModeModelInfo,
|
||||
previousModeVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens,
|
||||
previousModeReasoningEffort,
|
||||
mcpMarketplaceEnabled,
|
||||
telemetrySetting: telemetrySetting || "unset",
|
||||
planActSeparateModelsSetting,
|
||||
@@ -326,6 +338,7 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
requestyModelId,
|
||||
requestyModelInfo,
|
||||
togetherApiKey,
|
||||
togetherModelId,
|
||||
qwenApiKey,
|
||||
@@ -345,8 +358,10 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
thinkingBudgetTokens,
|
||||
reasoningEffort,
|
||||
clineApiKey,
|
||||
sambanovaApiKey,
|
||||
favoritedModelIds,
|
||||
} = apiConfiguration
|
||||
await updateGlobalState(context, "apiProvider", apiProvider)
|
||||
await updateGlobalState(context, "apiModelId", apiModelId)
|
||||
@@ -393,12 +408,15 @@ export async function updateApiConfiguration(context: vscode.ExtensionContext, a
|
||||
await updateGlobalState(context, "liteLlmUsePromptCache", liteLlmUsePromptCache)
|
||||
await updateGlobalState(context, "qwenApiLine", qwenApiLine)
|
||||
await updateGlobalState(context, "requestyModelId", requestyModelId)
|
||||
await updateGlobalState(context, "requestyModelInfo", requestyModelInfo)
|
||||
await updateGlobalState(context, "togetherModelId", togetherModelId)
|
||||
await storeSecret(context, "asksageApiKey", asksageApiKey)
|
||||
await updateGlobalState(context, "asksageApiUrl", asksageApiUrl)
|
||||
await updateGlobalState(context, "thinkingBudgetTokens", thinkingBudgetTokens)
|
||||
await updateGlobalState(context, "reasoningEffort", reasoningEffort)
|
||||
await storeSecret(context, "clineApiKey", clineApiKey)
|
||||
await storeSecret(context, "sambanovaApiKey", sambanovaApiKey)
|
||||
await updateGlobalState(context, "favoritedModelIds", favoritedModelIds)
|
||||
}
|
||||
|
||||
export async function resetExtensionState(context: vscode.ExtensionContext) {
|
||||
|
||||
+273
-155
@@ -1,6 +1,5 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import cloneDeep from "clone-deep"
|
||||
import fs from "fs/promises"
|
||||
import getFolderSize from "get-folder-size"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import os from "os"
|
||||
@@ -48,37 +47,43 @@ import {
|
||||
ClineSayBrowserAction,
|
||||
ClineSayTool,
|
||||
COMPLETION_RESULT_CHANGES_FLAG,
|
||||
ExtensionMessage,
|
||||
} from "../../shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "../../shared/getApiMetrics"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay } from "../../shared/Languages"
|
||||
import { ClineAskResponse, ClineCheckpointRestore } from "../../shared/WebviewMessage"
|
||||
import { calculateApiCostAnthropic } from "../../utils/cost"
|
||||
import { fileExistsAtPath, isDirectory } from "../../utils/fs"
|
||||
import { arePathsEqual, getReadablePath } from "../../utils/path"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual, getReadablePath, isLocatedInWorkspace } from "../../utils/path"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "../../utils/string"
|
||||
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from ".././assistant-message"
|
||||
import { constructNewFileContent } from ".././assistant-message/diff"
|
||||
import { ContextManager } from ".././context-management/ContextManager"
|
||||
import { ClineIgnoreController } from ".././ignore/ClineIgnoreController"
|
||||
import { parseMentions } from ".././mentions"
|
||||
import { formatResponse } from ".././prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from ".././prompts/system"
|
||||
import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
||||
import { getContextWindowInfo } from "../context/context-management/context-window-utils"
|
||||
import { FileContextTracker } from "../context/context-tracking/FileContextTracker"
|
||||
import { ModelContextTracker } from "../context/context-tracking/ModelContextTracker"
|
||||
import {
|
||||
checkIsAnthropicContextWindowError,
|
||||
checkIsOpenRouterContextWindowError,
|
||||
} from "../context-management/context-error-handling"
|
||||
import { Controller } from "../controller"
|
||||
} from "../context/context-management/context-error-handling"
|
||||
import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { ContextManager } from "../context/context-management/ContextManager"
|
||||
import { loadMcpDocumentation } from "../prompts/loadMcpDocumentation"
|
||||
import {
|
||||
ensureRulesDirectoryExists,
|
||||
ensureTaskDirectoryExists,
|
||||
getSavedApiConversationHistory,
|
||||
getSavedClineMessages,
|
||||
saveApiConversationHistory,
|
||||
saveClineMessages,
|
||||
GlobalFileNames,
|
||||
} from "../storage/disk"
|
||||
import { loadMcpDocumentation } from "../prompts/loadMcpDocumentation"
|
||||
import { getGlobalClineRules, getLocalClineRules } from "../context/instructions/user-instructions/cline-rules"
|
||||
import { getGlobalState } from "../storage/state"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
|
||||
|
||||
@@ -86,8 +91,17 @@ type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlo
|
||||
type UserContent = Array<Anthropic.ContentBlockParam>
|
||||
|
||||
export class Task {
|
||||
// dependencies
|
||||
private context: vscode.ExtensionContext
|
||||
private mcpHub: McpHub
|
||||
private workspaceTracker: WorkspaceTracker
|
||||
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
private postStateToWebview: () => Promise<void>
|
||||
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
|
||||
private reinitExistingTaskFromId: (taskId: string) => Promise<void>
|
||||
private cancelTask: () => Promise<void>
|
||||
|
||||
readonly taskId: string
|
||||
readonly apiProvider?: string
|
||||
api: ApiHandler
|
||||
private terminalManager: TerminalManager
|
||||
private urlContentFetcher: UrlContentFetcher
|
||||
@@ -107,7 +121,6 @@ export class Task {
|
||||
private lastMessageTs?: number
|
||||
private consecutiveAutoApprovedRequestsCount: number = 0
|
||||
private consecutiveMistakeCount: number = 0
|
||||
private controllerRef: WeakRef<Controller>
|
||||
private abort: boolean = false
|
||||
didFinishAbortingStream = false
|
||||
abandoned = false
|
||||
@@ -119,8 +132,9 @@ export class Task {
|
||||
isAwaitingPlanResponse = false
|
||||
didRespondToPlanAskBySwitchingMode = false
|
||||
|
||||
// File tracking
|
||||
// Metadata tracking
|
||||
private fileContextTracker: FileContextTracker
|
||||
private modelContextTracker: ModelContextTracker
|
||||
|
||||
// streaming
|
||||
isWaitingForFirstChunk = false
|
||||
@@ -137,7 +151,14 @@ export class Task {
|
||||
private didAutomaticallyRetryFailedApiRequest = false
|
||||
|
||||
constructor(
|
||||
controller: Controller,
|
||||
context: vscode.ExtensionContext,
|
||||
mcpHub: McpHub,
|
||||
workspaceTracker: WorkspaceTracker,
|
||||
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>,
|
||||
postStateToWebview: () => Promise<void>,
|
||||
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
|
||||
reinitExistingTaskFromId: (taskId: string) => Promise<void>,
|
||||
cancelTask: () => Promise<void>,
|
||||
apiConfiguration: ApiConfiguration,
|
||||
autoApprovalSettings: AutoApprovalSettings,
|
||||
browserSettings: BrowserSettings,
|
||||
@@ -147,15 +168,21 @@ export class Task {
|
||||
images?: string[],
|
||||
historyItem?: HistoryItem,
|
||||
) {
|
||||
this.context = context
|
||||
this.mcpHub = mcpHub
|
||||
this.workspaceTracker = workspaceTracker
|
||||
this.updateTaskHistory = updateTaskHistory
|
||||
this.postStateToWebview = postStateToWebview
|
||||
this.postMessageToWebview = postMessageToWebview
|
||||
this.reinitExistingTaskFromId = reinitExistingTaskFromId
|
||||
this.cancelTask = cancelTask
|
||||
this.clineIgnoreController = new ClineIgnoreController(cwd)
|
||||
this.clineIgnoreController.initialize().catch((error) => {
|
||||
console.error("Failed to initialize ClineIgnoreController:", error)
|
||||
})
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
this.apiProvider = apiConfiguration.apiProvider
|
||||
this.terminalManager = new TerminalManager()
|
||||
this.urlContentFetcher = new UrlContentFetcher(controller.context)
|
||||
this.browserSession = new BrowserSession(controller.context, browserSettings)
|
||||
this.urlContentFetcher = new UrlContentFetcher(context)
|
||||
this.browserSession = new BrowserSession(context, browserSettings)
|
||||
this.contextManager = new ContextManager()
|
||||
this.diffViewProvider = new DiffViewProvider(cwd)
|
||||
this.customInstructions = customInstructions
|
||||
@@ -174,8 +201,8 @@ export class Task {
|
||||
}
|
||||
|
||||
// Initialize file context tracker
|
||||
this.fileContextTracker = new FileContextTracker(controller, this.taskId)
|
||||
|
||||
this.fileContextTracker = new FileContextTracker(context, this.taskId)
|
||||
this.modelContextTracker = new ModelContextTracker(context, this.taskId)
|
||||
// Now that taskId is initialized, we can build the API handler
|
||||
this.api = buildApiHandler({
|
||||
...apiConfiguration,
|
||||
@@ -192,19 +219,20 @@ export class Task {
|
||||
this.startTask(task, images)
|
||||
}
|
||||
|
||||
// initialize telemetry
|
||||
if (historyItem) {
|
||||
// Open task from history
|
||||
telemetryService.captureTaskRestarted(this.taskId, this.apiProvider)
|
||||
telemetryService.captureTaskRestarted(this.taskId, apiConfiguration.apiProvider)
|
||||
} else {
|
||||
// New task started
|
||||
telemetryService.captureTaskCreated(this.taskId, this.apiProvider)
|
||||
telemetryService.captureTaskCreated(this.taskId, apiConfiguration.apiProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// While a task is ref'd by a controller, it will always have access to the extension context
|
||||
// This error is thrown if the controller derefs the task after e.g., aborting the task
|
||||
private getContext(): vscode.ExtensionContext {
|
||||
const context = this.controllerRef.deref()?.context
|
||||
const context = this.context
|
||||
if (!context) {
|
||||
throw new Error("Unable to access extension context")
|
||||
}
|
||||
@@ -256,7 +284,7 @@ export class Task {
|
||||
} catch (error) {
|
||||
console.error("Failed to get task directory size:", taskDir, error)
|
||||
}
|
||||
await this.controllerRef.deref()?.updateTaskHistory({
|
||||
await this.updateTaskHistory({
|
||||
id: this.taskId,
|
||||
ts: lastRelevantMessage.ts,
|
||||
task: taskMessage.text ?? "",
|
||||
@@ -291,15 +319,12 @@ export class Task {
|
||||
case "workspace":
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controllerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
this.checkpointTrackerErrorMessage = errorMessage
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
didWorkspaceRestoreFail = true
|
||||
}
|
||||
@@ -381,17 +406,17 @@ export class Task {
|
||||
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
|
||||
await this.controllerRef.deref()?.postMessageToWebview({ type: "relinquishControl" })
|
||||
await this.postMessageToWebview({ type: "relinquishControl" })
|
||||
|
||||
this.controllerRef.deref()?.cancelTask() // the task is already cancelled by the provider beforehand, but we need to re-init to get the updated messages
|
||||
this.cancelTask() // the task is already cancelled by the provider beforehand, but we need to re-init to get the updated messages
|
||||
} else {
|
||||
await this.controllerRef.deref()?.postMessageToWebview({ type: "relinquishControl" })
|
||||
await this.postMessageToWebview({ type: "relinquishControl" })
|
||||
}
|
||||
}
|
||||
|
||||
async presentMultifileDiff(messageTs: number, seeNewChangesSinceLastTaskCompletion: boolean) {
|
||||
const relinquishButton = () => {
|
||||
this.controllerRef.deref()?.postMessageToWebview({ type: "relinquishControl" })
|
||||
this.postMessageToWebview({ type: "relinquishControl" })
|
||||
}
|
||||
|
||||
console.log("presentMultifileDiff", messageTs)
|
||||
@@ -412,15 +437,12 @@ export class Task {
|
||||
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controllerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
this.checkpointTrackerErrorMessage = errorMessage
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
vscode.window.showErrorMessage(errorMessage)
|
||||
relinquishButton()
|
||||
return
|
||||
@@ -527,10 +549,7 @@ export class Task {
|
||||
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.controllerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -601,8 +620,8 @@ export class Task {
|
||||
lastMessage.partial = partial
|
||||
// todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener
|
||||
// await this.saveClineMessagesAndUpdateHistory()
|
||||
// await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
// await this.postStateToWebview()
|
||||
await this.postMessageToWebview({
|
||||
type: "partialMessage",
|
||||
partialMessage: lastMessage,
|
||||
})
|
||||
@@ -621,7 +640,7 @@ export class Task {
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
throw new Error("Current ask promise was ignored 2")
|
||||
}
|
||||
} else {
|
||||
@@ -644,8 +663,8 @@ export class Task {
|
||||
lastMessage.text = text
|
||||
lastMessage.partial = false
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
// await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
// await this.postStateToWebview()
|
||||
await this.postMessageToWebview({
|
||||
type: "partialMessage",
|
||||
partialMessage: lastMessage,
|
||||
})
|
||||
@@ -662,7 +681,7 @@ export class Task {
|
||||
ask: type,
|
||||
text,
|
||||
})
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -679,7 +698,7 @@ export class Task {
|
||||
ask: type,
|
||||
text,
|
||||
})
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
|
||||
@@ -718,7 +737,7 @@ export class Task {
|
||||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
lastMessage.partial = partial
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
await this.postMessageToWebview({
|
||||
type: "partialMessage",
|
||||
partialMessage: lastMessage,
|
||||
})
|
||||
@@ -734,7 +753,7 @@ export class Task {
|
||||
images,
|
||||
partial,
|
||||
})
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
} else {
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
@@ -748,8 +767,8 @@ export class Task {
|
||||
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
// await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
// await this.postStateToWebview()
|
||||
await this.postMessageToWebview({
|
||||
type: "partialMessage",
|
||||
partialMessage: lastMessage,
|
||||
}) // more performant than an entire postStateToWebview
|
||||
@@ -764,7 +783,7 @@ export class Task {
|
||||
text,
|
||||
images,
|
||||
})
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -778,7 +797,7 @@ export class Task {
|
||||
text,
|
||||
images,
|
||||
})
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,7 +816,7 @@ export class Task {
|
||||
if (lastMessage?.partial && lastMessage.type === type && (lastMessage.ask === askOrSay || lastMessage.say === askOrSay)) {
|
||||
this.clineMessages.pop()
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -809,7 +828,7 @@ export class Task {
|
||||
this.clineMessages = []
|
||||
this.apiConversationHistory = []
|
||||
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
|
||||
await this.say("text", task, images)
|
||||
|
||||
@@ -942,16 +961,27 @@ export class Task {
|
||||
|
||||
const wasRecent = lastClineMessage?.ts && Date.now() - lastClineMessage.ts < 30_000
|
||||
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: formatResponse.taskResumption(
|
||||
this.chatSettings?.mode === "plan" ? "plan" : "act",
|
||||
agoText,
|
||||
cwd,
|
||||
wasRecent,
|
||||
responseText,
|
||||
),
|
||||
})
|
||||
const [taskResumptionMessage, userResponseMessage] = formatResponse.taskResumption(
|
||||
this.chatSettings?.mode === "plan" ? "plan" : "act",
|
||||
agoText,
|
||||
cwd,
|
||||
wasRecent,
|
||||
responseText,
|
||||
)
|
||||
|
||||
if (taskResumptionMessage !== "") {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: taskResumptionMessage,
|
||||
})
|
||||
}
|
||||
|
||||
if (userResponseMessage !== "") {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
text: userResponseMessage,
|
||||
})
|
||||
}
|
||||
|
||||
if (responseImages && responseImages.length > 0) {
|
||||
newUserContent.push(...formatResponse.imageBlocks(responseImages))
|
||||
@@ -1013,6 +1043,12 @@ export class Task {
|
||||
})
|
||||
|
||||
if (!isAttemptCompletionMessage) {
|
||||
// ensure we aren't creating a duplicate checkpoint
|
||||
const lastMessage = this.clineMessages.at(-1)
|
||||
if (lastMessage?.say === "checkpoint_created") {
|
||||
return
|
||||
}
|
||||
|
||||
// For non-attempt completion we just say checkpoints
|
||||
await this.say("checkpoint_created")
|
||||
this.checkpointTracker?.commit().then(async (commitHash) => {
|
||||
@@ -1155,7 +1191,7 @@ export class Task {
|
||||
}
|
||||
|
||||
// Check if the tool should be auto-approved based on the settings
|
||||
// Returns bool for most tools, tuple for execute_command (and future nested auto appoved settings)
|
||||
// Returns bool for most tools, and tuple for tools with nested settings
|
||||
shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
|
||||
if (this.autoApprovalSettings.enabled) {
|
||||
switch (toolName) {
|
||||
@@ -1163,14 +1199,20 @@ export class Task {
|
||||
case "list_files":
|
||||
case "list_code_definition_names":
|
||||
case "search_files":
|
||||
return this.autoApprovalSettings.actions.readFiles
|
||||
return [
|
||||
this.autoApprovalSettings.actions.readFiles,
|
||||
this.autoApprovalSettings.actions.readFilesExternally ?? false,
|
||||
]
|
||||
case "write_to_file":
|
||||
case "replace_in_file":
|
||||
return this.autoApprovalSettings.actions.editFiles
|
||||
return [
|
||||
this.autoApprovalSettings.actions.editFiles,
|
||||
this.autoApprovalSettings.actions.editFilesExternally ?? false,
|
||||
]
|
||||
case "execute_command":
|
||||
return [
|
||||
this.autoApprovalSettings.actions.executeSafeCommands,
|
||||
this.autoApprovalSettings.actions.executeAllCommands,
|
||||
this.autoApprovalSettings.actions.executeSafeCommands ?? false,
|
||||
this.autoApprovalSettings.actions.executeAllCommands ?? false,
|
||||
]
|
||||
case "browser_action":
|
||||
return this.autoApprovalSettings.actions.useBrowser
|
||||
@@ -1182,6 +1224,32 @@ export class Task {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the tool should be auto-approved based on the settings
|
||||
// and the path of the action. Returns true if the tool should be auto-approved
|
||||
// based on the user's settings and the path of the action.
|
||||
shouldAutoApproveToolWithPath(blockname: ToolUseName, autoApproveActionpath: string | undefined): boolean {
|
||||
let isLocalRead: boolean = false
|
||||
if (autoApproveActionpath) {
|
||||
const absolutePath = path.resolve(cwd, autoApproveActionpath)
|
||||
isLocalRead = absolutePath.startsWith(cwd)
|
||||
} else {
|
||||
// If we do not get a path for some reason, default to a (safer) false return
|
||||
isLocalRead = false
|
||||
}
|
||||
|
||||
// Get auto-approve settings for local and external edits
|
||||
const autoApproveResult = this.shouldAutoApproveTool(blockname)
|
||||
const [autoApproveLocal, autoApproveExternal] = Array.isArray(autoApproveResult)
|
||||
? autoApproveResult
|
||||
: [autoApproveResult, false]
|
||||
|
||||
if ((isLocalRead && autoApproveLocal) || (!isLocalRead && autoApproveLocal && autoApproveExternal)) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private formatErrorWithStatusCode(error: any): string {
|
||||
const statusCode = error.status || error.statusCode || (error.response && error.response.status)
|
||||
const message = error.message ?? JSON.stringify(serializeError(error), null, 2)
|
||||
@@ -1192,21 +1260,16 @@ export class Task {
|
||||
|
||||
async *attemptApiRequest(previousApiReqIndex: number): ApiStream {
|
||||
// Wait for MCP servers to be connected before generating system prompt
|
||||
await pWaitFor(() => this.controllerRef.deref()?.mcpHub?.isConnecting !== true, { timeout: 10_000 }).catch(() => {
|
||||
await pWaitFor(() => this.mcpHub.isConnecting !== true, { timeout: 10_000 }).catch(() => {
|
||||
console.error("MCP servers failed to connect in time")
|
||||
})
|
||||
|
||||
const mcpHub = this.controllerRef.deref()?.mcpHub
|
||||
if (!mcpHub) {
|
||||
throw new Error("MCP hub not available")
|
||||
}
|
||||
|
||||
const disableBrowserTool = vscode.workspace.getConfiguration("cline").get<boolean>("disableBrowserTool") ?? false
|
||||
const modelSupportsComputerUse = this.api.getModel().info.supportsComputerUse ?? false
|
||||
|
||||
const supportsComputerUse = modelSupportsComputerUse && !disableBrowserTool // only enable computer use if the model supports it and the user hasn't disabled it
|
||||
|
||||
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsComputerUse, mcpHub, this.browserSettings)
|
||||
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsComputerUse, this.mcpHub, this.browserSettings)
|
||||
|
||||
let settingsCustomInstructions = this.customInstructions?.trim()
|
||||
const preferredLanguage = getLanguageKey(
|
||||
@@ -1216,38 +1279,11 @@ export class Task {
|
||||
preferredLanguage && preferredLanguage !== DEFAULT_LANGUAGE_SETTINGS
|
||||
? `# Preferred Language\n\nSpeak in ${preferredLanguage}.`
|
||||
: ""
|
||||
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
|
||||
let clineRulesFileInstructions: string | undefined
|
||||
if (await fileExistsAtPath(clineRulesFilePath)) {
|
||||
if (await isDirectory(clineRulesFilePath)) {
|
||||
try {
|
||||
// Read all files in the .clinerules/ directory.
|
||||
const ruleFiles = await fs
|
||||
.readdir(clineRulesFilePath, { withFileTypes: true, recursive: true })
|
||||
.then((files) => files.filter((file) => file.isFile()))
|
||||
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
|
||||
const ruleFilesTotalContent = await Promise.all(
|
||||
ruleFiles.map(async (file) => {
|
||||
const ruleFilePath = path.resolve(clineRulesFilePath, file)
|
||||
const ruleFilePathRelative = path.relative(cwd, ruleFilePath)
|
||||
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
|
||||
}),
|
||||
).then((contents) => contents.join("\n\n"))
|
||||
clineRulesFileInstructions = formatResponse.clineRulesDirectoryInstructions(cwd, ruleFilesTotalContent)
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (ruleFileContent) {
|
||||
clineRulesFileInstructions = formatResponse.clineRulesFileInstructions(cwd, ruleFileContent)
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const localClineRulesFileInstructions = await getLocalClineRules(cwd)
|
||||
|
||||
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
|
||||
const globalClineRulesFileInstructions = await getGlobalClineRules(globalClineRulesFilePath)
|
||||
|
||||
const clineIgnoreContent = this.clineIgnoreController.clineIgnoreContent
|
||||
let clineIgnoreInstructions: string | undefined
|
||||
@@ -1257,14 +1293,16 @@ export class Task {
|
||||
|
||||
if (
|
||||
settingsCustomInstructions ||
|
||||
clineRulesFileInstructions ||
|
||||
globalClineRulesFileInstructions ||
|
||||
localClineRulesFileInstructions ||
|
||||
clineIgnoreInstructions ||
|
||||
preferredLanguageInstructions
|
||||
) {
|
||||
// altering the system prompt mid-task will break the prompt cache, but in the grand scheme this will not change often so it's better to not pollute user messages with it the way we have to with <potentially relevant details>
|
||||
systemPrompt += addUserInstructions(
|
||||
settingsCustomInstructions,
|
||||
clineRulesFileInstructions,
|
||||
globalClineRulesFileInstructions,
|
||||
localClineRulesFileInstructions,
|
||||
clineIgnoreInstructions,
|
||||
preferredLanguageInstructions,
|
||||
)
|
||||
@@ -1475,6 +1513,8 @@ export class Task {
|
||||
return `[${block.name}]`
|
||||
case "attempt_completion":
|
||||
return `[${block.name}]`
|
||||
case "new_task":
|
||||
return `[${block.name} for creating a new task]`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1715,12 +1755,14 @@ export class Task {
|
||||
tool: fileExists ? "editedExistingFile" : "newFileCreated",
|
||||
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
|
||||
content: diff || content,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
}
|
||||
|
||||
if (block.partial) {
|
||||
// update gui message
|
||||
const partialMessage = JSON.stringify(sharedMessageProps)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, relPath)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool") // in case the user changes auto-approval settings mid stream
|
||||
await this.say("tool", partialMessage, undefined, block.partial)
|
||||
} else {
|
||||
@@ -1777,6 +1819,7 @@ export class Task {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: diff || content,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
// ? formatResponse.createPrettyPatch(
|
||||
// relPath,
|
||||
// this.diffViewProvider.originalContent,
|
||||
@@ -1784,8 +1827,7 @@ export class Task {
|
||||
// )
|
||||
// : undefined,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, relPath)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
@@ -1875,7 +1917,7 @@ export class Task {
|
||||
}
|
||||
|
||||
if (!fileExists) {
|
||||
this.controllerRef.deref()?.workspaceTracker?.populateFilePaths()
|
||||
this.workspaceTracker.populateFilePaths()
|
||||
}
|
||||
|
||||
await this.diffViewProvider.reset()
|
||||
@@ -1903,8 +1945,9 @@ export class Task {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: undefined,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", partialMessage, undefined, block.partial)
|
||||
} else {
|
||||
@@ -1933,8 +1976,9 @@ export class Task {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: absolutePath,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false) // need to be sending partialValue bool, since undefined has its own purpose in that the message is treated neither as a partial or completion of a partial, but as a single complete message
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
@@ -1980,8 +2024,9 @@ export class Task {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", partialMessage, undefined, block.partial)
|
||||
} else {
|
||||
@@ -2011,8 +2056,9 @@ export class Task {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: result,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
@@ -2050,8 +2096,9 @@ export class Task {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", partialMessage, undefined, block.partial)
|
||||
} else {
|
||||
@@ -2078,8 +2125,9 @@ export class Task {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: result,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
@@ -2121,8 +2169,9 @@ export class Task {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", partialMessage, undefined, block.partial)
|
||||
} else {
|
||||
@@ -2157,8 +2206,9 @@ export class Task {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: results,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveTool(block.name)) {
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
@@ -2265,10 +2315,9 @@ export class Task {
|
||||
await this.say("browser_action_result", "") // starts loading spinner
|
||||
|
||||
// Re-make browserSession to make sure latest settings apply
|
||||
const localContext = this.controllerRef.deref()?.context
|
||||
if (localContext) {
|
||||
if (this.context) {
|
||||
await this.browserSession.dispose()
|
||||
this.browserSession = new BrowserSession(localContext, this.browserSettings)
|
||||
this.browserSession = new BrowserSession(this.context, this.browserSettings)
|
||||
} else {
|
||||
console.warn("no controller context available for browserSession")
|
||||
}
|
||||
@@ -2466,7 +2515,7 @@ export class Task {
|
||||
}
|
||||
|
||||
// Re-populate file paths in case the command modified the workspace (vscode listeners do not trigger unless the user manually creates/deletes files)
|
||||
this.controllerRef.deref()?.workspaceTracker?.populateFilePaths()
|
||||
this.workspaceTracker.populateFilePaths()
|
||||
|
||||
pushToolResult(result)
|
||||
|
||||
@@ -2548,9 +2597,8 @@ export class Task {
|
||||
arguments: mcp_arguments,
|
||||
} satisfies ClineAskUseMcpServer)
|
||||
|
||||
const isToolAutoApproved = this.controllerRef
|
||||
.deref()
|
||||
?.mcpHub?.connections?.find((conn) => conn.server.name === server_name)
|
||||
const isToolAutoApproved = this.mcpHub.connections
|
||||
?.find((conn) => conn.server.name === server_name)
|
||||
?.server.tools?.find((tool) => tool.name === tool_name)?.autoApprove
|
||||
|
||||
if (this.shouldAutoApproveTool(block.name) && isToolAutoApproved) {
|
||||
@@ -2570,9 +2618,7 @@ export class Task {
|
||||
|
||||
// now execute the tool
|
||||
await this.say("mcp_server_request_started") // same as browser_action_result
|
||||
const toolResult = await this.controllerRef
|
||||
.deref()
|
||||
?.mcpHub?.callTool(server_name, tool_name, parsedArguments)
|
||||
const toolResult = await this.mcpHub.callTool(server_name, tool_name, parsedArguments)
|
||||
|
||||
// TODO: add progress indicator and ability to parse images and non-text responses
|
||||
const toolResultPretty =
|
||||
@@ -2660,7 +2706,7 @@ export class Task {
|
||||
|
||||
// now execute the tool
|
||||
await this.say("mcp_server_request_started")
|
||||
const resourceResult = await this.controllerRef.deref()?.mcpHub?.readResource(server_name, uri)
|
||||
const resourceResult = await this.mcpHub.readResource(server_name, uri)
|
||||
const resourceResultPretty =
|
||||
resourceResult?.contents
|
||||
.map((item) => {
|
||||
@@ -2743,6 +2789,51 @@ export class Task {
|
||||
break
|
||||
}
|
||||
}
|
||||
case "new_task": {
|
||||
const context: string | undefined = block.params.context
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("new_task", removeClosingTag("context", context), block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!context) {
|
||||
this.consecutiveMistakeCount++
|
||||
pushToolResult(await this.sayAndCreateMissingParamError("new_task", "context"))
|
||||
break
|
||||
}
|
||||
this.consecutiveMistakeCount = 0
|
||||
|
||||
if (this.autoApprovalSettings.enabled && this.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
subtitle: "Cline wants to start a new task...",
|
||||
message: `Cline is suggesting to start a new task with: ${context}`,
|
||||
})
|
||||
}
|
||||
|
||||
const { text, images } = await this.ask("new_task", context, false)
|
||||
|
||||
// If the user provided a response, treat it as feedback
|
||||
if (text || images?.length) {
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
pushToolResult(
|
||||
formatResponse.toolResult(
|
||||
`The user provided feedback instead of creating a new task:\n<feedback>\n${text}\n</feedback>`,
|
||||
images,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// If no response, the user clicked the "Create New Task" button
|
||||
pushToolResult(
|
||||
formatResponse.toolResult(`The user has created a new task with the provided context.`),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError("creating new task", error)
|
||||
break
|
||||
}
|
||||
}
|
||||
case "plan_mode_respond": {
|
||||
const response: string | undefined = block.params.response
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
@@ -2834,14 +2925,7 @@ export class Task {
|
||||
break
|
||||
} else {
|
||||
await this.say("load_mcp_documentation", "", undefined, false)
|
||||
|
||||
const mcpHub = this.controllerRef.deref()?.mcpHub
|
||||
if (!mcpHub) {
|
||||
throw new Error("MCP hub not available")
|
||||
}
|
||||
|
||||
pushToolResult(await loadMcpDocumentation(mcpHub))
|
||||
|
||||
pushToolResult(await loadMcpDocumentation(this.mcpHub))
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -3049,6 +3133,14 @@ export class Task {
|
||||
throw new Error("Cline instance aborted")
|
||||
}
|
||||
|
||||
// Used to know what models were used in the task if user wants to export metadata for error reporting purposes
|
||||
const currentProviderId = (await getGlobalState(this.getContext(), "apiProvider")) as string
|
||||
if (currentProviderId && this.api.getModel().id) {
|
||||
try {
|
||||
await this.modelContextTracker.recordModelUsage(currentProviderId, this.api.getModel().id, this.chatSettings.mode)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (this.consecutiveMistakeCount >= 3) {
|
||||
if (this.autoApprovalSettings.enabled && this.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
@@ -3118,7 +3210,7 @@ export class Task {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await pTimeout(
|
||||
CheckpointTracker.create(this.taskId, this.controllerRef.deref()?.context.globalStorageUri.fsPath),
|
||||
CheckpointTracker.create(this.taskId, this.context.globalStorageUri.fsPath),
|
||||
{
|
||||
milliseconds: 15_000,
|
||||
message:
|
||||
@@ -3152,7 +3244,7 @@ export class Task {
|
||||
content: userContent,
|
||||
})
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "user")
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, currentProviderId, this.api.getModel().id, "user")
|
||||
|
||||
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
|
||||
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
|
||||
@@ -3160,7 +3252,7 @@ export class Task {
|
||||
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
|
||||
} satisfies ClineApiReqInfo)
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
|
||||
try {
|
||||
let cacheWriteTokens = 0
|
||||
@@ -3229,7 +3321,7 @@ export class Task {
|
||||
updateApiReqMsg(cancelReason, streamingFailedMessage)
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, currentProviderId, this.api.getModel().id, "assistant")
|
||||
|
||||
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
|
||||
this.didFinishAbortingStream = true
|
||||
@@ -3320,11 +3412,7 @@ export class Task {
|
||||
const errorMessage = this.formatErrorWithStatusCode(error)
|
||||
|
||||
await abortStream("streaming_failed", errorMessage)
|
||||
const history = await this.controllerRef.deref()?.getTaskWithId(this.taskId)
|
||||
if (history) {
|
||||
await this.controllerRef.deref()?.initClineWithHistoryItem(history.historyItem)
|
||||
// await this.controllerRef.deref()?.postStateToWebview()
|
||||
}
|
||||
await this.reinitExistingTaskFromId(this.taskId)
|
||||
}
|
||||
} finally {
|
||||
this.isStreaming = false
|
||||
@@ -3343,7 +3431,7 @@ export class Task {
|
||||
}
|
||||
updateApiReqMsg()
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3367,13 +3455,13 @@ export class Task {
|
||||
|
||||
updateApiReqMsg()
|
||||
await this.saveClineMessagesAndUpdateHistory()
|
||||
await this.controllerRef.deref()?.postStateToWebview()
|
||||
await this.postStateToWebview()
|
||||
|
||||
// now add to apiconversationhistory
|
||||
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
|
||||
let didEndLoop = false
|
||||
if (assistantMessage.length > 0) {
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, currentProviderId, this.api.getModel().id, "assistant")
|
||||
|
||||
await this.addToApiConversationHistory({
|
||||
role: "assistant",
|
||||
@@ -3628,6 +3716,36 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
// Add context window usage information
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(this.api)
|
||||
|
||||
// Get the token count from the most recent API request to accurately reflect context management
|
||||
const getTotalTokensFromApiReqMessage = (msg: ClineMessage) => {
|
||||
if (!msg.text) {
|
||||
return 0
|
||||
}
|
||||
try {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads } = JSON.parse(msg.text)
|
||||
return (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
} catch (e) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedMessages = combineApiRequests(combineCommandSequences(this.clineMessages.slice(1)))
|
||||
const lastApiReqMessage = findLast(modifiedMessages, (msg) => {
|
||||
if (msg.say !== "api_req_started") {
|
||||
return false
|
||||
}
|
||||
return getTotalTokensFromApiReqMessage(msg) > 0
|
||||
})
|
||||
|
||||
const lastApiReqTotalTokens = lastApiReqMessage ? getTotalTokensFromApiReqMessage(lastApiReqMessage) : 0
|
||||
const usagePercentage = Math.round((lastApiReqTotalTokens / contextWindow) * 100)
|
||||
|
||||
details += "\n\n# Context Window Usage"
|
||||
details += `\n${lastApiReqTotalTokens.toLocaleString()} / ${(contextWindow / 1000).toLocaleString()}K tokens used (${usagePercentage}%)`
|
||||
|
||||
details += "\n\n# Current Mode"
|
||||
if (this.chatSettings.mode === "plan") {
|
||||
details += "\nPLAN MODE\n" + formatResponse.planModeInstructions()
|
||||
|
||||
@@ -23,7 +23,7 @@ export class WebviewProvider implements vscode.WebviewViewProvider {
|
||||
private readonly outputChannel: vscode.OutputChannel,
|
||||
) {
|
||||
WebviewProvider.activeInstances.add(this)
|
||||
this.controller = new Controller(context, outputChannel, this)
|
||||
this.controller = new Controller(context, outputChannel, (message) => this.view?.webview.postMessage(message))
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
|
||||
+21
-8
@@ -9,6 +9,8 @@ import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import assert from "node:assert"
|
||||
import { telemetryService } from "./services/telemetry/TelemetryService"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import { createTestServer, shutdownTestServer } from "./services/test/TestServer"
|
||||
import { ErrorService } from "./services/error/ErrorService"
|
||||
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
@@ -27,12 +29,14 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
outputChannel = vscode.window.createOutputChannel("Cline")
|
||||
context.subscriptions.push(outputChannel)
|
||||
|
||||
ErrorService.initialize()
|
||||
Logger.initialize(outputChannel)
|
||||
Logger.log("Cline extension activated")
|
||||
|
||||
const sidebarWebview = new WebviewProvider(context, outputChannel)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
vscode.commands.executeCommand("setContext", "cline.isTestMode", IS_TEST && IS_TEST === "true")
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(WebviewProvider.sideBarId, sidebarWebview, {
|
||||
@@ -377,7 +381,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
// Register the command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: any[]) => {
|
||||
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: vscode.Diagnostic[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
@@ -393,13 +397,12 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}),
|
||||
)
|
||||
|
||||
return createClineAPI(outputChannel, sidebarWebview.controller)
|
||||
}
|
||||
// Set up test server if in test mode
|
||||
if (IS_TEST === "true") {
|
||||
createTestServer(sidebarWebview)
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
export function deactivate() {
|
||||
telemetryService.shutdown()
|
||||
Logger.log("Cline extension deactivated")
|
||||
return createClineAPI(outputChannel, sidebarWebview.controller)
|
||||
}
|
||||
|
||||
// TODO: Find a solution for automatically removing DEV related content from production builds.
|
||||
@@ -408,8 +411,18 @@ export function deactivate() {
|
||||
//
|
||||
// This is a workaround to reload the extension when the source code changes
|
||||
// since vscode doesn't support hot reload for extensions
|
||||
const { IS_DEV, DEV_WORKSPACE_FOLDER } = process.env
|
||||
const { IS_DEV, DEV_WORKSPACE_FOLDER, IS_TEST } = process.env
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
export function deactivate() {
|
||||
// Shutdown the test server if it exists
|
||||
shutdownTestServer()
|
||||
|
||||
telemetryService.shutdown()
|
||||
Logger.log("Cline extension deactivated")
|
||||
}
|
||||
|
||||
// Set up development mode file watcher
|
||||
if (IS_DEV && IS_DEV !== "false") {
|
||||
assert(DEV_WORKSPACE_FOLDER, "DEV_WORKSPACE_FOLDER must be set in development")
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(DEV_WORKSPACE_FOLDER, "src/**/*"))
|
||||
|
||||
@@ -105,8 +105,11 @@ export class DiffViewProvider {
|
||||
const beginningOfDocument = new vscode.Position(0, 0)
|
||||
diffEditor.selection = new vscode.Selection(beginningOfDocument, beginningOfDocument)
|
||||
|
||||
for (let i = 0; i < diffLines.length; i++) {
|
||||
const currentLine = this.streamedLines.length + i
|
||||
// Instead of animating each line, we'll update in larger chunks
|
||||
const currentLine = this.streamedLines.length + diffLines.length - 1
|
||||
if (currentLine >= 0) {
|
||||
// Only proceed if we have new lines
|
||||
|
||||
// Replace all content up to the current line with accumulated lines
|
||||
// This is necessary (as compared to inserting one line at a time) to handle cases where html tags on previous lines are auto closed for example
|
||||
const edit = new vscode.WorkspaceEdit()
|
||||
@@ -114,12 +117,33 @@ export class DiffViewProvider {
|
||||
const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
|
||||
edit.replace(document.uri, rangeToReplace, contentToReplace)
|
||||
await vscode.workspace.applyEdit(edit)
|
||||
// Update decorations
|
||||
|
||||
// Update decorations for the entire changed section
|
||||
this.activeLineController.setActiveLine(currentLine)
|
||||
this.fadedOverlayController.updateOverlayAfterLine(currentLine, document.lineCount)
|
||||
// Scroll to the current line
|
||||
this.scrollEditorToLine(currentLine)
|
||||
|
||||
// Scroll to the last changed line
|
||||
if (diffLines.length <= 5) {
|
||||
// For small changes, just jump directly to the line
|
||||
this.scrollEditorToLine(currentLine)
|
||||
} else {
|
||||
// For larger changes, create a quick scrolling animation
|
||||
const startLine = this.streamedLines.length
|
||||
const endLine = currentLine
|
||||
const totalLines = endLine - startLine
|
||||
const numSteps = 10 // Adjust this number to control animation speed
|
||||
const stepSize = Math.max(1, Math.floor(totalLines / numSteps))
|
||||
|
||||
// Create and await the smooth scrolling animation
|
||||
for (let line = startLine; line <= endLine; line += stepSize) {
|
||||
this.activeDiffEditor?.revealRange(new vscode.Range(line, 0, line, 0), vscode.TextEditorRevealType.InCenter)
|
||||
await new Promise((resolve) => setTimeout(resolve, 16)) // ~60fps
|
||||
}
|
||||
// Ensure we end at the final line
|
||||
this.scrollEditorToLine(currentLine)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the streamedLines with the new accumulated content
|
||||
this.streamedLines = accumulatedLines
|
||||
if (isFinal) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function openImage(dataUri: string) {
|
||||
const imageBuffer = Buffer.from(base64Data, "base64")
|
||||
const tempFilePath = path.join(os.tmpdir(), `temp_image_${Date.now()}.${format}`)
|
||||
try {
|
||||
await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), imageBuffer)
|
||||
await vscode.workspace.fs.writeFile(vscode.Uri.file(tempFilePath), new Uint8Array(imageBuffer))
|
||||
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(tempFilePath))
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(`Error opening image: ${error}`)
|
||||
|
||||
@@ -2,17 +2,17 @@ import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { listFiles } from "../../services/glob/list-files"
|
||||
import { Controller } from "../../core/controller"
|
||||
import { ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
|
||||
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
|
||||
class WorkspaceTracker {
|
||||
private controllerRef: WeakRef<Controller>
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private filePaths: Set<string> = new Set()
|
||||
|
||||
constructor(controller: Controller) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
constructor(private readonly postMessageToWebview: (message: ExtensionMessage) => Promise<void>) {
|
||||
this.postMessageToWebview = postMessageToWebview
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class WorkspaceTracker {
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
this.controllerRef.deref()?.postMessageToWebview({
|
||||
this.postMessageToWebview({
|
||||
type: "workspaceUpdated",
|
||||
filePaths: Array.from(this.filePaths).map((file) => {
|
||||
const relativePath = path.relative(cwd, file).toPosix()
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { Controller } from "../../core/controller"
|
||||
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
|
||||
import { ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
export class ClineAccountService {
|
||||
private readonly baseUrl = "https://api.cline.bot/v1"
|
||||
private controllerRef: WeakRef<Controller>
|
||||
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
|
||||
private getClineApiKey: () => Promise<string | undefined>
|
||||
|
||||
constructor(controller: Controller) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's Cline Account key from the apiConfiguration
|
||||
*/
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { apiConfiguration } = await provider.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
constructor(
|
||||
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
|
||||
getClineApiKey: () => Promise<string | undefined>,
|
||||
) {
|
||||
this.postMessageToWebview = postMessageToWebview
|
||||
this.getClineApiKey = getClineApiKey
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,7 +56,7 @@ export class ClineAccountService {
|
||||
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
|
||||
|
||||
// Post to webview
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsBalance",
|
||||
userCreditsBalance: data,
|
||||
})
|
||||
@@ -84,7 +76,7 @@ export class ClineAccountService {
|
||||
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
|
||||
|
||||
// Post to webview
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsUsage",
|
||||
userCreditsUsage: data,
|
||||
})
|
||||
@@ -104,7 +96,7 @@ export class ClineAccountService {
|
||||
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
|
||||
|
||||
// Post to webview
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsPayments",
|
||||
userCreditsPayments: data,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { exec, spawn } from "child_process"
|
||||
import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect } from "puppeteer-core"
|
||||
import { Browser, Page, TimeoutError, launch, connect } from "puppeteer-core"
|
||||
import type { ScreenshotOptions, ConsoleMessage } from "puppeteer-core"
|
||||
// @ts-ignore
|
||||
import PCR from "puppeteer-chromium-resolver"
|
||||
import pWaitFor from "p-wait-for"
|
||||
@@ -451,7 +452,7 @@ export class BrowserSession {
|
||||
const logs: string[] = []
|
||||
let lastLogTs = Date.now()
|
||||
|
||||
const consoleListener = (msg: any) => {
|
||||
const consoleListener = (msg: ConsoleMessage) => {
|
||||
if (msg.type() === "log") {
|
||||
logs.push(msg.text())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import * as Sentry from "@sentry/browser"
|
||||
import * as vscode from "vscode"
|
||||
import { telemetryService } from "../telemetry/TelemetryService"
|
||||
import * as pkg from "../../../package.json"
|
||||
|
||||
let telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
let isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
|
||||
|
||||
vscode.workspace.onDidChangeConfiguration(() => {
|
||||
telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
isTelemetryEnabled = ["all", "error"].includes(telemetryLevel)
|
||||
ErrorService.toggleEnabled(isTelemetryEnabled)
|
||||
if (isTelemetryEnabled) {
|
||||
ErrorService.setLevel(telemetryLevel as "error" | "all")
|
||||
}
|
||||
})
|
||||
|
||||
export class ErrorService {
|
||||
private static serviceEnabled: boolean
|
||||
private static serviceLevel: string
|
||||
|
||||
static initialize() {
|
||||
// Initialize sentry
|
||||
Sentry.init({
|
||||
dsn: "https://7936780e3f0f0290fcf8d4a395c249b7@o4509028819664896.ingest.us.sentry.io/4509052955983872",
|
||||
environment: process.env.NODE_ENV,
|
||||
release: `cline@${pkg.version}`,
|
||||
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration()],
|
||||
beforeSend(event) {
|
||||
// TelemetryService keeps track of whether the user has opted in to telemetry/error reporting
|
||||
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
|
||||
if (isUserManuallyOptedIn && ErrorService.isEnabled()) {
|
||||
return event
|
||||
}
|
||||
return null
|
||||
},
|
||||
})
|
||||
|
||||
ErrorService.toggleEnabled(true)
|
||||
ErrorService.setLevel("error")
|
||||
}
|
||||
|
||||
static toggleEnabled(state: boolean) {
|
||||
if (state === false) {
|
||||
ErrorService.serviceEnabled = false
|
||||
return
|
||||
}
|
||||
// If we are trying to enable the service, check that we are allowed to.
|
||||
if (isTelemetryEnabled) {
|
||||
ErrorService.serviceEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
static setLevel(level: "error" | "all") {
|
||||
switch (telemetryLevel) {
|
||||
case "error": {
|
||||
if (level === "error") {
|
||||
ErrorService.serviceLevel = level
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
ErrorService.serviceLevel = level
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static logException(error: Error): void {
|
||||
// Don't log if telemetry is off
|
||||
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
|
||||
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
|
||||
return
|
||||
}
|
||||
// Log the error to Sentry
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
|
||||
static logMessage(message: string, level: "error" | "warning" | "log" | "debug" | "info" = "log"): void {
|
||||
// Don't log if telemetry is off
|
||||
const isUserManuallyOptedIn = telemetryService.isTelemetryEnabled()
|
||||
if (!isUserManuallyOptedIn || !ErrorService.isEnabled()) {
|
||||
return
|
||||
}
|
||||
if (ErrorService.serviceLevel === "error" && level === "error") {
|
||||
// Log the message if allowed
|
||||
Sentry.captureMessage(message, { level })
|
||||
return
|
||||
}
|
||||
// Log the message if allowed
|
||||
Sentry.captureMessage(message, { level })
|
||||
}
|
||||
|
||||
static isEnabled(): boolean {
|
||||
return ErrorService.serviceEnabled
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OutputChannel } from "vscode"
|
||||
import { ErrorService } from "../error/ErrorService"
|
||||
|
||||
/**
|
||||
* Simple logging utility for the extension's backend code.
|
||||
@@ -12,7 +13,25 @@ export class Logger {
|
||||
Logger.outputChannel = outputChannel
|
||||
}
|
||||
|
||||
static error(message: string, exception?: Error) {
|
||||
Logger.outputChannel.appendLine(`ERROR: ${message}`)
|
||||
ErrorService.logMessage(message, "error")
|
||||
exception && ErrorService.logException(exception)
|
||||
}
|
||||
static warn(message: string) {
|
||||
Logger.outputChannel.appendLine(`WARN: ${message}`)
|
||||
ErrorService.logMessage(message, "warning")
|
||||
}
|
||||
static log(message: string) {
|
||||
Logger.outputChannel.appendLine(message)
|
||||
Logger.outputChannel.appendLine(`LOG: ${message}`)
|
||||
}
|
||||
static debug(message: string) {
|
||||
Logger.outputChannel.appendLine(`DEBUG: ${message}`)
|
||||
}
|
||||
static info(message: string) {
|
||||
Logger.outputChannel.appendLine(`INFO: ${message}`)
|
||||
}
|
||||
static trace(message: string) {
|
||||
Logger.outputChannel.appendLine(`TRACE: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-25
@@ -14,7 +14,6 @@ import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { Controller } from "../../core/controller"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpMode,
|
||||
@@ -31,6 +30,7 @@ import { arePathsEqual } from "../../utils/path"
|
||||
import { secondsToMs } from "../../utils/time"
|
||||
import { GlobalFileNames } from "../../core/storage/disk"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
// Default timeout for internal MCP data requests in milliseconds; is not the same as the user facing timeout stored as DEFAULT_MCP_TIMEOUT_SECONDS
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 5000
|
||||
@@ -76,15 +76,27 @@ const McpSettingsSchema = z.object({
|
||||
})
|
||||
|
||||
export class McpHub {
|
||||
private controllerRef: WeakRef<Controller>
|
||||
getMcpServersPath: () => Promise<string>
|
||||
private getSettingsDirectoryPath: () => Promise<string>
|
||||
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
|
||||
private clientVersion: string
|
||||
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private settingsWatcher?: vscode.FileSystemWatcher
|
||||
private fileWatchers: Map<string, FSWatcher> = new Map()
|
||||
connections: McpConnection[] = []
|
||||
isConnecting: boolean = false
|
||||
|
||||
constructor(controller: Controller) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
constructor(
|
||||
getMcpServersPath: () => Promise<string>,
|
||||
getSettingsDirectoryPath: () => Promise<string>,
|
||||
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
|
||||
clientVersion: string,
|
||||
) {
|
||||
this.getMcpServersPath = getMcpServersPath
|
||||
this.getSettingsDirectoryPath = getSettingsDirectoryPath
|
||||
this.postMessageToWebview = postMessageToWebview
|
||||
this.clientVersion = clientVersion
|
||||
this.watchMcpSettingsFile()
|
||||
this.initializeMcpServers()
|
||||
}
|
||||
@@ -94,21 +106,8 @@ export class McpHub {
|
||||
return this.connections.filter((conn) => !conn.server.disabled).map((conn) => conn.server)
|
||||
}
|
||||
|
||||
async getMcpServersPath(): Promise<string> {
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available")
|
||||
}
|
||||
const mcpServersPath = await provider.ensureMcpServersDirectoryExists()
|
||||
return mcpServersPath
|
||||
}
|
||||
|
||||
async getMcpSettingsFilePath(): Promise<string> {
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available")
|
||||
}
|
||||
const mcpSettingsFilePath = path.join(await provider.ensureSettingsDirectoryExists(), GlobalFileNames.mcpSettings)
|
||||
const mcpSettingsFilePath = path.join(await this.getSettingsDirectoryPath(), GlobalFileNames.mcpSettings)
|
||||
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
|
||||
if (!fileExists) {
|
||||
await fs.writeFile(
|
||||
@@ -193,7 +192,7 @@ export class McpHub {
|
||||
const client = new Client(
|
||||
{
|
||||
name: "Cline",
|
||||
version: this.controllerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
|
||||
version: this.clientVersion,
|
||||
},
|
||||
{
|
||||
capabilities: {},
|
||||
@@ -425,7 +424,7 @@ export class McpHub {
|
||||
this.isConnecting = false
|
||||
}
|
||||
|
||||
private setupFileWatcher(name: string, config: any) {
|
||||
private setupFileWatcher(name: string, config: Extract<McpServerConfig, { transportType: "stdio" }>) {
|
||||
const filePath = config.args?.find((arg: string) => arg.includes("build/index.js"))
|
||||
if (filePath) {
|
||||
// we use chokidar instead of onDidSaveTextDocument because it doesn't require the file to be open in the editor. The settings config is better suited for onDidSave since that will be manually updated by the user or Cline (and we want to detect save events, not every file change)
|
||||
@@ -451,10 +450,6 @@ export class McpHub {
|
||||
|
||||
async restartConnection(serverName: string): Promise<void> {
|
||||
this.isConnecting = true
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing connection and update its status
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
@@ -486,7 +481,7 @@ export class McpHub {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
const serverOrder = Object.keys(config.mcpServers || {})
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
await this.postMessageToWebview({
|
||||
type: "mcpServers",
|
||||
mcpServers: [...this.connections]
|
||||
.sort((a, b) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as vscode from "vscode"
|
||||
import { version as extensionVersion } from "../../../package.json"
|
||||
|
||||
import type { TaskFeedbackType } from "../../shared/WebviewMessage"
|
||||
import type { BrowserSettings } from "../../shared/BrowserSettings"
|
||||
|
||||
/**
|
||||
* PostHogClient handles telemetry event tracking for the Cline extension
|
||||
@@ -71,6 +72,8 @@ class PostHogClient {
|
||||
PLAN_MODE_TOGGLED: "ui.plan_mode_toggled",
|
||||
// Tracks when action mode is toggled on
|
||||
ACT_MODE_TOGGLED: "ui.act_mode_toggled",
|
||||
// Tracks when users use the "favorite" button in the model picker
|
||||
MODEL_FAVORITE_TOGGLED: "ui.model_favorite_toggled",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -472,7 +475,7 @@ class PostHogClient {
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param browserSettings The browser settings being used
|
||||
*/
|
||||
public captureBrowserToolStart(taskId: string, browserSettings: any) {
|
||||
public captureBrowserToolStart(taskId: string, browserSettings: BrowserSettings) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.BROWSER_TOOL_START,
|
||||
properties: {
|
||||
@@ -574,6 +577,21 @@ class PostHogClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when the user uses the model favorite button in the model picker
|
||||
* @param model The name of the model the user has interacted with
|
||||
* @param isFavorited Whether the model is being favorited (true) or unfavorited (false)
|
||||
*/
|
||||
public captureModelFavoritesUsage(model: string, isFavorited: boolean) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.MODEL_FAVORITE_TOGGLED,
|
||||
properties: {
|
||||
model,
|
||||
isFavorited,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public isTelemetryEnabled(): boolean {
|
||||
return this.telemetryEnabled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import * as http from "http"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "../../services/logging/Logger"
|
||||
import { WebviewProvider } from "../../core/webview"
|
||||
import { AutoApprovalSettings } from "../../shared/AutoApprovalSettings"
|
||||
import { updateGlobalState, getAllExtensionState } from "../../core/storage/state"
|
||||
import { ClineAsk, ExtensionMessage } from "../../shared/ExtensionMessage"
|
||||
import { WebviewMessage } from "../../shared/WebviewMessage"
|
||||
|
||||
// Task completion tracking
|
||||
let taskCompletionResolver: (() => void) | null = null
|
||||
|
||||
// Function to create a new task completion promise
|
||||
function createTaskCompletionTracker(): Promise<void> {
|
||||
// Create a new promise that will resolve when the task is completed
|
||||
return new Promise<void>((resolve) => {
|
||||
taskCompletionResolver = resolve
|
||||
})
|
||||
}
|
||||
|
||||
// Function to mark the current task as completed
|
||||
function completeTask(): void {
|
||||
if (taskCompletionResolver) {
|
||||
taskCompletionResolver()
|
||||
taskCompletionResolver = null
|
||||
Logger.log("Task marked as completed")
|
||||
}
|
||||
}
|
||||
|
||||
let testServer: http.Server | undefined
|
||||
let messageCatcherDisposable: vscode.Disposable | undefined
|
||||
|
||||
/**
|
||||
* Updates the auto approval settings to enable all actions
|
||||
* @param context The VSCode extension context
|
||||
* @param provider The webview provider instance
|
||||
*/
|
||||
async function updateAutoApprovalSettings(context: vscode.ExtensionContext, provider?: WebviewProvider) {
|
||||
try {
|
||||
const { autoApprovalSettings } = await getAllExtensionState(context)
|
||||
|
||||
// Enable all actions
|
||||
const updatedSettings: AutoApprovalSettings = {
|
||||
...autoApprovalSettings,
|
||||
enabled: true,
|
||||
actions: {
|
||||
readFiles: true,
|
||||
readFilesExternally: true,
|
||||
editFiles: true,
|
||||
editFilesExternally: true,
|
||||
executeSafeCommands: true,
|
||||
executeAllCommands: true,
|
||||
useBrowser: false, // Keep browser disabled for tests
|
||||
useMcp: false, // Keep MCP disabled for tests
|
||||
},
|
||||
maxRequests: 10000, // Increase max requests for tests
|
||||
}
|
||||
|
||||
await updateGlobalState(context, "autoApprovalSettings", updatedSettings)
|
||||
Logger.log("Auto approval settings updated for test mode")
|
||||
|
||||
// Update the webview with the new state
|
||||
if (provider?.controller) {
|
||||
await provider.controller.postStateToWebview()
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.log(`Error updating auto approval settings: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and starts an HTTP server for test automation
|
||||
* @param webviewProvider The webview provider instance to use for message catching
|
||||
* @returns The created HTTP server instance
|
||||
*/
|
||||
export function createTestServer(webviewProvider?: WebviewProvider): http.Server {
|
||||
// Update auto approval settings if webviewProvider is available
|
||||
if (webviewProvider?.controller?.context) {
|
||||
updateAutoApprovalSettings(webviewProvider.controller.context, webviewProvider)
|
||||
}
|
||||
const PORT = 9876
|
||||
|
||||
testServer = http.createServer((req, res) => {
|
||||
// Set CORS headers
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Only handle POST requests to /task
|
||||
if (req.method !== "POST" || req.url !== "/task") {
|
||||
res.writeHead(404)
|
||||
res.end(JSON.stringify({ error: "Not found" }))
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the request body
|
||||
let body = ""
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk.toString()
|
||||
})
|
||||
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
// Parse the JSON body
|
||||
const { task } = JSON.parse(body)
|
||||
|
||||
if (!task) {
|
||||
res.writeHead(400)
|
||||
res.end(JSON.stringify({ error: "Missing task parameter" }))
|
||||
return
|
||||
}
|
||||
|
||||
// Get a visible webview instance
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (!visibleWebview || !visibleWebview.controller) {
|
||||
res.writeHead(500)
|
||||
res.end(JSON.stringify({ error: "No active Cline instance found" }))
|
||||
return
|
||||
}
|
||||
|
||||
// Initiate a new task
|
||||
Logger.log(`Test server initiating task: ${task}`)
|
||||
|
||||
try {
|
||||
// Clear any existing task
|
||||
await visibleWebview.controller.clearTask()
|
||||
|
||||
// Ensure we're in Act mode before initiating the task
|
||||
const { chatSettings } = await visibleWebview.controller.getStateToPostToWebview()
|
||||
if (chatSettings.mode === "plan") {
|
||||
// Switch to Act mode if currently in Plan mode
|
||||
await visibleWebview.controller.togglePlanActModeWithChatSettings({ mode: "act" })
|
||||
}
|
||||
|
||||
// Initiate the new task
|
||||
const taskId = await visibleWebview.controller.initTask(task)
|
||||
|
||||
// Create a completion tracker for this task
|
||||
const completionPromise = createTaskCompletionTracker()
|
||||
|
||||
// Wait for the task to complete with a timeout
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Task completion timeout")), 15 * 60 * 1000) // 15 minute timeout
|
||||
})
|
||||
|
||||
try {
|
||||
// Wait for either completion or timeout
|
||||
await Promise.race([completionPromise, timeoutPromise])
|
||||
|
||||
// Return success response with the task ID
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
taskId,
|
||||
completed: true,
|
||||
}),
|
||||
)
|
||||
} catch (timeoutError) {
|
||||
// Task didn't complete within the timeout period
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
taskId,
|
||||
completed: false,
|
||||
timeout: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.log(`Error initiating task: ${error}`)
|
||||
res.writeHead(500)
|
||||
res.end(JSON.stringify({ error: `Failed to initiate task: ${error}` }))
|
||||
}
|
||||
} catch (error) {
|
||||
res.writeHead(400)
|
||||
res.end(JSON.stringify({ error: `Invalid JSON: ${error}` }))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
testServer.listen(PORT, () => {
|
||||
Logger.log(`Test server listening on port ${PORT}`)
|
||||
})
|
||||
|
||||
// Handle server errors
|
||||
testServer.on("error", (error) => {
|
||||
Logger.log(`Test server error: ${error}`)
|
||||
})
|
||||
|
||||
// Set up message catcher for the provided webview instance or try to get the visible one
|
||||
if (webviewProvider) {
|
||||
messageCatcherDisposable = createMessageCatcher(webviewProvider)
|
||||
} else {
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (visibleWebview) {
|
||||
messageCatcherDisposable = createMessageCatcher(visibleWebview)
|
||||
} else {
|
||||
Logger.log("No visible webview instance found for message catcher")
|
||||
}
|
||||
}
|
||||
|
||||
return testServer
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message catcher that logs all messages sent to the webview
|
||||
* and automatically responds to messages that require user intervention
|
||||
* @param webviewProvider The webview provider instance
|
||||
* @returns A disposable that can be used to clean up the message catcher
|
||||
*/
|
||||
export function createMessageCatcher(webviewProvider: WebviewProvider): vscode.Disposable {
|
||||
Logger.log("Cline message catcher registered")
|
||||
|
||||
if (webviewProvider && webviewProvider.controller) {
|
||||
const originalPostMessageToWebview = webviewProvider.controller.postMessageToWebview
|
||||
|
||||
// Intercept outgoing messages from extension to webview
|
||||
webviewProvider.controller.postMessageToWebview = async (message: ExtensionMessage) => {
|
||||
Logger.log("Cline message received: " + JSON.stringify(message))
|
||||
|
||||
// Check for completion_result message
|
||||
if (message.type === "partialMessage" && message.partialMessage?.say === "completion_result") {
|
||||
// Complete the current task
|
||||
completeTask()
|
||||
}
|
||||
|
||||
// Check for ask messages that require user intervention
|
||||
if (message.type === "partialMessage" && message.partialMessage?.type === "ask" && !message.partialMessage.partial) {
|
||||
const askType = message.partialMessage.ask as ClineAsk
|
||||
const askText = message.partialMessage.text
|
||||
|
||||
// Automatically respond to different types of asks
|
||||
setTimeout(() => {
|
||||
autoRespondToAsk(webviewProvider, askType, askText)
|
||||
}, 100) // Small delay to ensure the message is processed first
|
||||
}
|
||||
|
||||
return originalPostMessageToWebview.call(webviewProvider.controller, message)
|
||||
}
|
||||
} else {
|
||||
Logger.log("No visible webview instance found for message catcher")
|
||||
}
|
||||
|
||||
return new vscode.Disposable(() => {
|
||||
// Cleanup function if needed
|
||||
Logger.log("Cline message catcher disposed")
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically responds to ask messages to continue task execution without user intervention
|
||||
* @param webviewProvider The webview provider instance
|
||||
* @param askType The type of ask message
|
||||
* @param askText The text content of the ask message
|
||||
*/
|
||||
function autoRespondToAsk(webviewProvider: WebviewProvider, askType: ClineAsk, askText?: string): void {
|
||||
if (!webviewProvider.controller) {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.log(`Auto-responding to ask type: ${askType}`)
|
||||
|
||||
// Create a response message based on the ask type
|
||||
const response: WebviewMessage = {
|
||||
type: "askResponse",
|
||||
askResponse: "yesButtonClicked", // Default to approving most actions
|
||||
}
|
||||
|
||||
// Handle specific ask types differently if needed
|
||||
switch (askType) {
|
||||
case "followup":
|
||||
// For follow-up questions, provide a generic response
|
||||
response.askResponse = "messageResponse"
|
||||
response.text = "I can't answer any questions right now, use your best judgment."
|
||||
break
|
||||
|
||||
case "api_req_failed":
|
||||
// Always retry API requests
|
||||
response.askResponse = "yesButtonClicked" // "Retry" button
|
||||
break
|
||||
|
||||
case "completion_result":
|
||||
// Accept the completion
|
||||
response.askResponse = "messageResponse"
|
||||
response.text = "Task completed successfully."
|
||||
break
|
||||
|
||||
case "mistake_limit_reached":
|
||||
// Provide guidance to continue
|
||||
response.askResponse = "messageResponse"
|
||||
response.text = "Try breaking down the task into smaller steps."
|
||||
break
|
||||
|
||||
case "auto_approval_max_req_reached":
|
||||
// Reset the count to continue
|
||||
response.askResponse = "yesButtonClicked" // "Reset and continue" button
|
||||
break
|
||||
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
// Resume the task
|
||||
response.askResponse = "messageResponse"
|
||||
break
|
||||
|
||||
case "new_task":
|
||||
// Decline creating a new task to keep the current task running
|
||||
response.askResponse = "messageResponse"
|
||||
response.text = "Continue with the current task."
|
||||
break
|
||||
|
||||
case "plan_mode_respond":
|
||||
// Respond to plan mode with a message to toggle to Act mode
|
||||
response.askResponse = "messageResponse"
|
||||
response.text = "PLAN_MODE_TOGGLE_RESPONSE" // Special marker to toggle to Act mode
|
||||
|
||||
// Automatically toggle to Act mode after responding
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
if (webviewProvider.controller) {
|
||||
Logger.log("Auto-toggling to Act mode from Plan mode")
|
||||
await webviewProvider.controller.togglePlanActModeWithChatSettings({ mode: "act" })
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.log(`Error toggling to Act mode: ${error}`)
|
||||
}
|
||||
}, 500) // Small delay to ensure the response is processed first
|
||||
break
|
||||
|
||||
// For all other ask types (tool, command, browser_action_launch, use_mcp_server),
|
||||
// we use the default "yesButtonClicked" to approve the action
|
||||
}
|
||||
|
||||
// Send the response message
|
||||
webviewProvider.controller.handleWebviewMessage(response)
|
||||
Logger.log(`Auto-responded to ${askType} with ${response.askResponse}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuts down the test server if it exists
|
||||
*/
|
||||
export function shutdownTestServer() {
|
||||
if (testServer) {
|
||||
testServer.close()
|
||||
Logger.log("Test server shut down")
|
||||
testServer = undefined
|
||||
}
|
||||
|
||||
// Dispose of the message catcher if it exists
|
||||
if (messageCatcherDisposable) {
|
||||
messageCatcherDisposable.dispose()
|
||||
messageCatcherDisposable = undefined
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ export interface AutoApprovalSettings {
|
||||
enabled: boolean
|
||||
// Individual action permissions
|
||||
actions: {
|
||||
readFiles: boolean // Read files and directories
|
||||
editFiles: boolean // Edit files
|
||||
executeSafeCommands: boolean // Execute safe commands
|
||||
executeAllCommands: boolean // Execute all commands
|
||||
readFiles: boolean // Read files and directories in the working directory
|
||||
readFilesExternally?: boolean // Read files and directories outside of the working directory
|
||||
editFiles: boolean // Edit files in the working directory
|
||||
editFilesExternally?: boolean // Edit files outside of the working directory
|
||||
executeSafeCommands?: boolean // Execute safe commands
|
||||
executeAllCommands?: boolean // Execute all commands
|
||||
useBrowser: boolean // Use browser
|
||||
useMcp: boolean // Use MCP servers
|
||||
}
|
||||
@@ -19,7 +21,9 @@ export const DEFAULT_AUTO_APPROVAL_SETTINGS: AutoApprovalSettings = {
|
||||
enabled: false,
|
||||
actions: {
|
||||
readFiles: false,
|
||||
readFilesExternally: false,
|
||||
editFiles: false,
|
||||
editFilesExternally: false,
|
||||
executeSafeCommands: false,
|
||||
executeAllCommands: false,
|
||||
useBrowser: false,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AutoApprovalSettings } from "./AutoApprovalSettings"
|
||||
import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ChatSettings } from "./ChatSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
|
||||
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse, McpViewTab } from "./mcp"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ExtensionMessage {
|
||||
| "partialMessage"
|
||||
| "openRouterModels"
|
||||
| "openAiModels"
|
||||
| "requestyModels"
|
||||
| "mcpServers"
|
||||
| "relinquishControl"
|
||||
| "vsCodeLmModels"
|
||||
@@ -69,6 +70,7 @@ export interface ExtensionMessage {
|
||||
partialMessage?: ClineMessage
|
||||
openRouterModels?: Record<string, ModelInfo>
|
||||
openAiModels?: string[]
|
||||
requestyModels?: Record<string, ModelInfo>
|
||||
mcpServers?: McpServer[]
|
||||
customToken?: string
|
||||
mcpMarketplaceCatalog?: McpMarketplaceCatalog
|
||||
@@ -106,6 +108,7 @@ export interface ExtensionMessage {
|
||||
serverName: string
|
||||
error?: string
|
||||
}
|
||||
tab?: McpViewTab
|
||||
}
|
||||
|
||||
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
|
||||
@@ -151,6 +154,7 @@ export interface ClineMessage {
|
||||
partial?: boolean
|
||||
lastCheckpointHash?: string
|
||||
isCheckpointCheckedOut?: boolean
|
||||
isOperationOutsideWorkspace?: boolean
|
||||
conversationHistoryIndex?: number
|
||||
conversationHistoryDeletedRange?: [number, number] // for when conversation history is truncated for API requests
|
||||
}
|
||||
@@ -169,6 +173,7 @@ export type ClineAsk =
|
||||
| "auto_approval_max_req_reached"
|
||||
| "browser_action_launch"
|
||||
| "use_mcp_server"
|
||||
| "new_task"
|
||||
|
||||
export type ClineSay =
|
||||
| "task"
|
||||
@@ -211,6 +216,7 @@ export interface ClineSayTool {
|
||||
content?: string
|
||||
regex?: string
|
||||
filePattern?: string
|
||||
operationIsLocatedInWorkspace?: boolean
|
||||
}
|
||||
|
||||
// must keep in sync with system prompt
|
||||
@@ -256,6 +262,10 @@ export interface ClineAskQuestion {
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface ClineAskNewTask {
|
||||
context: string
|
||||
}
|
||||
|
||||
export interface ClineApiReqInfo {
|
||||
request?: string
|
||||
tokensIn?: number
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChatSettings } from "./ChatSettings"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
import { ChatContent } from "./ChatContent"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import { McpViewTab } from "./mcp"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
@@ -29,6 +30,7 @@ export interface WebviewMessage {
|
||||
| "openMention"
|
||||
| "cancelTask"
|
||||
| "refreshOpenRouterModels"
|
||||
| "refreshRequestyModels"
|
||||
| "refreshOpenAiModels"
|
||||
| "openMcpSettings"
|
||||
| "restartMcpServer"
|
||||
@@ -78,6 +80,7 @@ export interface WebviewMessage {
|
||||
| "scrollToSettings"
|
||||
| "getRelativePaths" // Handles single and multiple URI resolution
|
||||
| "searchFiles"
|
||||
| "toggleFavoriteModel"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
uris?: string[] // Used for getRelativePaths
|
||||
@@ -93,6 +96,7 @@ export interface WebviewMessage {
|
||||
chatContent?: ChatContent
|
||||
mcpId?: string
|
||||
timeout?: number
|
||||
tab?: McpViewTab
|
||||
// For toggleToolAutoApprove
|
||||
serverName?: string
|
||||
serverUrl?: string
|
||||
@@ -111,6 +115,8 @@ export interface WebviewMessage {
|
||||
feedbackType?: TaskFeedbackType
|
||||
mentionsRequestId?: string
|
||||
query?: string
|
||||
// For toggleFavoriteModel
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
+131
-9
@@ -1,3 +1,5 @@
|
||||
import type { LanguageModelChatSelector } from "../api/providers/types"
|
||||
|
||||
export type ApiProvider =
|
||||
| "anthropic"
|
||||
| "openrouter"
|
||||
@@ -60,36 +62,46 @@ export interface ApiHandlerOptions {
|
||||
deepSeekApiKey?: string
|
||||
requestyApiKey?: string
|
||||
requestyModelId?: string
|
||||
requestyModelInfo?: ModelInfo
|
||||
togetherApiKey?: string
|
||||
togetherModelId?: string
|
||||
qwenApiKey?: string
|
||||
doubaoApiKey?: string
|
||||
mistralApiKey?: string
|
||||
azureApiVersion?: string
|
||||
vsCodeLmModelSelector?: any
|
||||
vsCodeLmModelSelector?: LanguageModelChatSelector
|
||||
o3MiniReasoningEffort?: string
|
||||
qwenApiLine?: string
|
||||
asksageApiUrl?: string
|
||||
asksageApiKey?: string
|
||||
xaiApiKey?: string
|
||||
thinkingBudgetTokens?: number
|
||||
reasoningEffort?: string
|
||||
sambanovaApiKey?: string
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
apiProvider?: ApiProvider
|
||||
favoritedModelIds?: string[]
|
||||
}
|
||||
|
||||
// Models
|
||||
|
||||
interface PriceTier {
|
||||
tokenLimit: number // Upper limit (inclusive) of *input* tokens for this price. Use Infinity for the highest tier.
|
||||
price: number // Price per million tokens for this tier.
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
maxTokens?: number
|
||||
contextWindow?: number
|
||||
supportsImages?: boolean
|
||||
supportsComputerUse?: boolean
|
||||
supportsPromptCache: boolean // this value is hardcoded for now
|
||||
inputPrice?: number
|
||||
outputPrice?: number
|
||||
inputPrice?: number // Keep for non-tiered input models
|
||||
inputPriceTiers?: PriceTier[] // Add for tiered input pricing
|
||||
outputPrice?: number // Keep for non-tiered output models
|
||||
outputPriceTiers?: PriceTier[] // Add for tiered output pricing
|
||||
cacheWritesPrice?: number
|
||||
cacheReadsPrice?: number
|
||||
description?: string
|
||||
@@ -384,8 +396,16 @@ export const vertexModels = {
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
// inputPrice: 1.25, // Removed
|
||||
// outputPrice: 10, // Removed
|
||||
inputPriceTiers: [
|
||||
{ tokenLimit: 200000, price: 1.25 }, // Input price for <= 200k input tokens
|
||||
{ tokenLimit: Infinity, price: 2.5 }, // Input price for > 200k input tokens
|
||||
],
|
||||
outputPriceTiers: [
|
||||
{ tokenLimit: 200000, price: 10.0 }, // Output price for <= 200k input tokens
|
||||
{ tokenLimit: Infinity, price: 15.0 }, // Output price for > 200k input tokens
|
||||
],
|
||||
},
|
||||
"gemini-2.0-flash-thinking-exp-01-21": {
|
||||
maxTokens: 65_536,
|
||||
@@ -474,8 +494,14 @@ export const geminiModels = {
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.25,
|
||||
outputPrice: 10,
|
||||
inputPriceTiers: [
|
||||
{ tokenLimit: 200000, price: 1.25 }, // Input price for <= 200k input tokens
|
||||
{ tokenLimit: Infinity, price: 2.5 }, // Input price for > 200k input tokens
|
||||
],
|
||||
outputPriceTiers: [
|
||||
{ tokenLimit: 200000, price: 10.0 }, // Output price for <= 200k input tokens
|
||||
{ tokenLimit: Infinity, price: 15.0 }, // Output price for > 200k input tokens
|
||||
],
|
||||
},
|
||||
"gemini-2.0-flash-001": {
|
||||
maxTokens: 8192,
|
||||
@@ -578,8 +604,32 @@ export const geminiModels = {
|
||||
// OpenAI Native
|
||||
// https://openai.com/api/pricing/
|
||||
export type OpenAiNativeModelId = keyof typeof openAiNativeModels
|
||||
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-4o"
|
||||
export const openAiNativeDefaultModelId: OpenAiNativeModelId = "gpt-4.1"
|
||||
export const openAiNativeModels = {
|
||||
"gpt-4.1": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 1_047_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 2,
|
||||
outputPrice: 8,
|
||||
},
|
||||
"gpt-4.1-mini": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 1_047_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.4,
|
||||
outputPrice: 1.6,
|
||||
},
|
||||
"gpt-4.1-nano": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 1_047_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.4,
|
||||
},
|
||||
"o3-mini": {
|
||||
maxTokens: 100_000,
|
||||
contextWindow: 200_000,
|
||||
@@ -1143,6 +1193,26 @@ export const doubaoModels = {
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
},
|
||||
"deepseek-v3-250324": {
|
||||
maxTokens: 12_288,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.55,
|
||||
outputPrice: 2.19,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
},
|
||||
"deepseek-r1-250120": {
|
||||
maxTokens: 32_768,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.27,
|
||||
outputPrice: 1.09,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Mistral
|
||||
@@ -1298,8 +1368,44 @@ export const askSageModels = {
|
||||
// X AI
|
||||
// https://docs.x.ai/docs/api-reference
|
||||
export type XAIModelId = keyof typeof xaiModels
|
||||
export const xaiDefaultModelId: XAIModelId = "grok-2-latest"
|
||||
export const xaiDefaultModelId: XAIModelId = "grok-3-beta"
|
||||
export const xaiModels = {
|
||||
"grok-3-beta": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
description: "X AI's Grok-3 beta model with 131K context window",
|
||||
},
|
||||
"grok-3-fast-beta": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
description: "X AI's Grok-3 fast beta model with 131K context window",
|
||||
},
|
||||
"grok-3-mini-beta": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 0.5,
|
||||
description: "X AI's Grok-3 mini beta model with 131K context window",
|
||||
},
|
||||
"grok-3-mini-fast-beta": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 4.0,
|
||||
description: "X AI's Grok-3 mini fast beta model with 131K context window",
|
||||
},
|
||||
"grok-2-latest": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 131072,
|
||||
@@ -1476,3 +1582,19 @@ export const sambanovaModels = {
|
||||
outputPrice: 1.5,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Requesty
|
||||
// https://requesty.ai/models
|
||||
export const requestyDefaultModelId = "anthropic/claude-3-7-sonnet-latest"
|
||||
export const requestyDefaultModelInfo: ModelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
description: "Anthropic's most intelligent model. Highest level of intelligence and capability.",
|
||||
}
|
||||
|
||||
@@ -105,3 +105,5 @@ export interface McpDownloadResponse {
|
||||
llmsInstallationContent: string
|
||||
requiresApiKey: boolean
|
||||
}
|
||||
|
||||
export type McpViewTab = "marketplace" | "addRemote" | "installed"
|
||||
|
||||
@@ -7,8 +7,8 @@ import { userInfo } from "os"
|
||||
describe("Shell Detection Tests", () => {
|
||||
let originalPlatform: string
|
||||
let originalEnv: NodeJS.ProcessEnv
|
||||
let originalGetConfig: any
|
||||
let originalUserInfo: any
|
||||
let originalGetConfig: typeof vscode.workspace.getConfiguration
|
||||
let originalUserInfo: typeof userInfo
|
||||
|
||||
// Helper to mock VS Code configuration
|
||||
function mockVsCodeConfig(platformKey: string, defaultProfileName: string | null, profiles: Record<string, any>) {
|
||||
|
||||
+49
-5
@@ -2,15 +2,48 @@ import { ModelInfo } from "../shared/api"
|
||||
|
||||
function calculateApiCostInternal(
|
||||
modelInfo: ModelInfo,
|
||||
inputTokens: number,
|
||||
inputTokens: number, // Note: For OpenAI-style, this is non-cached tokens. For Anthropic-style, this is total input tokens.
|
||||
outputTokens: number,
|
||||
cacheCreationInputTokens: number,
|
||||
cacheReadInputTokens: number,
|
||||
totalInputTokensForPricing?: number, // The *total* input tokens, used for tiered pricing lookup
|
||||
): number {
|
||||
// Determine effective input price
|
||||
let effectiveInputPrice = modelInfo.inputPrice || 0
|
||||
if (modelInfo.inputPriceTiers && modelInfo.inputPriceTiers.length > 0 && totalInputTokensForPricing !== undefined) {
|
||||
// Ensure tiers are sorted by tokenLimit ascending before finding
|
||||
const sortedInputTiers = [...modelInfo.inputPriceTiers].sort((a, b) => a.tokenLimit - b.tokenLimit)
|
||||
// Find the first tier where the total input tokens are less than or equal to the limit
|
||||
const tier = sortedInputTiers.find((t) => totalInputTokensForPricing! <= t.tokenLimit)
|
||||
if (tier) {
|
||||
effectiveInputPrice = tier.price
|
||||
} else {
|
||||
// Should ideally not happen if Infinity is used for the last tier, but fallback just in case
|
||||
effectiveInputPrice = sortedInputTiers[sortedInputTiers.length - 1]?.price || 0
|
||||
}
|
||||
}
|
||||
|
||||
// Determine effective output price (based on total *input* tokens for pricing)
|
||||
let effectiveOutputPrice = modelInfo.outputPrice || 0
|
||||
if (modelInfo.outputPriceTiers && modelInfo.outputPriceTiers.length > 0 && totalInputTokensForPricing !== undefined) {
|
||||
// Ensure tiers are sorted by tokenLimit ascending before finding
|
||||
const sortedOutputTiers = [...modelInfo.outputPriceTiers].sort((a, b) => a.tokenLimit - b.tokenLimit)
|
||||
const tier = sortedOutputTiers.find((t) => totalInputTokensForPricing! <= t.tokenLimit)
|
||||
if (tier) {
|
||||
effectiveOutputPrice = tier.price
|
||||
} else {
|
||||
// Should ideally not happen if Infinity is used for the last tier, but fallback just in case
|
||||
effectiveOutputPrice = sortedOutputTiers[sortedOutputTiers.length - 1]?.price || 0
|
||||
}
|
||||
}
|
||||
|
||||
const cacheWritesCost = ((modelInfo.cacheWritesPrice || 0) / 1_000_000) * cacheCreationInputTokens
|
||||
const cacheReadsCost = ((modelInfo.cacheReadsPrice || 0) / 1_000_000) * cacheReadInputTokens
|
||||
const baseInputCost = ((modelInfo.inputPrice || 0) / 1_000_000) * inputTokens
|
||||
const outputCost = ((modelInfo.outputPrice || 0) / 1_000_000) * outputTokens
|
||||
// Use effectiveInputPrice for baseInputCost. Note: 'inputTokens' here is the potentially adjusted count (e.g., non-cached for OpenAI)
|
||||
const baseInputCost = (effectiveInputPrice / 1_000_000) * inputTokens
|
||||
// Use effectiveOutputPrice for outputCost
|
||||
const outputCost = (effectiveOutputPrice / 1_000_000) * outputTokens
|
||||
|
||||
const totalCost = cacheWritesCost + cacheReadsCost + baseInputCost + outputCost
|
||||
return totalCost
|
||||
}
|
||||
@@ -25,7 +58,15 @@ export function calculateApiCostAnthropic(
|
||||
): number {
|
||||
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
|
||||
const cacheReadInputTokensNum = cacheReadInputTokens || 0
|
||||
return calculateApiCostInternal(modelInfo, inputTokens, outputTokens, cacheCreationInputTokensNum, cacheReadInputTokensNum)
|
||||
// Anthropic style doesn't need totalInputTokensForPricing as its inputTokens already represents the total
|
||||
return calculateApiCostInternal(
|
||||
modelInfo,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokensNum,
|
||||
cacheReadInputTokensNum,
|
||||
undefined, // Pass undefined for totalInputTokensForPricing
|
||||
)
|
||||
}
|
||||
|
||||
// For OpenAI compliant usage, the input tokens count INCLUDES the cached tokens
|
||||
@@ -38,12 +79,15 @@ export function calculateApiCostOpenAI(
|
||||
): number {
|
||||
const cacheCreationInputTokensNum = cacheCreationInputTokens || 0
|
||||
const cacheReadInputTokensNum = cacheReadInputTokens || 0
|
||||
// Calculate non-cached tokens for the internal function's 'inputTokens' parameter
|
||||
const nonCachedInputTokens = Math.max(0, inputTokens - cacheCreationInputTokensNum - cacheReadInputTokensNum)
|
||||
// Pass the original 'inputTokens' as 'totalInputTokensForPricing' for tier lookup
|
||||
return calculateApiCostInternal(
|
||||
modelInfo,
|
||||
nonCachedInputTokens,
|
||||
nonCachedInputTokens, // Pass the adjusted token count here
|
||||
outputTokens,
|
||||
cacheCreationInputTokensNum,
|
||||
cacheReadInputTokensNum,
|
||||
inputTokens, // Pass the original total input tokens for pricing tier lookup
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,3 +74,22 @@ export async function getFileSizeInKB(filePath: string): Promise<number> {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively reads a directory and returns an array of absolute file paths.
|
||||
*
|
||||
* @param directoryPath - The path to the directory to read.
|
||||
* @returns A promise that resolves to an array of absolute file paths.
|
||||
* @throws Error if the directory cannot be read.
|
||||
*/
|
||||
export const readDirectory = async (directoryPath: string) => {
|
||||
try {
|
||||
const filePaths = await fs
|
||||
.readdir(directoryPath, { withFileTypes: true, recursive: true })
|
||||
.then((files) => files.filter((file) => file.isFile()))
|
||||
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
|
||||
return filePaths
|
||||
} catch {
|
||||
throw new Error(`Error reading directory at ${directoryPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as path from "path"
|
||||
import os from "os"
|
||||
import * as vscode from "vscode"
|
||||
import { realpathSync } from "fs"
|
||||
|
||||
/*
|
||||
The Node.js 'path' module resolves and normalizes paths differently depending on the platform:
|
||||
@@ -110,3 +111,24 @@ export const getWorkspacePath = (defaultCwdPath = "") => {
|
||||
}
|
||||
return cwdPath
|
||||
}
|
||||
|
||||
export const isLocatedInWorkspace = (pathToCheck: string = ""): boolean => {
|
||||
const workspacePath = getWorkspacePath()
|
||||
|
||||
// Handle long paths in Windows
|
||||
if (pathToCheck.startsWith("\\\\?\\") || workspacePath.startsWith("\\\\?\\")) {
|
||||
return pathToCheck.startsWith(workspacePath)
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(workspacePath, pathToCheck)
|
||||
|
||||
// Using realpathSync to resolve any symbolic links
|
||||
try {
|
||||
const realWorkspacePath = realpathSync(workspacePath)
|
||||
const realPath = realpathSync(resolvedPath)
|
||||
return realPath.startsWith(realWorkspacePath)
|
||||
} catch (error) {
|
||||
console.error("Error resolving paths:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user