mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bcc12090a2 | |||
| 58b14c69b1 | |||
| b831100f20 | |||
| 23fa305eac | |||
| 0762e6406d | |||
| 94da9f6669 | |||
| fdeef9cece | |||
| dd055b3327 | |||
| e852c6953b | |||
| aef27eb1c4 | |||
| 863572031f | |||
| 65eee1ac6a | |||
| 8213b0b910 | |||
| f5fc3fed6f | |||
| 42df03177f | |||
| 648ae1b1fd | |||
| d3cff6ac47 | |||
| 13af435103 | |||
| 3b1477bc24 | |||
| 688f93db6d | |||
| a702270e85 | |||
| 03acada1b9 | |||
| 95cca05f5e | |||
| 33d77eb095 | |||
| 3e0c39acbb | |||
| 366d8a5411 | |||
| d22596e5c2 | |||
| 7cc612dbce | |||
| f0cab63a43 | |||
| 3c1b670b73 | |||
| 5de05d68ef | |||
| 9b6b1c376c | |||
| e5e26f45fa | |||
| b794583b7a | |||
| ca96bd8f62 | |||
| 0ecdf8d0cc | |||
| 5be6ba68a3 | |||
| 1bdd1e943f | |||
| c85a4abeb4 | |||
| 011b19225e | |||
| a3945dce7f | |||
| 8daca03996 | |||
| 777b8576f2 | |||
| 880755ec89 | |||
| 1c70089521 | |||
| fe2a8a9477 | |||
| eb2550ec3d | |||
| 9c3ac14cab | |||
| 6a8f900d75 | |||
| 407e472322 | |||
| 580b2e35e2 | |||
| 30c121509f | |||
| b3aee3857c | |||
| 10cbbb2c6b | |||
| 08365b3e0b | |||
| cb4c61b1ca | |||
| 2d9ff863b7 | |||
| ae9b20a12b | |||
| c16e271c14 | |||
| b940cef0e4 | |||
| bc228de20e | |||
| a9cac3206a |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Checkpoints multiroot pt.1: Accept array of workspaces when initializting checkpoints
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: automatically retry on rate limit errors with SAP AI Core provider
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Run Testing platform within Test workflow
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat: preserve reasoning traces for cline/openrouter/anthropic providers to maintain conversation integrity
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
remove temperature settings in z.ai models
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Empty Pr to bump changeset
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @dcbartlett
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/.github/ @saoudrizwan
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: jetbrains-trigger-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: 1998650
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
- name: Trigger IntelliJ Plugin Integration Test
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
-H "User-Agent: cline-pr-trigger" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://api.github.com/repos/cline/intellij-plugin/dispatches \
|
||||
-d '{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"branch_name": "${{ github.head_ref }}",
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"pr_title": "${{ github.event.pull_request.title }}",
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Log trigger details
|
||||
run: |
|
||||
echo "Triggered IntelliJ Plugin integration test for:"
|
||||
echo " PR #${{ github.event.number }}: ${{ github.event.pull_request.title }}"
|
||||
echo " Branch: ${{ github.head_ref }}"
|
||||
echo " Action: ${{ github.event.action }}"
|
||||
echo " SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## [3.32.0]
|
||||
|
||||
- Added the new code-supernova-1-million stealth model, available for free and delivering a 1 million token context window
|
||||
- Changes to inform Cline about commands that are available on your system
|
||||
|
||||
## [3.31.1]
|
||||
|
||||
- Version bump
|
||||
|
||||
## [3.31.0]
|
||||
|
||||
- UI Improvements: New task header and focus chain design to take up less space for a cleaner experience
|
||||
- Voice Mode: Experimental feature that must be enabled in settings for hands-free coding
|
||||
- YOLO Mode: Enable in settings to let Cline approve all actions and automatically switch between plan/act mode
|
||||
- Fix Oracle Code Assist provider issues
|
||||
|
||||
## [3.30.3]
|
||||
|
||||
- Add Oracle Code Assist provider
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
"features/focus-chain",
|
||||
"features/auto-compact",
|
||||
"features/editing-messages",
|
||||
"features/dictation",
|
||||
{
|
||||
"group": "@ Mentions",
|
||||
"pages": [
|
||||
@@ -121,6 +122,13 @@
|
||||
"features/commands-and-shortcuts/git-integration",
|
||||
"features/commands-and-shortcuts/keyboard-shortcuts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Customization",
|
||||
"pages": [
|
||||
"features/customization/opening-cline-in-sidebar",
|
||||
"features/customization/disable-terminal-pagers"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "Disable Terminal Pagers During Cline Sessions"
|
||||
description: "Make CLI output non-interactive when Cline runs commands by detecting the CLINE_ACTIVE environment variable and disabling pagers like less."
|
||||
---
|
||||
|
||||
Many CLI tools (like Git) use a pager such as `less` for interactive, scrollable output. When Cline runs commands in your terminal, that interactivity gets in the way — the pager can pause on the first page and block progress. You can configure your shell so that when a terminal is spawned by Cline, pagers are disabled and output streams through normally.
|
||||
|
||||
## How it works
|
||||
|
||||
Cline sets an environment variable for terminals it opens to run commands:
|
||||
|
||||
- `CLINE_ACTIVE` — non-empty when the shell is running under Cline
|
||||
|
||||
You can detect this variable in your shell startup file and adjust environment variables or aliases only for Cline-run sessions. This keeps your normal interactive terminals unchanged.
|
||||
|
||||
## Quick setup (Zsh/Bash)
|
||||
|
||||
Add the following to your `~/.zshrc`, `~/.bashrc`, or `~/.bash_profile`:
|
||||
|
||||
```bash
|
||||
# Disable pagers when the terminal is launched by Cline
|
||||
if [[ -n "$CLINE_ACTIVE" ]]; then
|
||||
export PAGER=cat
|
||||
export GIT_PAGER=cat
|
||||
export SYSTEMD_PAGER=cat
|
||||
export LESS="-FRX"
|
||||
fi
|
||||
```
|
||||
|
||||
<Note>
|
||||
- `PAGER=cat` ensures generic pager-aware tools print directly to stdout
|
||||
- `GIT_PAGER=cat` prevents Git from invoking `less`
|
||||
- `SYSTEMD_PAGER=cat` disables paging in systemd tools (if present)
|
||||
- `LESS="-FRX"` makes `less` behave more like streaming output if a tool still calls it
|
||||
</Note>
|
||||
|
||||
This configuration only applies when `CLINE_ACTIVE` is set, so your normal terminals keep their usual interactive behavior.
|
||||
|
||||
## Verify
|
||||
|
||||
- Open a task in Cline that runs terminal commands and check:
|
||||
- `echo "$CLINE_ACTIVE"` prints a non-empty value
|
||||
- `git log` or other long outputs should stream without pausing
|
||||
- If changes don't take effect:
|
||||
- Make sure you updated the correct startup file for your shell
|
||||
- Restart VS Code/Cursor so integrated terminals reload your shell config
|
||||
- Confirm your terminal profile sources your `~/.zshrc` or `~/.bashrc`
|
||||
|
||||
## Optional tweaks
|
||||
|
||||
- Prefer command-line options when you don't want to rely on env vars:
|
||||
|
||||
```bash
|
||||
# One-off usage (no aliases)
|
||||
git --no-pager log -n 50 --decorate --oneline
|
||||
systemctl --no-pager status nginx
|
||||
journalctl --no-pager -u nginx -n 200
|
||||
less -FRX README.md
|
||||
```
|
||||
|
||||
- You can also override paging via shell aliases scoped to Cline sessions using options rather than env vars:
|
||||
|
||||
```bash
|
||||
if [[ -n "$CLINE_ACTIVE" ]]; then
|
||||
# Make 'less' non-interactive by default
|
||||
alias less='less -FRX'
|
||||
# Disable paging for common tools via CLI flags
|
||||
alias git='command git --no-pager'
|
||||
alias systemctl='command systemctl --no-pager'
|
||||
alias journalctl='command journalctl --no-pager'
|
||||
fi
|
||||
```
|
||||
|
||||
- If you prefer environment variables, many CLIs also respect a generic or tool-specific pager variable:
|
||||
- Git: `GIT_PAGER=cat`
|
||||
- Systemd: `SYSTEMD_PAGER=cat`
|
||||
- Man pages: `MANPAGER=cat` (not typically needed for Cline-driven commands)
|
||||
|
||||
- Aliases affect the current interactive shell, while environment variables propagate to child processes. Choose the approach that best fits your workflow.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: "Opening Cline in the Right Sidebar"
|
||||
description: "Learn how to open Cline in the right sidebar in VS Code and Cursor"
|
||||
---
|
||||
|
||||
By default, when you first install Cline, it appears in VS Code's left sidebar alongside your file explorer and other extensions. However, for a better coding experience, we recommend moving Cline to the right sidebar. This allows you to keep your project files visible in the left sidebar while chatting with Cline on the right, giving you full visibility of your codebase as Cline works on your project.
|
||||
|
||||
## VS Code
|
||||
|
||||
To open Cline in the right sidebar:
|
||||
|
||||
<Steps>
|
||||
<Step title="Align Extension View">
|
||||
Make sure your extension view is aligned vertically to the left
|
||||
</Step>
|
||||
<Step title="Open Right Side View">
|
||||
Click the button that opens the right side panel in VS Code (typically used to open GitHub Copilot chat). Optionally use the `Option + CMD/Ctrl + B` shortcut.
|
||||
</Step>
|
||||
<Step title="Drag Cline Icon">
|
||||
Drag the Cline icon over to the nav panel at the top of that right view
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
src="https://storage.googleapis.com/cline_public_images/vscode_right_view.gif"
|
||||
alt="VS Code Right Sidebar Setup"
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
## Cursor
|
||||
|
||||
To open Cline in the right sidebar:
|
||||
|
||||
<Steps>
|
||||
<Step title="Align Extensions">
|
||||
Cursor uses a horizontal activity bar by default to optimize space for the AI chat interface ([see here for details](https://cursor.com/docs/configuration/migrations/vscode#activity-bar-orientation)). To switch to vertical:
|
||||
|
||||
1. Open the Command Palette (`CMD/Ctrl + Shift + P`)
|
||||
2. Search for "Preferences: Open Settings (UI)"
|
||||
3. Search for `workbench.activityBar.orientation`
|
||||
4. Set the value to `vertical`
|
||||
5. Restart Cursor for the changes to take effect
|
||||
</Step>
|
||||
<Step title="Open Agent Panel">
|
||||
Click the Cursor cube icon button that opens Cursor's agent (right side view panel)
|
||||
</Step>
|
||||
<Step title="Drag to Three Dots">
|
||||
Drag the Cline icon directly onto the three dots button - it doesn't work if you just drag it to the top, it has to be the three dots
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Frame>
|
||||
<img
|
||||
src="https://storage.googleapis.com/cline_public_images/cursor-side-bar.gif"
|
||||
alt="Cursor Right Sidebar Setup"
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
Once set up, Cline will load on the right side and you can use it as normal.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
title: Dictation
|
||||
description:
|
||||
---
|
||||
|
||||
Cline lets you transcribe speech to text in an easy, built-in service
|
||||
|
||||
## Get Started
|
||||
|
||||
1. **Enable Dictation** in Feature Settings.
|
||||
2. **Click the microphone** in the chat input area.
|
||||
3. **Speak** - the button turns red while recording.
|
||||
4. **Click Stop Recording** when done.
|
||||
5. **Wait for transcription** - "[Transcribing...]" will appear and then the finished transcription will appear.
|
||||
|
||||
## Settings
|
||||
|
||||
Enable or disable dictation in Feature Settings by toggling "Enable Dictation." You can also change the transcription language to one of the supported languages.
|
||||
|
||||
## Requirements
|
||||
|
||||
Cline uses FFmpeg to capture your voice across all platforms:
|
||||
|
||||
- **macOS**: FFmpeg (via Homebrew: `brew install ffmpeg`)
|
||||
- **Linux**: FFmpeg (via apt: `sudo apt-get install ffmpeg`)
|
||||
- **Windows**: FFmpeg (via winget: `winget install Gyan.FFmpeg`)
|
||||
|
||||
If you don't have FFmpeg installed, Cline will automatically detect this and prompt you to install it with a single click.
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Independent from Chat Provider
|
||||
|
||||
The voice transcription feature works completely independently from whatever chat provider you're using. You can use Claude, GPT-4, or any other model for your conversations, and voice transcription will always use Cline's own transcription service. As long as you have a valid Cline account with enough credits, dictation will work regardless of your chat model choice.
|
||||
|
||||
### Audio Format
|
||||
|
||||
Voice recordings are captured in WebM format using the Opus codec for optimal compression. The system records in mono (single channel) at a 16kHz sample rate, which is specifically optimized for voice recognition. The bitrate is set to 32kbps to keep file sizes efficient while maintaining good audio quality.
|
||||
|
||||
### Privacy & Security
|
||||
|
||||
Your audio is recorded locally on your machine and only the audio file itself is sent to Cline's transcription service for processing. No audio is stored anywhere after transcription is complete, and all temporary files are automatically cleaned up to protect your privacy.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
`Failed to start recording` - Audio recording tools aren't installed. Cline will prompt you to install FFmpeg - just follow the chat instructions.
|
||||
|
||||
`Invalid audio format or request data` - Usually an audio recording issue. Make sure FFmpeg is properly installed and working.
|
||||
|
||||
`Authentication failed` - You need to reauthenticate your Cline account. Sign out and back in, then check your internet connection.
|
||||
|
||||
`Insufficient credits for transcription service` - Your Cline account doesn't have enough credits. Check your balance and purchase more if needed.
|
||||
|
||||
`Cannot connect to transcription service` - Connection issue. Check your internet connection and firewall settings aren't blocking Cline's servers.
|
||||
|
||||
## API Usage
|
||||
|
||||
Voice transcription uses Cline's transcription service, which requires credits from your Cline account. Currently, voice transcription is billed at $0.006 per minute of audio.
|
||||
|
||||
**Note:** We are still experimenting with this feature and pricing may change in the future.
|
||||
@@ -7,7 +7,7 @@ title: "Configuring MCP Servers"
|
||||
Utilizing MCP servers will increase your token usage. Cline offers the ability to restrict or disable MCP server functionality as desired.
|
||||
|
||||
1. Click the "MCP Servers" icon in the top navigation bar of the Cline extension.
|
||||
2. Select the "Installed" tab, and then Click the "Advanced MCP Settings" link at the bottom of that pane.
|
||||
2. Select the "Configure" tab, and then Click the "Advanced MCP Settings" link at the bottom of that pane.
|
||||
3. Cline will open a new settings window. find `Cline>Mcp:Mode` and make your selection from the dropdown menu.
|
||||
|
||||
<Frame>
|
||||
@@ -56,7 +56,7 @@ To set the maximum time to wait for a response after a tool call to the MCP serv
|
||||
Settings for all installed MCP servers are located in the `cline_mcp_settings.json` file:
|
||||
|
||||
1. Click the MCP Servers icon at the top navigation bar of the Cline pane.
|
||||
2. Select the "Installed" tab.
|
||||
2. Select the "Configure" tab.
|
||||
3. Click the "Configure MCP Servers" button at the bottom of the pane.
|
||||
|
||||
The file uses a JSON format with a `mcpServers` object containing named server configurations:
|
||||
|
||||
@@ -54,7 +54,7 @@ Please note: Smithery is maintained independently and is not affiliated with our
|
||||
|
||||
### Managing Installed MCP Servers
|
||||
|
||||
Once added, your MCP servers appear in the "Installed" tab where you can:
|
||||
Once added, your MCP servers appear in the "Configure" tab where you can:
|
||||
|
||||
#### View Server Status
|
||||
|
||||
@@ -98,7 +98,7 @@ If a server fails to connect:
|
||||
|
||||
For advanced users, Cline stores MCP server configurations in a JSON file that can be modified:
|
||||
|
||||
1. In the "Installed" tab, click "Configure MCP Servers" to access the settings file
|
||||
1. In the "Configure" tab, click "Configure MCP Servers" to access the settings file
|
||||
2. The configuration for each server follows this format:
|
||||
|
||||
```json
|
||||
|
||||
Generated
+4
-4
@@ -1386,7 +1386,7 @@
|
||||
"integrity": "sha512-/uR4hAwpcJW9+zbmZL48kKFnWLkOxhIqoGWvZzjg0CniVhR4emtQJAps80WqLAhz0iJgCQxg/axtA7leaznDzQ==",
|
||||
"license": "Elastic-2.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.3",
|
||||
"axios": "^1.12.0",
|
||||
"openapi-types": "^12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2688,9 +2688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.11.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
|
||||
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
|
||||
Generated
+10
-9
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"chalk": "5.6.2",
|
||||
"commander": "^9.4.1",
|
||||
@@ -200,12 +200,13 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -1667,12 +1668,12 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"axios": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"chalk": "5.6.2",
|
||||
"dotenv": "^16.5.0",
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.30.3",
|
||||
"version": "3.32.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.30.3",
|
||||
"version": "3.32.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"chrome-launcher": "^1.1.2",
|
||||
|
||||
+7
-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.30.3",
|
||||
"version": "3.32.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -200,6 +200,11 @@
|
||||
"command": "cline.openWalkthrough",
|
||||
"title": "Open Walkthrough",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.reconstructTaskHistory",
|
||||
"title": "Reconstruct Task History",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
@@ -452,7 +457,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.8.2",
|
||||
"axios": "^1.12.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"chrome-launcher": "^1.1.2",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
import "cline/common.proto";
|
||||
option java_package = "bot.cline.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
service DictationService {
|
||||
rpc startRecording(EmptyRequest) returns (RecordingResult);
|
||||
rpc stopRecording(EmptyRequest) returns (RecordedAudio);
|
||||
rpc cancelRecording(EmptyRequest) returns (RecordingResult);
|
||||
rpc getRecordingStatus(EmptyRequest) returns (RecordingStatus);
|
||||
rpc transcribeAudio(TranscribeAudioRequest) returns (Transcription);
|
||||
}
|
||||
|
||||
message TranscribeAudioRequest {
|
||||
string audio_base64 = 2;
|
||||
string language = 3;
|
||||
}
|
||||
|
||||
message RecordingResult {
|
||||
bool success = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message RecordedAudio {
|
||||
bool success = 1;
|
||||
string audio_base64 = 2;
|
||||
string error = 3;
|
||||
}
|
||||
|
||||
message RecordingStatus {
|
||||
bool is_recording = 1;
|
||||
double duration_seconds = 2;
|
||||
string error = 3;
|
||||
}
|
||||
|
||||
message Transcription {
|
||||
string text = 1;
|
||||
string error = 2;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ service FileService {
|
||||
rpc refreshRules(EmptyRequest) returns (RefreshedRules);
|
||||
|
||||
// Opens a task's conversation history file on disk
|
||||
rpc openTaskHistory(StringRequest) returns (Empty);
|
||||
rpc openDiskConversationHistory(StringRequest) returns (Empty);
|
||||
|
||||
// Toggles a workflow on or off
|
||||
rpc toggleWorkflow(ToggleWorkflowRequest) returns (ClineRulesToggles);
|
||||
|
||||
@@ -18,8 +18,13 @@ service StateService {
|
||||
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
|
||||
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
|
||||
rpc setWelcomeViewCompleted(BooleanRequest) returns (Empty);
|
||||
rpc updateInfoBannerVersion(Int64Request) returns (Empty);
|
||||
}
|
||||
message DictationSettings {
|
||||
bool feature_enabled = 1;
|
||||
bool dictation_enabled = 2;
|
||||
string dictation_language = 3;
|
||||
}
|
||||
|
||||
message State {
|
||||
string state_json = 1;
|
||||
}
|
||||
@@ -140,6 +145,9 @@ message UpdateSettingsRequest {
|
||||
optional BrowserSettingsUpdate browser_settings = 20;
|
||||
optional string default_terminal_profile = 21;
|
||||
optional bool yolo_mode_toggled = 22;
|
||||
optional DictationSettings dictation_settings = 23;
|
||||
optional int32 auto_condense_threshold = 24;
|
||||
optional bool multi_root_enabled = 25;
|
||||
}
|
||||
|
||||
// Complete API Configuration message
|
||||
|
||||
@@ -13,6 +13,7 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
|
||||
import { ErrorService } from "./services/error"
|
||||
import { featureFlagsService } from "./services/feature-flags"
|
||||
import { initializeDistinctId } from "./services/logging/distinctId"
|
||||
@@ -99,6 +100,9 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
|
||||
* Performs cleanup when Cline is deactivated that is common to all platforms.
|
||||
*/
|
||||
export async function tearDown(): Promise<void> {
|
||||
// Clean up audio recording service to ensure no orphaned processes
|
||||
audioRecordingService.cleanup()
|
||||
|
||||
PostHogClientProvider.getInstance().dispose()
|
||||
telemetryService.dispose()
|
||||
ErrorService.get().dispose()
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ export enum Environment {
|
||||
local = "local",
|
||||
}
|
||||
|
||||
interface EnvironmentConfig {
|
||||
export interface EnvironmentConfig {
|
||||
appBaseUrl: string
|
||||
apiBaseUrl: string
|
||||
mcpBaseUrl: string
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface ApiProviderInfo {
|
||||
providerId: string
|
||||
model: ApiHandlerModel
|
||||
customPrompt?: string // "compact"
|
||||
autoCondenseThreshold?: number // 0-1 range
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
|
||||
@@ -150,6 +150,8 @@ export class AnthropicHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
let thinkingDeltaAccumulator = ""
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk?.type) {
|
||||
case "message_start":
|
||||
@@ -182,14 +184,26 @@ export class AnthropicHandler implements ApiHandler {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
const thinking = chunk.content_block.thinking
|
||||
const signature = chunk.content_block.signature
|
||||
if (thinking && signature) {
|
||||
yield {
|
||||
type: "ant_thinking",
|
||||
thinking,
|
||||
signature,
|
||||
}
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
// Content is encrypted, and we don't to pass placeholder text back to the API
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
yield {
|
||||
type: "ant_redacted_thinking",
|
||||
data: chunk.content_block.data,
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
// we may receive multiple text blocks, in which case just insert a line break between them
|
||||
@@ -209,10 +223,23 @@ export class AnthropicHandler implements ApiHandler {
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
// 'reasoning' type just displays in the UI, but ant_thinking will be used to send the thinking traces back to the API
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
thinkingDeltaAccumulator += chunk.delta.thinking
|
||||
break
|
||||
case "signature_delta":
|
||||
// It's used when sending the thinking block back to the API
|
||||
// API expects this in completed form, not as array of deltas
|
||||
if (thinkingDeltaAccumulator && chunk.delta.signature) {
|
||||
yield {
|
||||
type: "ant_thinking",
|
||||
thinking: thinkingDeltaAccumulator,
|
||||
signature: chunk.delta.signature,
|
||||
}
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
@@ -220,10 +247,6 @@ export class AnthropicHandler implements ApiHandler {
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
case "signature_delta":
|
||||
// We don't need to do anything with the signature in the client
|
||||
// It's used when sending the thinking block back to the API
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_stop":
|
||||
|
||||
@@ -162,7 +162,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-ignore-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
|
||||
if (this.getModel().id === "cline/code-supernova") {
|
||||
if (this.getModel().id === "cline/code-supernova-1-million") {
|
||||
totalCost = 0
|
||||
}
|
||||
|
||||
@@ -200,14 +200,13 @@ export class ClineHandler implements ApiHandler {
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
// TODO: replace this with firebase auth
|
||||
// TODO: use global API Host
|
||||
const clineAccountAuthToken = await this._authService.getAuthToken()
|
||||
if (!clineAccountAuthToken) {
|
||||
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
}
|
||||
const response = await axios.get(`${this.clineAccountService.baseUrl}/generation?id=${this.lastGenerationId}`, {
|
||||
headers: {
|
||||
// Align with backend auth expectations
|
||||
Authorization: `Bearer ${clineAccountAuthToken}`,
|
||||
},
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
|
||||
@@ -122,6 +122,21 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter passes reasoning details that we can pass back unmodified in api requests to preserve reasoning traces for model
|
||||
// See: https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
|
||||
if (
|
||||
"reasoning_details" in delta &&
|
||||
delta.reasoning_details &&
|
||||
// @ts-ignore-next-line
|
||||
delta.reasoning_details.length && // exists and non-0
|
||||
!shouldSkipReasoningForModel(this.options.openRouterModelId)
|
||||
) {
|
||||
yield {
|
||||
type: "reasoning_details",
|
||||
reasoning_details: delta.reasoning_details,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ModelInfo, SapAiCoreModelId, sapAiCoreDefaultModelId, sapAiCoreModels }
|
||||
import axios from "axios"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
@@ -454,6 +455,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
return this.deployments?.some((d) => d.name.split(":")[0].toLowerCase() === modelId.split(":")[0].toLowerCase()) ?? false
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
if (this.options.sapAiCoreUseOrchestrationMode) {
|
||||
yield* this.createMessageWithOrchestration(systemPrompt, messages)
|
||||
@@ -823,16 +825,13 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
// Handle metadata (token usage)
|
||||
if (data.metadata?.usage) {
|
||||
// inputTokens does not include cached write/read tokens
|
||||
let inputTokens = data.metadata.usage.inputTokens || 0
|
||||
const outputTokens = data.metadata.usage.outputTokens || 0
|
||||
|
||||
// calibrate input token
|
||||
const totalTokens = data.metadata.usage.totalTokens || 0
|
||||
const cacheReadInputTokens = data.metadata.usage.cacheReadInputTokens || 0
|
||||
const cacheWriteOutputTokens = data.metadata.usage.cacheWriteOutputTokens || 0
|
||||
if (inputTokens + outputTokens + cacheReadInputTokens + cacheWriteOutputTokens !== totalTokens) {
|
||||
inputTokens = totalTokens - outputTokens - cacheReadInputTokens - cacheWriteOutputTokens
|
||||
}
|
||||
const cacheWriteInputTokens = data.metadata.usage.cacheWriteInputTokens || 0
|
||||
inputTokens = inputTokens + cacheReadInputTokens + cacheWriteInputTokens
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
|
||||
@@ -115,7 +115,15 @@ export function convertToOpenAiMessages(
|
||||
|
||||
// Process non-tool messages
|
||||
let content: string | undefined
|
||||
const reasoningDetails: any[] = []
|
||||
if (nonToolMessages.length > 0) {
|
||||
nonToolMessages.forEach((part) => {
|
||||
// @ts-ignore-next-line
|
||||
if (part.type === "text" && part.reasoning_details) {
|
||||
// @ts-ignore-next-line
|
||||
reasoningDetails.push(part.reasoning_details)
|
||||
}
|
||||
})
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
@@ -142,6 +150,8 @@ export function convertToOpenAiMessages(
|
||||
content,
|
||||
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
|
||||
tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
|
||||
// @ts-ignore-next-line
|
||||
reasoning_details: reasoningDetails,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
export type ApiStream = AsyncGenerator<ApiStreamChunk>
|
||||
export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamReasoningChunk | ApiStreamUsageChunk
|
||||
export type ApiStreamChunk =
|
||||
| ApiStreamTextChunk
|
||||
| ApiStreamReasoningChunk
|
||||
| ApiStreamReasoningDetailsChunk
|
||||
| ApiStreamAnthropicThinkingChunk
|
||||
| ApiStreamAnthropicRedactedThinkingChunk
|
||||
| ApiStreamUsageChunk
|
||||
|
||||
export interface ApiStreamTextChunk {
|
||||
type: "text"
|
||||
@@ -11,6 +17,22 @@ export interface ApiStreamReasoningChunk {
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
export interface ApiStreamReasoningDetailsChunk {
|
||||
type: "reasoning_details"
|
||||
reasoning_details: any // openrouter has various properties that we can pass back unmodified in api requests to preserve reasoning traces
|
||||
}
|
||||
|
||||
export interface ApiStreamAnthropicThinkingChunk {
|
||||
type: "ant_thinking"
|
||||
thinking: string
|
||||
signature: string
|
||||
}
|
||||
|
||||
export interface ApiStreamAnthropicRedactedThinkingChunk {
|
||||
type: "ant_redacted_thinking"
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface ApiStreamUsageChunk {
|
||||
type: "usage"
|
||||
inputTokens: number
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
ensureTaskDirectoryExists,
|
||||
getSavedClineMessages,
|
||||
getTaskMetadata,
|
||||
readTaskHistoryFromState,
|
||||
writeTaskHistoryToState,
|
||||
} from "@core/storage/disk"
|
||||
import { HostProvider } from "@hosts/host-provider"
|
||||
import { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { ShowMessageType } from "@shared/proto/host/window"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import * as path from "path"
|
||||
import { ulid } from "ulid"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
interface TaskReconstructionResult {
|
||||
totalTasks: number
|
||||
reconstructedTasks: number
|
||||
skippedTasks: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstructs task history from existing task folders
|
||||
*/
|
||||
export async function reconstructTaskHistory(context: vscode.ExtensionContext): Promise<void> {
|
||||
try {
|
||||
// Show confirmation dialog using HostProvider
|
||||
const proceed = await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message:
|
||||
"This will rebuild your task history from existing task data. This operation will backup your current task history and attempt to reconstruct it from task folders. Continue?",
|
||||
options: {
|
||||
items: ["Yes, Reconstruct", "Cancel"],
|
||||
},
|
||||
})
|
||||
|
||||
if (proceed?.selectedOption !== "Yes, Reconstruct") {
|
||||
return
|
||||
}
|
||||
|
||||
// Show initial progress message
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: "Reconstructing task history...",
|
||||
})
|
||||
|
||||
const result = await performTaskHistoryReconstruction(context)
|
||||
|
||||
// Show results
|
||||
if (result.errors.length > 0) {
|
||||
const errorMessage = `Reconstruction completed with warnings:\n- Reconstructed: ${result.reconstructedTasks} tasks\n- Skipped: ${result.skippedTasks} tasks\n- Errors: ${result.errors.length}\n\nFirst few errors:\n${result.errors.slice(0, 3).join("\n")}`
|
||||
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.WARNING,
|
||||
message: errorMessage,
|
||||
})
|
||||
} else {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: `Task history successfully reconstructed! Found and restored ${result.reconstructedTasks} tasks.`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Failed to reconstruct task history: ${errorMessage}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function performTaskHistoryReconstruction(context: vscode.ExtensionContext): Promise<TaskReconstructionResult> {
|
||||
const result: TaskReconstructionResult = {
|
||||
totalTasks: 0,
|
||||
reconstructedTasks: 0,
|
||||
skippedTasks: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
// Backup existing task history
|
||||
await backupExistingTaskHistory(context)
|
||||
|
||||
// Get tasks directory
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
// Check if tasks directory exists
|
||||
if (!(await fileExistsAtPath(tasksDir))) {
|
||||
throw new Error("No tasks directory found. Nothing to reconstruct.")
|
||||
}
|
||||
|
||||
// Scan for task directories
|
||||
const taskIds = await scanTaskDirectories(tasksDir)
|
||||
result.totalTasks = taskIds.length
|
||||
|
||||
if (taskIds.length === 0) {
|
||||
throw new Error("No task directories found. Nothing to reconstruct.")
|
||||
}
|
||||
|
||||
// Process each task
|
||||
const reconstructedItems: HistoryItem[] = []
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
try {
|
||||
const historyItem = await reconstructTaskHistoryItem(context, taskId)
|
||||
if (historyItem) {
|
||||
reconstructedItems.push(historyItem)
|
||||
result.reconstructedTasks++
|
||||
} else {
|
||||
result.skippedTasks++
|
||||
}
|
||||
} catch (error) {
|
||||
result.skippedTasks++
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
result.errors.push(`Task ${taskId}: ${errorMsg}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp (newest first)
|
||||
reconstructedItems.sort((a, b) => b.ts - a.ts)
|
||||
|
||||
// Write reconstructed history
|
||||
await writeTaskHistoryToState(reconstructedItems)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function backupExistingTaskHistory(context: vscode.ExtensionContext): Promise<void> {
|
||||
try {
|
||||
const existingHistory = await readTaskHistoryFromState()
|
||||
if (existingHistory.length > 0) {
|
||||
const backupPath = path.join(context.globalStorageUri.fsPath, "state", `taskHistory.backup.${Date.now()}.json`)
|
||||
|
||||
// Ensure state directory exists
|
||||
const fs = await import("fs/promises")
|
||||
await fs.mkdir(path.dirname(backupPath), { recursive: true })
|
||||
await fs.writeFile(backupPath, JSON.stringify(existingHistory, null, 2))
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal error, just log it
|
||||
console.warn("Failed to backup existing task history:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function scanTaskDirectories(tasksDir: string): Promise<string[]> {
|
||||
const fs = await import("fs/promises")
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((name) => /^\d+$/.test(name)) // Only numeric task IDs
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to scan tasks directory: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function reconstructTaskHistoryItem(context: vscode.ExtensionContext, taskId: string): Promise<HistoryItem | null> {
|
||||
try {
|
||||
// Get task directory
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
|
||||
// Load UI messages to extract task info
|
||||
const clineMessages = await getSavedClineMessages(context, taskId)
|
||||
if (clineMessages.length === 0) {
|
||||
return null // Skip empty tasks
|
||||
}
|
||||
|
||||
// Load task metadata for token usage
|
||||
const metadata = await getTaskMetadata(context, taskId)
|
||||
|
||||
// Extract task information
|
||||
const taskInfo = extractTaskInformation(clineMessages, metadata)
|
||||
|
||||
// Create HistoryItem
|
||||
const historyItem: HistoryItem = {
|
||||
id: taskId,
|
||||
ulid: taskInfo.ulid || ulid(), // Generate new ULID if missing
|
||||
ts: taskInfo.timestamp,
|
||||
task: taskInfo.taskDescription,
|
||||
tokensIn: taskInfo.tokensIn,
|
||||
tokensOut: taskInfo.tokensOut,
|
||||
cacheWrites: taskInfo.cacheWrites,
|
||||
cacheReads: taskInfo.cacheReads,
|
||||
totalCost: taskInfo.totalCost,
|
||||
size: taskInfo.size,
|
||||
isFavorited: taskInfo.isFavorited,
|
||||
conversationHistoryDeletedRange: taskInfo.conversationHistoryDeletedRange,
|
||||
}
|
||||
|
||||
return historyItem
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to reconstruct task ${taskId}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskInfo {
|
||||
ulid?: string
|
||||
timestamp: number
|
||||
taskDescription: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
cacheWrites?: number
|
||||
cacheReads?: number
|
||||
totalCost: number
|
||||
size?: number
|
||||
isFavorited?: boolean
|
||||
conversationHistoryDeletedRange?: [number, number]
|
||||
}
|
||||
|
||||
function extractTaskInformation(clineMessages: ClineMessage[], metadata: any): TaskInfo {
|
||||
// Find the first user message (task description)
|
||||
const firstUserMessage = clineMessages.find((msg) => msg.type === "say" && msg.say === "text" && msg.text)
|
||||
|
||||
// Extract timestamp from first message or use task ID as fallback
|
||||
const timestamp = clineMessages.length > 0 ? clineMessages[0].ts : Date.now()
|
||||
|
||||
// Extract task description
|
||||
let taskDescription = "Untitled Task"
|
||||
if (firstUserMessage?.text) {
|
||||
// Clean up the task description
|
||||
const cleanText = firstUserMessage.text
|
||||
.replace(/<task>\s*/g, "")
|
||||
.replace(/\s*<\/task>/g, "")
|
||||
.trim()
|
||||
|
||||
const firstLine = cleanText.split("\n")[0]
|
||||
if (firstLine) {
|
||||
taskDescription = firstLine.substring(0, 100) // Limit length
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate token usage from API request messages
|
||||
let tokensIn = 0
|
||||
let tokensOut = 0
|
||||
let cacheWrites = 0
|
||||
let cacheReads = 0
|
||||
let totalCost = 0
|
||||
|
||||
// Look for api_req_started messages with token info
|
||||
const apiReqMessages = clineMessages.filter((msg) => msg.type === "say" && msg.say === "api_req_started" && msg.text)
|
||||
|
||||
for (const msg of apiReqMessages) {
|
||||
try {
|
||||
if (msg.text) {
|
||||
const apiInfo = JSON.parse(msg.text)
|
||||
if (apiInfo.tokensIn) tokensIn += apiInfo.tokensIn
|
||||
if (apiInfo.tokensOut) tokensOut += apiInfo.tokensOut
|
||||
if (apiInfo.cacheWrites) cacheWrites += apiInfo.cacheWrites
|
||||
if (apiInfo.cacheReads) cacheReads += apiInfo.cacheReads
|
||||
if (apiInfo.cost) totalCost += apiInfo.cost
|
||||
}
|
||||
} catch {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
// Use metadata if available and no tokens found in messages
|
||||
if (tokensIn === 0 && tokensOut === 0 && metadata.model_usage) {
|
||||
for (const usage of metadata.model_usage) {
|
||||
tokensIn += usage.tokensIn || 0
|
||||
tokensOut += usage.tokensOut || 0
|
||||
cacheWrites += usage.cacheWrites || 0
|
||||
cacheReads += usage.cacheReads || 0
|
||||
totalCost += usage.totalCost || 0
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate approximate size (rough estimate)
|
||||
const messageSize = JSON.stringify(clineMessages).length
|
||||
const size = Math.floor(messageSize / 1024) // KB
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
taskDescription,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
cacheWrites: cacheWrites > 0 ? cacheWrites : undefined,
|
||||
cacheReads: cacheReads > 0 ? cacheReads : undefined,
|
||||
totalCost,
|
||||
size,
|
||||
}
|
||||
}
|
||||
@@ -108,15 +108,22 @@ export class ContextManager {
|
||||
/**
|
||||
* Determine whether we should compact context window, based on token counts
|
||||
*/
|
||||
shouldCompactContextWindow(clineMessages: ClineMessage[], api: ApiHandler, previousApiReqIndex: number): boolean {
|
||||
shouldCompactContextWindow(
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
previousApiReqIndex: number,
|
||||
thresholdPercentage?: number,
|
||||
): boolean {
|
||||
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)
|
||||
return totalTokens >= maxAllowedSize
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
const roundedThreshold = thresholdPercentage ? Math.floor(contextWindow * thresholdPercentage) : maxAllowedSize
|
||||
const thresholdTokens = Math.min(roundedThreshold, maxAllowedSize)
|
||||
return totalTokens >= thresholdTokens
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -285,7 +285,7 @@ export class FileContextTracker {
|
||||
static async cleanupOrphanedWarnings(context: vscode.ExtensionContext): Promise<void> {
|
||||
const startTime = Date.now()
|
||||
try {
|
||||
const taskHistory = await readTaskHistoryFromState(context)
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
const existingTaskIds = new Set(taskHistory.map((task) => task.id))
|
||||
const allStateKeys = context.workspaceState.keys()
|
||||
const pendingWarningKeys = allStateKeys.filter((key) => key.startsWith("pendingFileContextWarning_"))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { RecordingResult } from "@shared/proto/cline/dictation"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Cancels audio recording without saving or transcribing the audio
|
||||
* @param controller The controller instance
|
||||
* @returns RecordingResult indicating success or failure
|
||||
*/
|
||||
export const cancelRecording = async (controller: Controller): Promise<RecordingResult> => {
|
||||
const taskId = controller.task?.taskId
|
||||
const recordingStatus = audioRecordingService.getRecordingStatus()
|
||||
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
|
||||
let errorMessage = ""
|
||||
let isSuccess = true
|
||||
try {
|
||||
const result = await audioRecordingService.cancelRecording()
|
||||
isSuccess = !!result?.success
|
||||
errorMessage = result?.error ?? ""
|
||||
} catch (error) {
|
||||
console.error("Error canceling recording:", error)
|
||||
isSuccess = false
|
||||
errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
|
||||
}
|
||||
|
||||
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
|
||||
return RecordingResult.create({
|
||||
success: isSuccess,
|
||||
error: errorMessage ?? "",
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { RecordingStatus } from "@shared/proto/cline/dictation"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
|
||||
/**
|
||||
* Gets the current recording status
|
||||
* @returns RecordingStatus with current status
|
||||
*/
|
||||
export const getRecordingStatus = async (): Promise<RecordingStatus> => {
|
||||
try {
|
||||
const status = audioRecordingService.getRecordingStatus()
|
||||
|
||||
return RecordingStatus.create({
|
||||
isRecording: status.isRecording,
|
||||
durationSeconds: status.durationSeconds,
|
||||
error: status.error ?? "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error getting recording status:", error)
|
||||
return RecordingStatus.create({
|
||||
isRecording: false,
|
||||
durationSeconds: 0,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { RecordingResult } from "@shared/proto/cline/dictation"
|
||||
import * as os from "os"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Handles the installation of missing dependencies with Cline
|
||||
*/
|
||||
async function handleInstallWithCline(
|
||||
controller: Controller,
|
||||
dependencyName: string,
|
||||
installCommand: string,
|
||||
platform: string,
|
||||
): Promise<void> {
|
||||
const platformName = platform === "darwin" ? "macOS" : platform === "win32" ? "Windows" : "Linux"
|
||||
const installTask = `Please install ${dependencyName} for voice recording on ${platformName}.\n\nRun this command:\n\`\`\`bash\n${installCommand}\n\`\`\`\n\nThis will enable voice recording functionality in Cline.`
|
||||
|
||||
// Clear any existing task and start the installation task
|
||||
await controller.clearTask()
|
||||
await controller.postStateToWebview()
|
||||
await controller.initTask(installTask)
|
||||
|
||||
HostProvider.get().logToChannel(`Started task to install ${dependencyName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles copying the installation command to clipboard
|
||||
*/
|
||||
async function handleCopyCommand(installCommand: string): Promise<void> {
|
||||
const vscode = await import("vscode")
|
||||
await vscode.env.clipboard.writeText(installCommand)
|
||||
|
||||
await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: `Installation command copied to clipboard: ${installCommand}`,
|
||||
options: { items: [] },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles missing dependency notification and user action
|
||||
*/
|
||||
async function handleMissingDependency(
|
||||
controller: Controller,
|
||||
platform: string,
|
||||
config: (typeof AUDIO_PROGRAM_CONFIG)[keyof typeof AUDIO_PROGRAM_CONFIG],
|
||||
): Promise<void> {
|
||||
const installWithCline = "Install with Cline"
|
||||
const installManually = "Copy Command"
|
||||
const dismiss = "Dismiss"
|
||||
|
||||
const action = await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: `${config.dependencyName} is required for voice recording. ${config.installDescription}`,
|
||||
options: { items: [installWithCline, installManually, dismiss] },
|
||||
})
|
||||
|
||||
if (action.selectedOption === installWithCline) {
|
||||
await handleInstallWithCline(controller, config.dependencyName, config.installCommand, platform)
|
||||
} else if (action.selectedOption === installManually) {
|
||||
await handleCopyCommand(config.installCommand)
|
||||
}
|
||||
// If dismiss, do nothing
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles sign-in errors for dictation
|
||||
*/
|
||||
async function handleSignInError(controller: Controller, errorMessage: string): Promise<void> {
|
||||
const signInAction = "Sign in to Cline"
|
||||
const action = await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Voice recording error: ${errorMessage}`,
|
||||
options: { items: [signInAction] },
|
||||
})
|
||||
|
||||
if (action.selectedOption === signInAction) {
|
||||
await controller.authService.createAuthRequest()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a generic error message
|
||||
*/
|
||||
async function showGenericError(errorMessage: string): Promise<void> {
|
||||
await HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: `Voice recording error: ${errorMessage}`,
|
||||
options: { items: [] },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the recording error is due to missing dependencies
|
||||
*/
|
||||
function isMissingDependencyError(
|
||||
error: string | undefined,
|
||||
config: (typeof AUDIO_PROGRAM_CONFIG)[keyof typeof AUDIO_PROGRAM_CONFIG] | undefined,
|
||||
): boolean {
|
||||
return !!(error && config && error.includes(config.error))
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts audio recording using the Extension Host
|
||||
* @param controller The controller instance
|
||||
* @returns RecordingResult with success status
|
||||
*/
|
||||
export const startRecording = async (controller: Controller): Promise<RecordingResult> => {
|
||||
const taskId = controller.task?.taskId
|
||||
|
||||
try {
|
||||
// Verify user authentication
|
||||
const userInfo = controller.authService.getInfo()
|
||||
if (!userInfo?.user?.uid) {
|
||||
throw new Error("Please sign in to your Cline Account to use Dictation.")
|
||||
}
|
||||
|
||||
// Attempt to start recording
|
||||
const result = await audioRecordingService.startRecording()
|
||||
|
||||
// Handle successful recording start
|
||||
if (result.success) {
|
||||
telemetryService.captureVoiceRecordingStarted(taskId, process.platform)
|
||||
return RecordingResult.create({
|
||||
success: true,
|
||||
error: "",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if the error is due to missing dependencies
|
||||
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
|
||||
const config = AUDIO_PROGRAM_CONFIG[platform]
|
||||
|
||||
if (isMissingDependencyError(result.error, config)) {
|
||||
// Don't await - show dialog asynchronously so frontend gets immediate response
|
||||
handleMissingDependency(controller, platform, config)
|
||||
}
|
||||
|
||||
return RecordingResult.create({
|
||||
success: false,
|
||||
error: result.error || "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error starting recording:", error)
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
|
||||
|
||||
// Handle different error types
|
||||
if (errorMessage.includes("sign in")) {
|
||||
// Don't await - show dialog asynchronously so frontend gets immediate response
|
||||
handleSignInError(controller, errorMessage)
|
||||
} else {
|
||||
// Don't await - show dialog asynchronously so frontend gets immediate response
|
||||
showGenericError(errorMessage)
|
||||
}
|
||||
|
||||
return RecordingResult.create({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { RecordedAudio } from "@shared/proto/cline/dictation"
|
||||
import { audioRecordingService } from "@/services/dictation/AudioRecordingService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Stops audio recording and returns the recorded audio
|
||||
* @param controller The controller instance
|
||||
* @returns RecordedAudio with audio data
|
||||
*/
|
||||
export const stopRecording = async (controller: Controller): Promise<RecordedAudio> => {
|
||||
const taskId = controller.task?.taskId
|
||||
const recordingStatus = audioRecordingService.getRecordingStatus()
|
||||
const recordingDuration = recordingStatus.durationSeconds * 1000 // Convert to milliseconds
|
||||
|
||||
try {
|
||||
const result = await audioRecordingService.stopRecording()
|
||||
|
||||
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, result.success, process.platform)
|
||||
|
||||
return RecordedAudio.create({
|
||||
success: result.success,
|
||||
audioBase64: result.audioBase64 ?? "",
|
||||
error: result.error ?? "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error stopping recording:", error)
|
||||
|
||||
telemetryService.captureVoiceRecordingStopped(taskId, recordingDuration, false, process.platform)
|
||||
|
||||
return RecordedAudio.create({
|
||||
success: false,
|
||||
audioBase64: "",
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TranscribeAudioRequest, Transcription } from "@shared/proto/cline/dictation"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { getVoiceTranscriptionService } from "@/services/dictation/VoiceTranscriptionService"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Transcribes audio using Cline transcription service
|
||||
* @param controller The controller instance
|
||||
* @param request TranscribeAudioRequest containing base64 audio data
|
||||
* @returns Transcription with transcribed text or error
|
||||
*/
|
||||
export const transcribeAudio = async (controller: Controller, request: TranscribeAudioRequest): Promise<Transcription> => {
|
||||
const taskId = controller.task?.taskId
|
||||
const startTime = Date.now()
|
||||
|
||||
// Capture telemetry for transcription start
|
||||
telemetryService.captureVoiceTranscriptionStarted(taskId, request.language ?? "en")
|
||||
|
||||
try {
|
||||
// Transcribe the audio
|
||||
const result = await getVoiceTranscriptionService().transcribeAudio(request.audioBase64, request.language ?? "en")
|
||||
const durationMs = Date.now() - startTime
|
||||
|
||||
if (result.error) {
|
||||
let errorType = "api_error"
|
||||
if (result.error.includes("Authentication failed")) {
|
||||
errorType = "invalid_jwt_token"
|
||||
} else if (result.error.includes("Insufficient credits")) {
|
||||
errorType = "insufficient_credits"
|
||||
} else if (result.error.includes("Invalid audio format")) {
|
||||
errorType = "invalid_audio_format"
|
||||
} else if (result.error.includes("No internet connection")) {
|
||||
errorType = "no_internet"
|
||||
} else if (result.error.includes("Cannot connect")) {
|
||||
errorType = "connection_error"
|
||||
} else if (result.error.includes("Connection timed out")) {
|
||||
errorType = "timeout_error"
|
||||
} else if (result.error.includes("Network error")) {
|
||||
errorType = "network_error"
|
||||
}
|
||||
|
||||
telemetryService.captureVoiceTranscriptionError(taskId, errorType, result.error, durationMs)
|
||||
|
||||
// Use the error message directly from the service as it's already user-friendly
|
||||
const errorMessage = result.error
|
||||
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: errorMessage,
|
||||
})
|
||||
} else if (result.text) {
|
||||
telemetryService.captureVoiceTranscriptionCompleted(taskId, result.text.length, durationMs, request.language ?? "en")
|
||||
}
|
||||
|
||||
return Transcription.create({
|
||||
text: result.text ?? "",
|
||||
error: result.error ?? "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error transcribing audio:", error)
|
||||
const durationMs = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"
|
||||
|
||||
telemetryService.captureVoiceTranscriptionError(taskId, "unexpected_error", errorMessage, durationMs)
|
||||
|
||||
return Transcription.create({
|
||||
text: "",
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -9,11 +9,11 @@ import { Controller } from ".."
|
||||
* @param request The request message containing the file path in the 'value' field
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function openTaskHistory(_controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
export async function openDiskConversationHistory(_controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
const globalStoragePath = HostProvider.get().globalStorageFsPath
|
||||
const taskHistoryPath = path.join(globalStoragePath, "tasks", request.value, "api_conversation_history.json")
|
||||
const taskConversationHistoryPath = path.join(globalStoragePath, "tasks", request.value, "api_conversation_history.json")
|
||||
if (request.value) {
|
||||
openFileIntegration(taskHistoryPath)
|
||||
openFileIntegration(taskConversationHistoryPath)
|
||||
}
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import { PersistenceErrorEvent, StateManager } from "../storage/StateManager"
|
||||
import { Task } from "../task"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
|
||||
/*
|
||||
@@ -197,7 +199,6 @@ export class Controller {
|
||||
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 autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
|
||||
const shellIntegrationTimeout = this.stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
|
||||
const terminalReuseEnabled = this.stateManager.getGlobalStateKey("terminalReuseEnabled")
|
||||
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
|
||||
@@ -599,10 +600,15 @@ export class Controller {
|
||||
// Read OpenRouter models from disk cache
|
||||
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
|
||||
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
|
||||
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
|
||||
if (fileExists) {
|
||||
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
try {
|
||||
if (await fileExistsAtPath(openRouterModelsFilePath)) {
|
||||
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
|
||||
const models = JSON.parse(fileContents)
|
||||
// Append stealth models
|
||||
return appendClineStealthModels(models)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error reading cached OpenRouter models:", error)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -687,6 +693,7 @@ export class Controller {
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
|
||||
const focusChainSettings = this.stateManager.getGlobalSettingsKey("focusChainSettings")
|
||||
const dictationSettings = this.stateManager.getGlobalSettingsKey("dictationSettings")
|
||||
const preferredLanguage = this.stateManager.getGlobalSettingsKey("preferredLanguage")
|
||||
const openaiReasoningEffort = this.stateManager.getGlobalSettingsKey("openaiReasoningEffort")
|
||||
const mode = this.stateManager.getGlobalSettingsKey("mode")
|
||||
@@ -712,11 +719,13 @@ export class Controller {
|
||||
const mcpResponsesCollapsed = this.stateManager.getGlobalStateKey("mcpResponsesCollapsed")
|
||||
const terminalOutputLineLimit = this.stateManager.getGlobalSettingsKey("terminalOutputLineLimit")
|
||||
const favoritedModelIds = this.stateManager.getGlobalStateKey("favoritedModelIds")
|
||||
const lastDismissedInfoBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedInfoBannerVersion") || 0
|
||||
|
||||
const localClineRulesToggles = this.stateManager.getWorkspaceStateKey("localClineRulesToggles")
|
||||
const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
|
||||
const localCursorRulesToggles = this.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
const workflowToggles = this.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
|
||||
@@ -733,6 +742,12 @@ export class Controller {
|
||||
const distinctId = getDistinctId()
|
||||
const version = ExtensionRegistryInfo.version
|
||||
|
||||
// Set feature flag in dictation settings based on platform
|
||||
const updatedDictationSettings = {
|
||||
...dictationSettings,
|
||||
featureEnabled: process.platform === "darwin", // Enable dictation only on macOS
|
||||
}
|
||||
|
||||
return {
|
||||
version,
|
||||
apiConfiguration,
|
||||
@@ -743,6 +758,7 @@ export class Controller {
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
focusChainSettings,
|
||||
dictationSettings: updatedDictationSettings,
|
||||
preferredLanguage,
|
||||
openaiReasoningEffort,
|
||||
mode,
|
||||
@@ -774,10 +790,16 @@ export class Controller {
|
||||
platform,
|
||||
shouldShowAnnouncement,
|
||||
favoritedModelIds,
|
||||
autoCondenseThreshold,
|
||||
// NEW: Add workspace information
|
||||
workspaceRoots: this.workspaceManager?.getRoots() ?? [],
|
||||
primaryRootIndex: this.workspaceManager?.getPrimaryIndex() ?? 0,
|
||||
isMultiRootWorkspace: (this.workspaceManager?.getRoots().length ?? 0) > 1,
|
||||
multiRootSetting: {
|
||||
user: this.stateManager.getGlobalStateKey("multiRootEnabled"),
|
||||
featureFlag: featureFlagsService.getMultiRootEnabled(),
|
||||
},
|
||||
lastDismissedInfoBannerVersion,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import axios from "axios"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
|
||||
import { DEFAULT_OCA_BASE_URL } from "@/services/auth/oca/utils/constants"
|
||||
import { createOcaHeaders, getProxyAgents } from "@/services/auth/oca/utils/utils"
|
||||
import { createOcaHeaders, getAxiosSettings } from "@/services/auth/oca/utils/utils"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { Controller } from ".."
|
||||
@@ -25,12 +25,19 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
|
||||
const models: Record<string, OcaModelInfo> = {}
|
||||
let defaultModelId: string | undefined
|
||||
const ocaAccessToken = await OcaAuthService.getInstance().getAuthToken()
|
||||
if (!ocaAccessToken) {
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Not authenticated with OCA. Please sign in first.",
|
||||
})
|
||||
return OcaCompatibleModelInfo.create({ error: "Not authenticated with OCA" })
|
||||
}
|
||||
const baseUrl = request.value || DEFAULT_OCA_BASE_URL
|
||||
const modelsUrl = `${baseUrl}/v1/model/info`
|
||||
const headers = await createOcaHeaders(ocaAccessToken!, "models-refresh")
|
||||
try {
|
||||
Logger.log(`Making refresh oca model request with customer opc-request-id: ${headers["opc-request-id"]}`)
|
||||
const response = await axios.get(modelsUrl, { headers, ...getProxyAgents() })
|
||||
const response = await axios.get(modelsUrl, { headers, ...getAxiosSettings() })
|
||||
if (response.data?.data) {
|
||||
if (response.data.data.length === 0) {
|
||||
HostProvider.window.showMessage({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "@shared/proto/cline/models"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import axios from "axios"
|
||||
import cloneDeep from "clone-deep"
|
||||
import fs from "fs/promises"
|
||||
@@ -79,7 +78,7 @@ export async function refreshOpenRouterModels(
|
||||
): Promise<OpenRouterCompatibleModelInfo> {
|
||||
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
|
||||
|
||||
let models: Record<string, OpenRouterModelInfo> = {}
|
||||
const models: Record<string, OpenRouterModelInfo> = {}
|
||||
try {
|
||||
const response = await axios.get("https://openrouter.ai/api/v1/models")
|
||||
|
||||
@@ -221,22 +220,6 @@ export async function refreshOpenRouterModels(
|
||||
models[openRouterClaudeSonnet41mModelId] = claudeSonnet41mModelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Add hardcoded stealth model
|
||||
models["cline/code-supernova"] = OpenRouterModelInfo.create({
|
||||
maxTokens: clineCodeSupernovaModelInfo.maxTokens ?? 0,
|
||||
contextWindow: clineCodeSupernovaModelInfo.contextWindow ?? 0,
|
||||
supportsImages: clineCodeSupernovaModelInfo.supportsImages ?? false,
|
||||
supportsPromptCache: clineCodeSupernovaModelInfo.supportsPromptCache ?? false,
|
||||
inputPrice: clineCodeSupernovaModelInfo.inputPrice ?? 0,
|
||||
outputPrice: clineCodeSupernovaModelInfo.outputPrice ?? 0,
|
||||
cacheWritesPrice: clineCodeSupernovaModelInfo.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: clineCodeSupernovaModelInfo.cacheReadsPrice ?? 0,
|
||||
description: clineCodeSupernovaModelInfo.description ?? "",
|
||||
thinkingConfig: clineCodeSupernovaModelInfo.thinkingConfig ?? undefined,
|
||||
supportsGlobalEndpoint: clineCodeSupernovaModelInfo.supportsGlobalEndpoint ?? undefined,
|
||||
tiers: clineCodeSupernovaModelInfo.tiers ?? [],
|
||||
})
|
||||
} else {
|
||||
console.error("Invalid response from OpenRouter API")
|
||||
}
|
||||
@@ -246,29 +229,45 @@ export async function refreshOpenRouterModels(
|
||||
console.error("Error fetching OpenRouter models:", error)
|
||||
|
||||
// If we failed to fetch models, try to read cached models
|
||||
const cachedModels = await readOpenRouterModels(controller)
|
||||
const cachedModels = await controller.readOpenRouterModels()
|
||||
if (cachedModels) {
|
||||
models = cachedModels
|
||||
return OpenRouterCompatibleModelInfo.create({ models: cachedModels })
|
||||
}
|
||||
}
|
||||
|
||||
return OpenRouterCompatibleModelInfo.create({ models })
|
||||
// Append stealth models if any
|
||||
return OpenRouterCompatibleModelInfo.create({ models: appendClineStealthModels(models) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads cached OpenRouter models from disk
|
||||
* Stealth models are models that are compatible with the OpenRouter API but not listed on the OpenRouter website or API.
|
||||
*/
|
||||
async function readOpenRouterModels(controller: Controller): Promise<Record<string, OpenRouterModelInfo> | undefined> {
|
||||
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
|
||||
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
|
||||
if (fileExists) {
|
||||
try {
|
||||
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
|
||||
return JSON.parse(fileContents)
|
||||
} catch (error) {
|
||||
console.error("Error reading cached OpenRouter models:", error)
|
||||
return undefined
|
||||
const CLINE_STEALTH_MODELS: Record<string, OpenRouterModelInfo> = {
|
||||
"cline/code-supernova-1-million": OpenRouterModelInfo.create({
|
||||
maxTokens: clineCodeSupernovaModelInfo.maxTokens ?? 0,
|
||||
contextWindow: clineCodeSupernovaModelInfo.contextWindow ?? 0,
|
||||
supportsImages: clineCodeSupernovaModelInfo.supportsImages ?? false,
|
||||
supportsPromptCache: clineCodeSupernovaModelInfo.supportsPromptCache ?? false,
|
||||
inputPrice: clineCodeSupernovaModelInfo.inputPrice ?? 0,
|
||||
outputPrice: clineCodeSupernovaModelInfo.outputPrice ?? 0,
|
||||
cacheWritesPrice: clineCodeSupernovaModelInfo.cacheWritesPrice ?? 0,
|
||||
cacheReadsPrice: clineCodeSupernovaModelInfo.cacheReadsPrice ?? 0,
|
||||
description: clineCodeSupernovaModelInfo.description ?? "",
|
||||
thinkingConfig: clineCodeSupernovaModelInfo.thinkingConfig ?? undefined,
|
||||
supportsGlobalEndpoint: clineCodeSupernovaModelInfo.supportsGlobalEndpoint ?? undefined,
|
||||
tiers: clineCodeSupernovaModelInfo.tiers ?? [],
|
||||
}),
|
||||
// Add more stealth models here as needed
|
||||
}
|
||||
|
||||
export function appendClineStealthModels(
|
||||
currentModels: Record<string, OpenRouterModelInfo>,
|
||||
): Record<string, OpenRouterModelInfo> {
|
||||
// Create a shallow clone of the current models to avoid mutating the original object
|
||||
const cloned = { ...currentModels }
|
||||
for (const [modelId, modelInfo] of Object.entries(CLINE_STEALTH_MODELS)) {
|
||||
if (!cloned[modelId]) {
|
||||
cloned[modelId] = modelInfo
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
return cloned
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Empty, Int64Request } from "@shared/proto/cline/common"
|
||||
import { Controller } from ".."
|
||||
|
||||
/**
|
||||
* Updates the info banner version to track which version the user has dismissed
|
||||
* @param controller The controller instance
|
||||
* @param request The request containing the version number
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function updateInfoBannerVersion(controller: Controller, request: Int64Request): Promise<Empty> {
|
||||
const version = Number(request.value)
|
||||
|
||||
controller.stateManager.setGlobalState("lastDismissedInfoBannerVersion", version)
|
||||
await controller.postStateToWebview()
|
||||
|
||||
return Empty.create()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { buildApiHandler } from "@core/api"
|
||||
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import {
|
||||
PlanActMode,
|
||||
@@ -147,7 +148,6 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
if (request.strictPlanModeEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("strictPlanModeEnabled", request.strictPlanModeEnabled)
|
||||
}
|
||||
|
||||
// Update yolo mode setting
|
||||
if (request.yoloModeToggled !== undefined) {
|
||||
if (controller.task) {
|
||||
@@ -156,6 +156,15 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
controller.stateManager.setGlobalState("yoloModeToggled", request.yoloModeToggled)
|
||||
}
|
||||
|
||||
if (request.dictationSettings !== undefined) {
|
||||
// Convert from protobuf format (snake_case) to TypeScript format (camelCase)
|
||||
const dictationSettings = {
|
||||
featureEnabled: request.dictationSettings.featureEnabled ?? true,
|
||||
dictationEnabled: request.dictationSettings.dictationEnabled ?? true,
|
||||
dictationLanguage: request.dictationSettings.dictationLanguage ?? "en",
|
||||
}
|
||||
controller.stateManager.setGlobalState("dictationSettings", dictationSettings)
|
||||
}
|
||||
// Update auto-condense setting
|
||||
if (request.useAutoCondense !== undefined) {
|
||||
if (controller.task) {
|
||||
@@ -273,6 +282,15 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
}
|
||||
}
|
||||
|
||||
if (request.autoCondenseThreshold !== undefined) {
|
||||
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
|
||||
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
|
||||
}
|
||||
|
||||
if (request.multiRootEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("multiRootEnabled", !!request.multiRootEnabled)
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
await controller.postStateToWebview()
|
||||
|
||||
|
||||
@@ -18,12 +18,11 @@ import { sendOpenRouterModelsEvent } from "../models/subscribeToOpenRouterModels
|
||||
*/
|
||||
export async function initializeWebview(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
try {
|
||||
// Post last cached models in case the call to endpoint fails
|
||||
controller.readOpenRouterModels().then((openRouterModels) => {
|
||||
if (openRouterModels) {
|
||||
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: openRouterModels }))
|
||||
}
|
||||
})
|
||||
// Post last cached models as soon as possible for immediate availability in the UI
|
||||
const lastCachedModels = await controller.readOpenRouterModels()
|
||||
if (lastCachedModels) {
|
||||
sendOpenRouterModelsEvent(OpenRouterCompatibleModelInfo.create({ models: lastCachedModels }))
|
||||
}
|
||||
|
||||
// Refresh OpenRouter models from API
|
||||
refreshOpenRouterModels(controller, EmptyRequest.create()).then(async (response) => {
|
||||
|
||||
@@ -20,7 +20,6 @@ export async function getToolUseToolsSection(variant: PromptVariant, context: Sy
|
||||
|
||||
// Define multi-root hint based on feature flag
|
||||
const multiRootHint = context.isMultiRootEnabled ? MULTI_ROOT_HINT : ""
|
||||
|
||||
return new TemplateEngine().resolve(template, context, {
|
||||
TASK_PROGRESS: shouldIncludeTaskProgress ? TASK_PROGRESS : "",
|
||||
FOCUS_CHAIN_ATTEMPT: shouldIncludeTaskProgress ? FOCUS_CHAIN_ATTEMPT : "",
|
||||
|
||||
@@ -22,6 +22,7 @@ const generic: ClineToolSpec = {
|
||||
name: "diff",
|
||||
required: true,
|
||||
instruction: `One or more SEARCH/REPLACE blocks following this exact format:
|
||||
|
||||
\`\`\`
|
||||
------- SEARCH
|
||||
[exact content to find]
|
||||
|
||||
@@ -30,7 +30,7 @@ const generic: ClineToolSpec = {
|
||||
{
|
||||
name: "path",
|
||||
required: true,
|
||||
instruction: `The path of the directory to search in (relative to the current working directory {{CWD}}). This directory will be recursively searched.`,
|
||||
instruction: `The path of the directory to search in (relative to the current working directory {{CWD}}){{MULTI_ROOT_HINT}}. This directory will be recursively searched.`,
|
||||
usage: "Directory path here",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
SettingsKey,
|
||||
} from "./state-keys"
|
||||
import { readGlobalStateFromDisk, readSecretsFromDisk, readWorkspaceStateFromDisk } from "./utils/state-helpers"
|
||||
|
||||
export interface PersistenceErrorEvent {
|
||||
error: Error
|
||||
}
|
||||
@@ -276,7 +275,7 @@ export class StateManager {
|
||||
*/
|
||||
private async setupTaskHistoryWatcher(): Promise<void> {
|
||||
try {
|
||||
const historyFile = await getTaskHistoryStateFilePath(this.context)
|
||||
const historyFile = await getTaskHistoryStateFilePath()
|
||||
|
||||
// Close any existing watcher before creating a new one
|
||||
if (this.taskHistoryWatcher) {
|
||||
@@ -296,7 +295,7 @@ export class StateManager {
|
||||
if (!this.isInitialized) {
|
||||
return
|
||||
}
|
||||
const onDisk = await readTaskHistoryFromState(this.context)
|
||||
const onDisk = await readTaskHistoryFromState()
|
||||
const cached = this.globalStateCache["taskHistory"]
|
||||
if (JSON.stringify(onDisk) !== JSON.stringify(cached)) {
|
||||
this.globalStateCache["taskHistory"] = onDisk
|
||||
@@ -765,7 +764,7 @@ export class StateManager {
|
||||
Array.from(keys).map((key) => {
|
||||
if (key === "taskHistory") {
|
||||
// Route task history persistence to file, not VS Code globalState
|
||||
return writeTaskHistoryToState(this.context, this.globalStateCache[key])
|
||||
return writeTaskHistoryToState(this.globalStateCache[key])
|
||||
}
|
||||
return this.context.globalState.update(key, this.globalStateCache[key])
|
||||
}),
|
||||
|
||||
+11
-11
@@ -99,7 +99,7 @@ export async function ensureMcpServersDirectoryExists(): Promise<string> {
|
||||
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 path.join(os.homedir(), "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
|
||||
}
|
||||
@@ -184,8 +184,8 @@ export async function saveTaskMetadata(context: vscode.ExtensionContext, taskId:
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureStateDirectoryExists(context: vscode.ExtensionContext): Promise<string> {
|
||||
const stateDir = path.join(context.globalStorageUri.fsPath, "state")
|
||||
export async function ensureStateDirectoryExists(): Promise<string> {
|
||||
const stateDir = path.join(HostProvider.get().globalStorageFsPath, "state")
|
||||
await fs.mkdir(stateDir, { recursive: true })
|
||||
return stateDir
|
||||
}
|
||||
@@ -194,18 +194,18 @@ export async function ensureCacheDirectoryExists(): Promise<string> {
|
||||
return HostProvider.getGlobalStorageDir("cache")
|
||||
}
|
||||
|
||||
export async function getTaskHistoryStateFilePath(context: vscode.ExtensionContext): Promise<string> {
|
||||
return path.join(await ensureStateDirectoryExists(context), "taskHistory.json")
|
||||
export async function getTaskHistoryStateFilePath(): Promise<string> {
|
||||
return path.join(await ensureStateDirectoryExists(), "taskHistory.json")
|
||||
}
|
||||
|
||||
export async function taskHistoryStateFileExists(context: vscode.ExtensionContext): Promise<boolean> {
|
||||
const filePath = await getTaskHistoryStateFilePath(context)
|
||||
export async function taskHistoryStateFileExists(): Promise<boolean> {
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
return fileExistsAtPath(filePath)
|
||||
}
|
||||
|
||||
export async function readTaskHistoryFromState(context: vscode.ExtensionContext): Promise<HistoryItem[]> {
|
||||
export async function readTaskHistoryFromState(): Promise<HistoryItem[]> {
|
||||
try {
|
||||
const filePath = await getTaskHistoryStateFilePath(context)
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
const contents = await fs.readFile(filePath, "utf8")
|
||||
try {
|
||||
@@ -222,9 +222,9 @@ export async function readTaskHistoryFromState(context: vscode.ExtensionContext)
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeTaskHistoryToState(context: vscode.ExtensionContext, items: HistoryItem[]): Promise<void> {
|
||||
export async function writeTaskHistoryToState(items: HistoryItem[]): Promise<void> {
|
||||
try {
|
||||
const filePath = await getTaskHistoryStateFilePath(context)
|
||||
const filePath = await getTaskHistoryStateFilePath()
|
||||
// Always create the file; if items is empty, write [] to ensure presence on first startup
|
||||
await fs.writeFile(filePath, JSON.stringify(items))
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,13 +5,13 @@ import { WorkspaceRoot } from "@/core/workspace/WorkspaceRoot"
|
||||
import { AutoApprovalSettings } from "@/shared/AutoApprovalSettings"
|
||||
import { BrowserSettings } from "@/shared/BrowserSettings"
|
||||
import { ClineRulesToggles } from "@/shared/cline-rules"
|
||||
import { DictationSettings } from "@/shared/DictationSettings"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { McpDisplayMode } from "@/shared/McpDisplayMode"
|
||||
import { McpMarketplaceCatalog } from "@/shared/mcp"
|
||||
import { Mode, OpenaiReasoningEffort } from "@/shared/storage/types"
|
||||
import { TelemetrySetting } from "@/shared/TelemetrySetting"
|
||||
import { UserInfo } from "@/shared/UserInfo"
|
||||
|
||||
export type SecretKey = keyof Secrets
|
||||
|
||||
export type GlobalStateKey = keyof GlobalState
|
||||
@@ -40,6 +40,7 @@ export interface GlobalState {
|
||||
workspaceRoots: WorkspaceRoot[] | undefined
|
||||
primaryRootIndex: number
|
||||
multiRootEnabled: boolean
|
||||
lastDismissedInfoBannerVersion: number
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
@@ -94,9 +95,11 @@ export interface Settings {
|
||||
preferredLanguage: string
|
||||
openaiReasoningEffort: OpenaiReasoningEffort
|
||||
mode: Mode
|
||||
dictationSettings: DictationSettings
|
||||
focusChainSettings: FocusChainSettings
|
||||
customPrompt: "compact" | undefined
|
||||
difyBaseUrl: string | undefined
|
||||
autoCondenseThreshold: number | undefined // number from 0 to 1
|
||||
ocaBaseUrl: string | undefined
|
||||
|
||||
// Plan mode configurations
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function migrateTaskHistoryToFile(context: vscode.ExtensionContext)
|
||||
let finalData: HistoryItem[]
|
||||
let migrationAction: string
|
||||
|
||||
const newLocationData = await readTaskHistoryFromState(context)
|
||||
const newLocationData = await readTaskHistoryFromState()
|
||||
|
||||
if (newLocationData.length === 0) {
|
||||
// Move old data to new location
|
||||
@@ -97,9 +97,9 @@ export async function migrateTaskHistoryToFile(context: vscode.ExtensionContext)
|
||||
}
|
||||
|
||||
// Perform migration operations sequentially - only clear old data if write succeeds
|
||||
await writeTaskHistoryToState(context, finalData)
|
||||
await writeTaskHistoryToState(finalData)
|
||||
|
||||
const successfullyWrittenData = await readTaskHistoryFromState(context)
|
||||
const successfullyWrittenData = await readTaskHistoryFromState()
|
||||
|
||||
if (!Array.isArray(successfullyWrittenData)) {
|
||||
console.error("[Storage Migration] Failed to write taskHistory to file: Written data is not an array")
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ApiProvider, fireworksDefaultModelId, type OcaModelInfo } from "@shared/api"
|
||||
import { ANTHROPIC_MIN_THINKING_BUDGET, ApiProvider, fireworksDefaultModelId, type OcaModelInfo } from "@shared/api"
|
||||
import { ExtensionContext } from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@/shared/AutoApprovalSettings"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "@/shared/BrowserSettings"
|
||||
import { ClineRulesToggles } from "@/shared/cline-rules"
|
||||
import { DEFAULT_DICTATION_SETTINGS, DictationSettings } from "@/shared/DictationSettings"
|
||||
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@/shared/FocusChainSettings"
|
||||
import { DEFAULT_MCP_DISPLAY_MODE } from "@/shared/McpDisplayMode"
|
||||
import { OpenaiReasoningEffort } from "@/shared/storage/types"
|
||||
import { readTaskHistoryFromState } from "../disk"
|
||||
import { GlobalStateAndSettings, LocalState, SecretKey, Secrets } from "../state-keys"
|
||||
|
||||
export async function readSecretsFromDisk(context: ExtensionContext): Promise<Secrets> {
|
||||
const [
|
||||
apiKey,
|
||||
@@ -230,12 +230,18 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
context.globalState.get<GlobalStateAndSettings["openaiReasoningEffort"]>("openaiReasoningEffort")
|
||||
const preferredLanguage = context.globalState.get<GlobalStateAndSettings["preferredLanguage"]>("preferredLanguage")
|
||||
const focusChainSettings = context.globalState.get<GlobalStateAndSettings["focusChainSettings"]>("focusChainSettings")
|
||||
const dictationSettings = context.globalState.get<GlobalStateAndSettings["dictationSettings"]>("dictationSettings") as
|
||||
| DictationSettings
|
||||
| undefined
|
||||
|
||||
const mcpMarketplaceCatalog =
|
||||
context.globalState.get<GlobalStateAndSettings["mcpMarketplaceCatalog"]>("mcpMarketplaceCatalog")
|
||||
const lastDismissedInfoBannerVersion =
|
||||
context.globalState.get<GlobalStateAndSettings["lastDismissedInfoBannerVersion"]>("lastDismissedInfoBannerVersion")
|
||||
const qwenCodeOauthPath = context.globalState.get<GlobalStateAndSettings["qwenCodeOauthPath"]>("qwenCodeOauthPath")
|
||||
const customPrompt = context.globalState.get<GlobalStateAndSettings["customPrompt"]>("customPrompt")
|
||||
|
||||
const autoCondenseThreshold =
|
||||
context.globalState.get<GlobalStateAndSettings["autoCondenseThreshold"]>("autoCondenseThreshold") // number from 0 to 1
|
||||
// Get mode-related configurations
|
||||
const mode = context.globalState.get<GlobalStateAndSettings["mode"]>("mode")
|
||||
|
||||
@@ -399,7 +405,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
}
|
||||
}
|
||||
|
||||
const taskHistory = await readTaskHistoryFromState(context)
|
||||
const taskHistory = await readTaskHistoryFromState()
|
||||
|
||||
// Multi-root workspace support
|
||||
const workspaceRoots = context.globalState.get<GlobalStateAndSettings["workspaceRoots"]>("workspaceRoots")
|
||||
@@ -455,7 +461,9 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
// Plan mode configurations
|
||||
planModeApiProvider: planModeApiProvider || apiProvider,
|
||||
planModeApiModelId,
|
||||
planModeThinkingBudgetTokens,
|
||||
// undefined means it was never modified, 0 means it was turned off
|
||||
// (having this on by default ensures that <thinking> text does not pollute the user's chat and is instead rendered as reasoning)
|
||||
planModeThinkingBudgetTokens: planModeThinkingBudgetTokens ?? ANTHROPIC_MIN_THINKING_BUDGET,
|
||||
planModeReasoningEffort,
|
||||
planModeVsCodeLmModelSelector,
|
||||
planModeAwsBedrockCustomSelected,
|
||||
@@ -489,7 +497,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
// Act mode configurations
|
||||
actModeApiProvider: actModeApiProvider || apiProvider,
|
||||
actModeApiModelId,
|
||||
actModeThinkingBudgetTokens,
|
||||
actModeThinkingBudgetTokens: actModeThinkingBudgetTokens ?? ANTHROPIC_MIN_THINKING_BUDGET,
|
||||
actModeReasoningEffort,
|
||||
actModeVsCodeLmModelSelector,
|
||||
actModeAwsBedrockCustomSelected,
|
||||
@@ -523,6 +531,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
|
||||
// Other global fields
|
||||
focusChainSettings: focusChainSettings || DEFAULT_FOCUS_CHAIN_SETTINGS,
|
||||
dictationSettings: { ...DEFAULT_DICTATION_SETTINGS, ...dictationSettings },
|
||||
strictPlanModeEnabled: strictPlanModeEnabled ?? true,
|
||||
yoloModeToggled: yoloModeToggled ?? false,
|
||||
useAutoCondense: useAutoCondense ?? false,
|
||||
@@ -551,12 +560,14 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
mcpMarketplaceCatalog,
|
||||
qwenCodeOauthPath,
|
||||
customPrompt,
|
||||
autoCondenseThreshold: autoCondenseThreshold || 0.75, // default to 0.75 if not set
|
||||
lastDismissedInfoBannerVersion: lastDismissedInfoBannerVersion ?? 0,
|
||||
// Multi-root workspace support
|
||||
workspaceRoots,
|
||||
primaryRootIndex: primaryRootIndex ?? 0,
|
||||
// Feature flag - defaults to false
|
||||
// For now, always return false to disable multi-root support by default
|
||||
multiRootEnabled: multiRootEnabled ?? false,
|
||||
multiRootEnabled: !!multiRootEnabled,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[StateHelpers] Failed to read global state:", error)
|
||||
|
||||
@@ -17,12 +17,12 @@ export function getFocusChainFilePath(taskDir: string, taskId: string): string {
|
||||
export function createFocusChainMarkdownContent(taskId: string, focusChainList: string): string {
|
||||
return `# Focus Chain List for Task ${taskId}
|
||||
|
||||
<!-- Edit this markdown file to update your focus chain focusChain list -->
|
||||
<!-- Edit this markdown file to update your focus chain list -->
|
||||
<!-- Use the format: - [ ] for incomplete items and - [x] for completed items -->
|
||||
|
||||
${focusChainList}
|
||||
|
||||
<!-- Save this file and the focusChain list will be updated in the task -->`
|
||||
<!-- Save this file and the focus chain list will be updated in the task -->`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+51
-11
@@ -1,5 +1,6 @@
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type { RedactedThinkingBlock, TextBlock, ThinkingBlock } from "@anthropic-ai/sdk/resources"
|
||||
import { ApiHandler, ApiProviderInfo, buildApiHandler } from "@core/api"
|
||||
import { ApiStream } from "@core/api/transform/stream"
|
||||
import { parseAssistantMessageV2 } from "@core/assistant-message"
|
||||
@@ -56,7 +57,7 @@ import { convertClineMessageToProto } from "@shared/proto-conversions/cline-mess
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { getGitRemoteUrls, getLatestGitCommitHash } from "@utils/git"
|
||||
import { isNextGenModelFamily } from "@utils/model-utils"
|
||||
import { isLocalModel, isNextGenModelFamily } from "@utils/model-utils"
|
||||
import { arePathsEqual, getDesktopDir } from "@utils/path"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { execa } from "execa"
|
||||
@@ -80,7 +81,7 @@ import { FocusChainManager } from "./focus-chain"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { ToolExecutor } from "./ToolExecutor"
|
||||
import { updateApiReqMsg } from "./utils"
|
||||
import { detectAvailableCliTools, updateApiReqMsg } from "./utils"
|
||||
|
||||
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
|
||||
type UserContent = Array<Anthropic.ContentBlockParam>
|
||||
@@ -1218,7 +1219,6 @@ export class Task {
|
||||
|
||||
if (userFeedback) {
|
||||
await this.say("user_feedback", userFeedback.text, userFeedback.images, userFeedback.files)
|
||||
await this.checkpointManager?.saveCheckpoint()
|
||||
|
||||
let fileContentString = ""
|
||||
if (userFeedback.files && userFeedback.files.length > 0) {
|
||||
@@ -1807,10 +1807,14 @@ export class Task {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold") as
|
||||
| number
|
||||
| undefined
|
||||
shouldCompact = this.contextManager.shouldCompactContextWindow(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
this.api,
|
||||
previousApiReqIndex,
|
||||
autoCondenseThreshold,
|
||||
)
|
||||
|
||||
// There is an edge case where the summarize_task tool call completes but the user cancels the next request before it finishes
|
||||
@@ -1857,14 +1861,11 @@ export class Task {
|
||||
"Issue with processing the /newrule command. Double check that, if '.clinerules' already exists, it's a directory and not a file. Otherwise there was an issue referencing this file/directory.",
|
||||
)
|
||||
}
|
||||
// Compact prompt is tailored for models with small context window where environment details would often
|
||||
// overflow the context window
|
||||
const useCompactPrompt = customPrompt === "compact"
|
||||
|
||||
userContent = parsedUserContent
|
||||
// add environment details as its own text block, separate from tool results
|
||||
// do not add environment details to the message which we are compacting the context window
|
||||
if (!shouldCompact && !useCompactPrompt) {
|
||||
if (!shouldCompact) {
|
||||
userContent.push({ type: "text", text: environmentDetails })
|
||||
}
|
||||
|
||||
@@ -1875,10 +1876,11 @@ export class Task {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const useCompactPrompt = customPrompt === "compact" && isLocalModel(this.getCurrentProviderInfo())
|
||||
const [parsedUserContent, environmentDetails, clinerulesError] = await this.loadContext(
|
||||
userContent,
|
||||
includeFileDetails,
|
||||
customPrompt === "compact",
|
||||
useCompactPrompt,
|
||||
)
|
||||
|
||||
if (clinerulesError === true) {
|
||||
@@ -1997,6 +1999,8 @@ export class Task {
|
||||
const stream = this.attemptApiRequest(previousApiReqIndex) // yields only if the first chunk is successful, otherwise will allow the user to retry the request (most likely due to rate limit error, which gets thrown on the first chunk)
|
||||
let assistantMessage = ""
|
||||
let reasoningMessage = ""
|
||||
const reasoningDetails = []
|
||||
const antThinkingContent: (Anthropic.Messages.RedactedThinkingBlock | Anthropic.Messages.ThinkingBlock)[] = []
|
||||
this.taskState.isStreaming = true
|
||||
let didReceiveUsageChunk = false
|
||||
try {
|
||||
@@ -2021,6 +2025,24 @@ export class Task {
|
||||
await this.say("reasoning", reasoningMessage, undefined, undefined, true)
|
||||
}
|
||||
break
|
||||
// for cline/openrouter providers
|
||||
case "reasoning_details":
|
||||
reasoningDetails.push(chunk.reasoning_details)
|
||||
break
|
||||
// for anthropic providers
|
||||
case "ant_thinking":
|
||||
antThinkingContent.push({
|
||||
type: "thinking",
|
||||
thinking: chunk.thinking,
|
||||
signature: chunk.signature,
|
||||
})
|
||||
break
|
||||
case "ant_redacted_thinking":
|
||||
antThinkingContent.push({
|
||||
type: "redacted_thinking",
|
||||
data: chunk.data,
|
||||
})
|
||||
break
|
||||
case "text": {
|
||||
if (reasoningMessage && assistantMessage.length === 0) {
|
||||
// complete reasoning message
|
||||
@@ -2150,7 +2172,19 @@ export class Task {
|
||||
|
||||
await this.messageStateHandler.addToApiConversationHistory({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: assistantMessage }],
|
||||
content: [
|
||||
// This is critical for maintaining the model’s reasoning flow and conversation integrity.
|
||||
// "When providing thinking blocks, the entire sequence of consecutive thinking blocks must match the outputs generated by the model during the original request; you cannot rearrange or modify the sequence of these blocks." The signature_delta is used to verify that the thinking was generated by Claude, and the thinking blocks will be ignored if it's incorrect or missing.
|
||||
// https://docs.claude.com/en/docs/build-with-claude/extended-thinking#preserving-thinking-blocks
|
||||
...antThinkingContent,
|
||||
{
|
||||
type: "text",
|
||||
text: assistantMessage,
|
||||
// reasoning_details only exists for cline/openrouter providers
|
||||
// @ts-ignore-next-line
|
||||
reasoning_details: reasoningDetails.length > 0 ? reasoningDetails : undefined,
|
||||
},
|
||||
] as Array<RedactedThinkingBlock | ThinkingBlock | TextBlock>,
|
||||
})
|
||||
|
||||
// NOTE: this comment is here for future reference - this was a workaround for userMessageContent not getting set to true. It was due to it not recursively calling for partial blocks when didRejectTool, so it would get stuck waiting for a partial block to complete before it could continue.
|
||||
@@ -2198,8 +2232,8 @@ export class Task {
|
||||
})
|
||||
|
||||
const baseErrorMessage =
|
||||
"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output."
|
||||
const errorText = reqId ? `${baseErrorMessage} (reqId: ${reqId})` : baseErrorMessage
|
||||
"Invalid API Response: The provider returned an empty or unparsable response. This is a provider-side issue where the model failed to generate valid output or returned tool calls that Cline cannot process. Retrying the request may help resolve this issue."
|
||||
const errorText = reqId ? `${baseErrorMessage} (Request ID: ${reqId})` : baseErrorMessage
|
||||
|
||||
await this.say("error", errorText)
|
||||
await this.messageStateHandler.addToApiConversationHistory({
|
||||
@@ -2527,6 +2561,12 @@ export class Task {
|
||||
if (latestGitHash) {
|
||||
details += `\n\n# Latest Git Commit Hash\n${latestGitHash}`
|
||||
}
|
||||
|
||||
// Add detected CLI tools
|
||||
const availableCliTools = await detectAvailableCliTools()
|
||||
if (availableCliTools.length > 0) {
|
||||
details += `\n\n# Detected CLI Tools\nThese are some of the tools on the user's machine, and may be useful if needed to accomplish the task: ${availableCliTools.join(", ")}. This list is not exhaustive, and other tools may be available.`
|
||||
}
|
||||
}
|
||||
|
||||
// Add context window usage information
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
import { resolveWorkspacePath } from "@core/workspace"
|
||||
import { ClineDefaultTool } from "@shared/tools"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { getCwd, getDesktopDir, isLocatedInPath } from "@/utils/path"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { getCwd, getDesktopDir, isLocatedInPath, isLocatedInWorkspace } from "@/utils/path"
|
||||
|
||||
export class AutoApprove {
|
||||
private stateManager: StateManager
|
||||
// Cache for workspace paths - populated on first access and reused for the task lifetime
|
||||
// NOTE: This assumes that the task has a fixed set of workspace roots(which is currently true).
|
||||
private workspacePathsCache: { paths: string[] } | null = null
|
||||
private isMultiRootScenarioCache: boolean | null = null
|
||||
|
||||
constructor(stateManager: StateManager) {
|
||||
this.stateManager = stateManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace information with caching to avoid repeated API calls
|
||||
* Cache is task-scoped since each task gets a new AutoApprove instance
|
||||
*/
|
||||
private async getWorkspaceInfo(): Promise<{
|
||||
workspacePaths: { paths: string[] }
|
||||
isMultiRootScenario: boolean
|
||||
}> {
|
||||
// Check if we already have cached values
|
||||
if (this.workspacePathsCache === null || this.isMultiRootScenarioCache === null) {
|
||||
// First time - fetch and cache for the lifetime of this task
|
||||
this.workspacePathsCache = await HostProvider.workspace.getWorkspacePaths({})
|
||||
this.isMultiRootScenarioCache = featureFlagsService.getMultiRootEnabled() && this.workspacePathsCache.paths.length > 1
|
||||
}
|
||||
|
||||
return {
|
||||
workspacePaths: this.workspacePathsCache,
|
||||
isMultiRootScenario: this.isMultiRootScenarioCache,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the tool should be auto-approved based on the settings
|
||||
// Returns bool for most tools, and tuple for tools with nested settings
|
||||
shouldAutoApproveTool(toolName: ClineDefaultTool): boolean | [boolean, boolean] {
|
||||
@@ -76,14 +103,23 @@ export class AutoApprove {
|
||||
|
||||
let isLocalRead: boolean = false
|
||||
if (autoApproveActionpath) {
|
||||
const cwd = await getCwd(getDesktopDir())
|
||||
// When called with a string cwd, resolveWorkspacePath returns a string
|
||||
const absolutePath = resolveWorkspacePath(
|
||||
cwd,
|
||||
autoApproveActionpath,
|
||||
"AutoApprove.shouldAutoApproveToolWithPath",
|
||||
) as string
|
||||
isLocalRead = isLocatedInPath(cwd, absolutePath)
|
||||
// Use cached workspace info instead of fetching every time
|
||||
const { isMultiRootScenario } = await this.getWorkspaceInfo()
|
||||
|
||||
if (isMultiRootScenario) {
|
||||
// Multi-root: check if file is in ANY workspace
|
||||
isLocalRead = await isLocatedInWorkspace(autoApproveActionpath)
|
||||
} else {
|
||||
// Single-root: use existing logic
|
||||
const cwd = await getCwd(getDesktopDir())
|
||||
// When called with a string cwd, resolveWorkspacePath returns a string
|
||||
const absolutePath = resolveWorkspacePath(
|
||||
cwd,
|
||||
autoApproveActionpath,
|
||||
"AutoApprove.shouldAutoApproveToolWithPath",
|
||||
) as string
|
||||
isLocalRead = isLocatedInPath(cwd, absolutePath)
|
||||
}
|
||||
} else {
|
||||
// If we do not get a path for some reason, default to a (safer) false return
|
||||
isLocalRead = false
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { regexSearchFiles } from "@services/ripgrep"
|
||||
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import * as path from "path"
|
||||
import { formatResponse } from "@/core/prompts/responses"
|
||||
import { parseWorkspaceInlinePath } from "@/core/workspace/utils/parseWorkspaceInlinePath"
|
||||
import { WorkspacePathAdapter } from "@/core/workspace/WorkspacePathAdapter"
|
||||
import { resolveWorkspacePath } from "@/core/workspace/WorkspaceResolver"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
@@ -25,6 +28,150 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
}]`
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which paths to search based on workspace configuration and hints
|
||||
*/
|
||||
private determineSearchPaths(
|
||||
config: TaskConfig,
|
||||
parsedPath: string,
|
||||
workspaceHint: string | undefined,
|
||||
originalPath: string,
|
||||
): Array<{ absolutePath: string; workspaceName?: string; workspaceRoot?: string }> {
|
||||
if (config.isMultiRootEnabled && config.workspaceManager) {
|
||||
const adapter = new WorkspacePathAdapter({
|
||||
cwd: config.cwd,
|
||||
isMultiRootEnabled: true,
|
||||
workspaceManager: config.workspaceManager,
|
||||
})
|
||||
|
||||
if (workspaceHint) {
|
||||
// Search only in the specified workspace
|
||||
const absolutePath = adapter.resolvePath(parsedPath, workspaceHint)
|
||||
const workspaceRoots = adapter.getWorkspaceRoots()
|
||||
const root = workspaceRoots.find((r) => r.name === workspaceHint)
|
||||
return [{ absolutePath, workspaceName: workspaceHint, workspaceRoot: root?.path }]
|
||||
} else {
|
||||
// As a fallback, perform the search across all available workspaces.
|
||||
// Typically, models should provide explicit hints to target specific workspaces for searching.
|
||||
const allPaths = adapter.getAllPossiblePaths(parsedPath)
|
||||
const workspaceRoots = adapter.getWorkspaceRoots()
|
||||
return allPaths.map((absPath, index) => ({
|
||||
absolutePath: absPath,
|
||||
workspaceName: workspaceRoots[index]?.name || path.basename(workspaceRoots[index]?.path || absPath),
|
||||
workspaceRoot: workspaceRoots[index]?.path,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
// Single-workspace mode (backward compatible)
|
||||
const pathResult = resolveWorkspacePath(config, originalPath, "SearchFilesTool.execute")
|
||||
const absolutePath = typeof pathResult === "string" ? pathResult : pathResult.absolutePath
|
||||
return [{ absolutePath, workspaceRoot: config.cwd }]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single search operation in a workspace
|
||||
*/
|
||||
private async executeSearch(
|
||||
config: TaskConfig,
|
||||
absolutePath: string,
|
||||
workspaceName: string | undefined,
|
||||
workspaceRoot: string | undefined,
|
||||
regex: string,
|
||||
filePattern: string | undefined,
|
||||
) {
|
||||
try {
|
||||
// Use workspace root for relative path calculation, fallback to cwd
|
||||
const basePathForRelative = workspaceRoot || config.cwd
|
||||
|
||||
const workspaceResults = await regexSearchFiles(
|
||||
basePathForRelative,
|
||||
absolutePath,
|
||||
regex,
|
||||
filePattern,
|
||||
config.services.clineIgnoreController,
|
||||
)
|
||||
|
||||
// Parse the result count from the first line
|
||||
const firstLine = workspaceResults.split("\n")[0]
|
||||
const resultMatch = firstLine.match(/Found (\d+) result/)
|
||||
const resultCount = resultMatch ? parseInt(resultMatch[1], 10) : 0
|
||||
|
||||
return {
|
||||
workspaceName,
|
||||
workspaceResults,
|
||||
resultCount,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
// If search fails in one workspace, return error info
|
||||
console.error(`Search failed in ${absolutePath}:`, error)
|
||||
return {
|
||||
workspaceName,
|
||||
workspaceResults: "",
|
||||
resultCount: 0,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats search results based on workspace configuration
|
||||
*/
|
||||
private formatSearchResults(
|
||||
config: TaskConfig,
|
||||
searchResults: Array<{
|
||||
workspaceName?: string
|
||||
workspaceResults: string
|
||||
resultCount: number
|
||||
success: boolean
|
||||
}>,
|
||||
searchPaths: Array<{ absolutePath: string; workspaceName?: string }>,
|
||||
): string {
|
||||
const allResults: string[] = []
|
||||
let totalResultCount = 0
|
||||
|
||||
for (const { workspaceName, workspaceResults, resultCount, success } of searchResults) {
|
||||
if (!success || !workspaceResults) {
|
||||
continue
|
||||
}
|
||||
|
||||
totalResultCount += resultCount
|
||||
|
||||
// If multi-workspace and we have results, annotate with workspace name
|
||||
if (config.isMultiRootEnabled && searchPaths.length > 1 && workspaceName) {
|
||||
// Check if this workspace has results (resultCount > 0)
|
||||
if (resultCount > 0) {
|
||||
// Skip the "Found X results" line and add workspace annotation
|
||||
const lines = workspaceResults.split("\n")
|
||||
// Skip first two lines (count and empty line) if they exist
|
||||
const resultsWithoutHeader = lines.length > 2 ? lines.slice(2).join("\n") : workspaceResults
|
||||
|
||||
if (resultsWithoutHeader.trim()) {
|
||||
allResults.push(`## Workspace: ${workspaceName}\n${resultsWithoutHeader}`)
|
||||
}
|
||||
}
|
||||
// Don't add anything for workspaces with 0 results in multi-workspace mode
|
||||
} else if (!config.isMultiRootEnabled || searchPaths.length === 1) {
|
||||
// Single workspace mode or single workspace search
|
||||
allResults.push(workspaceResults)
|
||||
}
|
||||
}
|
||||
|
||||
// Combine results
|
||||
if (config.isMultiRootEnabled && searchPaths.length > 1) {
|
||||
// Multi-workspace search result
|
||||
if (totalResultCount === 0) {
|
||||
return "Found 0 results."
|
||||
} else {
|
||||
return `Found ${totalResultCount === 1 ? "1 result" : `${totalResultCount.toLocaleString()} results`} across ${searchPaths.length} workspace${searchPaths.length > 1 ? "s" : ""}.\n\n${allResults.join("\n\n")}`
|
||||
}
|
||||
} else {
|
||||
// Single workspace result
|
||||
return allResults[0] || "Found 0 results."
|
||||
}
|
||||
}
|
||||
|
||||
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
const relPath = block.params.path
|
||||
const regex = block.params.regex
|
||||
@@ -74,25 +221,30 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Resolve the absolute path based on multi-workspace configuration
|
||||
const pathResult = resolveWorkspacePath(config, relDirPath!, "SearchFilesTool.execute")
|
||||
const absolutePath = typeof pathResult === "string" ? pathResult : pathResult.absolutePath
|
||||
// Parse workspace hint from the path
|
||||
const { workspaceHint, relPath: parsedPath } = parseWorkspaceInlinePath(relDirPath!)
|
||||
|
||||
// Execute the actual regex search operation
|
||||
const results = await regexSearchFiles(
|
||||
config.cwd,
|
||||
absolutePath,
|
||||
regex,
|
||||
filePattern,
|
||||
config.services.clineIgnoreController,
|
||||
// Determine which paths to search
|
||||
const searchPaths = this.determineSearchPaths(config, parsedPath, workspaceHint, relDirPath!)
|
||||
|
||||
// Execute searches in all relevant workspaces in parallel
|
||||
const searchPromises = searchPaths.map(({ absolutePath, workspaceName, workspaceRoot }) =>
|
||||
this.executeSearch(config, absolutePath, workspaceName, workspaceRoot, regex, filePattern),
|
||||
)
|
||||
|
||||
// Wait for all searches to complete
|
||||
const searchResults = await Promise.all(searchPromises)
|
||||
|
||||
// Format and combine results
|
||||
const results = this.formatSearchResults(config, searchResults, searchPaths)
|
||||
|
||||
const sharedMessageProps = {
|
||||
tool: "searchFiles",
|
||||
path: getReadablePath(config.cwd, relDirPath!),
|
||||
content: results,
|
||||
regex: regex,
|
||||
filePattern: filePattern,
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relDirPath!),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(parsedPath),
|
||||
} satisfies ClineSayTool
|
||||
|
||||
const completeMessage = JSON.stringify(sharedMessageProps)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiHandler } from "@core/api"
|
||||
import { execSync } from "child_process"
|
||||
import { showSystemNotification } from "@/integrations/notifications"
|
||||
import { ClineApiReqCancelReason, ClineApiReqInfo } from "@/shared/ExtensionMessage"
|
||||
import { calculateApiCostAnthropic } from "@/utils/cost"
|
||||
@@ -59,3 +60,75 @@ export const updateApiReqMsg = async (params: UpdateApiReqMsgParams) => {
|
||||
} satisfies ClineApiReqInfo),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Common CLI tools that developers frequently use
|
||||
*/
|
||||
const CLI_TOOLS = [
|
||||
"gh",
|
||||
"git",
|
||||
"docker",
|
||||
"podman",
|
||||
"kubectl",
|
||||
"aws",
|
||||
"gcloud",
|
||||
"az",
|
||||
"terraform",
|
||||
"pulumi",
|
||||
"npm",
|
||||
"yarn",
|
||||
"pnpm",
|
||||
"pip",
|
||||
"cargo",
|
||||
"go",
|
||||
"curl",
|
||||
"jq",
|
||||
"make",
|
||||
"cmake",
|
||||
"python",
|
||||
"node",
|
||||
"psql",
|
||||
"mysql",
|
||||
"redis-cli",
|
||||
"sqlite3",
|
||||
"mongosh",
|
||||
"code",
|
||||
"grep",
|
||||
"sed",
|
||||
"awk",
|
||||
"brew",
|
||||
"apt",
|
||||
"yum",
|
||||
"gradle",
|
||||
"mvn",
|
||||
"bundle",
|
||||
"dotnet",
|
||||
"helm",
|
||||
"ansible",
|
||||
"wget",
|
||||
]
|
||||
|
||||
/**
|
||||
* Detect which CLI tools are available in the system PATH
|
||||
* Uses 'which' command on Unix-like systems and 'where' on Windows
|
||||
*/
|
||||
export async function detectAvailableCliTools(): Promise<string[]> {
|
||||
const availableCommands: string[] = []
|
||||
const isWindows = process.platform === "win32"
|
||||
const checkCommand = isWindows ? "where" : "which"
|
||||
|
||||
for (const command of CLI_TOOLS) {
|
||||
try {
|
||||
// Use execSync to check if the command exists
|
||||
execSync(`${checkCommand} ${command}`, {
|
||||
stdio: "ignore", // Don't output to console
|
||||
timeout: 1000, // 1 second timeout to avoid hanging
|
||||
})
|
||||
availableCommands.push(command)
|
||||
} catch (error) {
|
||||
// Command not found, skip it
|
||||
}
|
||||
}
|
||||
|
||||
return availableCommands
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ export class WorkspacePathAdapter {
|
||||
}
|
||||
|
||||
if (root) {
|
||||
// If no relative path specified, return the workspace root itself
|
||||
if (!relativePath) {
|
||||
return root.path
|
||||
}
|
||||
return path.join(root.path, relativePath)
|
||||
}
|
||||
|
||||
@@ -68,6 +72,10 @@ export class WorkspacePathAdapter {
|
||||
// Default to primary workspace
|
||||
const primaryRoot = manager.getPrimaryRoot()
|
||||
if (primaryRoot) {
|
||||
// If no relative path specified, return the workspace root itself
|
||||
if (!relativePath) {
|
||||
return primaryRoot.path
|
||||
}
|
||||
return path.join(primaryRoot.path, relativePath)
|
||||
}
|
||||
|
||||
|
||||
@@ -487,6 +487,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
}),
|
||||
)
|
||||
|
||||
// Register the reconstructTaskHistory command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(commands.ReconstructTaskHistory, async () => {
|
||||
const { reconstructTaskHistory } = await import("./core/commands/reconstructTaskHistory")
|
||||
await reconstructTaskHistory(context)
|
||||
telemetryService.captureButtonClick("command_reconstructTaskHistory")
|
||||
}),
|
||||
)
|
||||
|
||||
// Register the generateGitCommitMessage command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(commands.GenerateCommit, async (scm) => {
|
||||
|
||||
Vendored
+2
-1
@@ -60,7 +60,8 @@ export abstract class BaseGrpcClient<TClient> {
|
||||
|
||||
protected getClient(): TClient {
|
||||
if (!this.client || !this.channel) {
|
||||
this.channel = createChannel(this.address)
|
||||
const channelOptions = { "grpc.enable_http_proxy": 0 }
|
||||
this.channel = createChannel(this.address, undefined, channelOptions)
|
||||
this.client = this.createClient(this.channel)
|
||||
}
|
||||
return this.client
|
||||
|
||||
@@ -50,7 +50,6 @@ export class ServiceRegistry {
|
||||
}
|
||||
|
||||
this.methodMetadata[methodName] = { isStreaming, ...metadata }
|
||||
console.log(`Registered ${this.serviceName} method: ${methodName}${isStreaming ? " (streaming)" : ""}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
import { MessageStateHandler } from "@core/task/message-state"
|
||||
import { showChangedFilesDiff } from "@core/task/multifile-diff"
|
||||
import { VcsType, WorkspaceRootManager } from "@core/workspace"
|
||||
import { WorkspaceRootManager } from "@core/workspace"
|
||||
import { telemetryService } from "@services/telemetry"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ShowMessageType } from "@/shared/proto/host/window"
|
||||
@@ -35,7 +35,7 @@ import { ICheckpointManager } from "./types"
|
||||
* Only created when multiple roots are detected and feature flag is enabled.
|
||||
*
|
||||
* This implementation follows Option B: Simple All-Workspace Approach
|
||||
* - Checkpoints all Git-enabled workspaces every time
|
||||
* - Creates checkpoints instance for each input workspace root
|
||||
* - Commits run in parallel in the background (non-blocking)
|
||||
* - Maintains backward compatibility with single-root expectations
|
||||
*/
|
||||
@@ -52,7 +52,7 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initialize checkpoint trackers for all Git-enabled roots
|
||||
* Initialize checkpoint trackers for all workspace roots
|
||||
* This is called separately to avoid blocking the Task constructor
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
@@ -78,13 +78,10 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
|
||||
|
||||
const startTime = performance.now()
|
||||
const roots = this.workspaceManager.getRoots()
|
||||
const gitRoots = roots.filter((root) => root.vcs === VcsType.Git)
|
||||
console.log(
|
||||
`[MultiRootCheckpointManager] Initializing for ${roots.length} workspace roots (${gitRoots.length} Git-enabled)`,
|
||||
)
|
||||
console.log(`[MultiRootCheckpointManager] Initializing for ${roots.length} workspace roots`)
|
||||
|
||||
// Initialize all Git-enabled roots in parallel
|
||||
const initPromises = gitRoots.map(async (root) => {
|
||||
// Initialize all workspace roots in parallel
|
||||
const initPromises = roots.map(async (root) => {
|
||||
try {
|
||||
console.log(`[MultiRootCheckpointManager] Creating tracker for ${root.name} at ${root.path}`)
|
||||
const tracker = await CheckpointTracker.create(this.taskId, this.enableCheckpoints, root.path)
|
||||
@@ -112,7 +109,7 @@ export class MultiRootCheckpointManager implements ICheckpointManager {
|
||||
telemetryService.captureMultiRootCheckpoint(
|
||||
this.taskId,
|
||||
"initialized",
|
||||
gitRoots.length,
|
||||
roots.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
performance.now() - startTime,
|
||||
|
||||
@@ -25,6 +25,7 @@ const ClineCommands = {
|
||||
Walkthrough: prefix + ".openWalkthrough",
|
||||
GenerateCommit: prefix + ".generateGitCommitMessage",
|
||||
AbortCommit: prefix + ".abortGitCommitMessage",
|
||||
ReconstructTaskHistory: prefix + ".reconstructTaskHistory",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "@shared/ClineAccount"
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
|
||||
import { AuthService } from "../auth/AuthService"
|
||||
|
||||
export class ClineAccountService {
|
||||
@@ -46,10 +47,12 @@ export class ClineAccountService {
|
||||
* @throws Error if the API key is not found or the request fails
|
||||
*/
|
||||
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
|
||||
const url = `${this._baseUrl}${endpoint}`
|
||||
|
||||
const url = new URL(endpoint, this._baseUrl).toString() // Validate URL
|
||||
// IMPORTANT: Prefixed with 'workos:' so backend can route verification to WorkOS provider
|
||||
const clineAccountAuthToken = await this._authService.getAuthToken()
|
||||
|
||||
if (!clineAccountAuthToken) {
|
||||
throw new Error("No Cline account auth token found")
|
||||
}
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
@@ -145,7 +148,7 @@ export class ClineAccountService {
|
||||
*/
|
||||
async fetchMe(): Promise<UserResponse | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<UserResponse>(`/api/v1/users/me`)
|
||||
const data = await this.authenticatedRequest<UserResponse>(CLINE_API_ENDPOINT.USER_INFO)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user data (RPC):", error)
|
||||
@@ -223,7 +226,7 @@ export class ClineAccountService {
|
||||
// Call API to switch account
|
||||
try {
|
||||
// make XHR request to switch account
|
||||
const _response = await this.authenticatedRequest<string>(`/api/v1/users/active-account`, {
|
||||
const _response = await this.authenticatedRequest<string>(CLINE_API_ENDPOINT.ACTIVE_ACCOUNT, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -240,4 +243,22 @@ export class ClineAccountService {
|
||||
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transcribes audio using the Cline transcription service
|
||||
* @param audioBase64 - Base64 encoded audio data
|
||||
* @param language - Optional language hint for transcription
|
||||
* @returns Promise with transcribed text or error
|
||||
*/
|
||||
async transcribeAudio(audioBase64: string, language = "en"): Promise<{ text: string }> {
|
||||
const response = await this.authenticatedRequest<{ text: string }>(`/api/v1/chat/transcriptions`, {
|
||||
method: "POST",
|
||||
data: {
|
||||
audioData: audioBase64,
|
||||
language: language,
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,23 +7,29 @@ import { HostProvider } from "@/hosts/host-provider"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { openExternal } from "@/utils/env"
|
||||
import { featureFlagsService } from "../feature-flags"
|
||||
import { ClineAuthProvider } from "./providers/ClineAuthProvider"
|
||||
import { FirebaseAuthProvider } from "./providers/FirebaseAuthProvider"
|
||||
|
||||
const DefaultClineAccountURI = `${clineEnvConfig.appBaseUrl}/auth`
|
||||
let authProviders: any[] = []
|
||||
import { IAuthProvider } from "./providers/IAuthProvider"
|
||||
|
||||
export type ServiceConfig = {
|
||||
URI?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const availableAuthProviders = {
|
||||
firebase: FirebaseAuthProvider,
|
||||
// Add other providers here as needed
|
||||
}
|
||||
|
||||
export interface ClineAuthInfo {
|
||||
/**
|
||||
* accessToken
|
||||
*/
|
||||
idToken: string
|
||||
/**
|
||||
* Short-lived refresh token
|
||||
*/
|
||||
refreshToken?: string
|
||||
/**
|
||||
* Access token expiration time
|
||||
* When expired, the access token needs to be refreshed using the refresh token.
|
||||
*/
|
||||
expiresAt?: number
|
||||
userInfo: ClineAccountUserInfo
|
||||
}
|
||||
|
||||
@@ -37,6 +43,10 @@ export interface ClineAccountUserInfo {
|
||||
* Cline app base URL, used for webview UI and other client-side operations
|
||||
*/
|
||||
appBaseUrl?: string
|
||||
/**
|
||||
* WorkOS IDP ID if user logged in via SSO
|
||||
*/
|
||||
subject?: string
|
||||
}
|
||||
|
||||
export interface ClineAccountOrganization {
|
||||
@@ -47,15 +57,13 @@ export interface ClineAccountOrganization {
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
// TODO: Add logic to handle multiple webviews getting auth updates.
|
||||
|
||||
export class AuthService {
|
||||
protected static instance: AuthService | null = null
|
||||
protected _config: ServiceConfig
|
||||
protected _authenticated: boolean = false
|
||||
protected _clineAuthInfo: ClineAuthInfo | null = null
|
||||
protected _provider: { provider: FirebaseAuthProvider } | null = null
|
||||
protected _activeAuthStatusUpdateSubscriptions = new Set<[Controller, StreamingResponseHandler<AuthState>]>()
|
||||
protected _provider: IAuthProvider | null = null
|
||||
protected _activeAuthStatusUpdateHandlers = new Set<StreamingResponseHandler<AuthState>>()
|
||||
protected _handlerToController = new Map<StreamingResponseHandler<AuthState>, Controller>()
|
||||
protected _controller: Controller
|
||||
|
||||
/**
|
||||
@@ -63,36 +71,8 @@ export class AuthService {
|
||||
* @param controller - Optional reference to the Controller instance.
|
||||
*/
|
||||
protected constructor(controller: Controller) {
|
||||
const providerName = "firebase"
|
||||
this._config = { URI: DefaultClineAccountURI }
|
||||
|
||||
// Fetch AuthProviders
|
||||
// TODO: Deliver this config from the backend securely
|
||||
// ex. https://app.cline.bot/api/v1/auth/providers
|
||||
|
||||
const authProvidersConfigs = [
|
||||
{
|
||||
name: "firebase",
|
||||
config: clineEnvConfig.firebase,
|
||||
},
|
||||
]
|
||||
|
||||
// Merge authProviders with availableAuthProviders
|
||||
authProviders = authProvidersConfigs.map((provider) => {
|
||||
const providerName = provider.name
|
||||
const ProviderClass = availableAuthProviders[providerName as keyof typeof availableAuthProviders]
|
||||
if (!ProviderClass) {
|
||||
throw new Error(`Auth provider "${providerName}" is not available`)
|
||||
}
|
||||
return {
|
||||
name: providerName,
|
||||
config: provider.config,
|
||||
provider: new ProviderClass(provider.config),
|
||||
}
|
||||
})
|
||||
|
||||
this._setProvider(authProviders.find((authProvider) => authProvider.name === providerName).name)
|
||||
|
||||
// Default to firebase for now
|
||||
this._setProvider("firebase")
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
@@ -126,7 +106,7 @@ export class AuthService {
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
get authProvider(): any {
|
||||
get authProvider(): IAuthProvider | null {
|
||||
return this._provider
|
||||
}
|
||||
|
||||
@@ -134,29 +114,52 @@ export class AuthService {
|
||||
this._setProvider(providerName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current authentication token with the appropriate prefix.
|
||||
* Refreshing it if necessary.
|
||||
*/
|
||||
async getAuthToken(): Promise<string | null> {
|
||||
if (!this._clineAuthInfo) {
|
||||
return null
|
||||
}
|
||||
const idToken = this._clineAuthInfo.idToken
|
||||
const shouldRefreshIdToken = await this._provider?.provider.shouldRefreshIdToken(idToken)
|
||||
if (shouldRefreshIdToken) {
|
||||
// Retrieves the stored id token and refreshes it, then updates this._clineAuthInfo
|
||||
await this.restoreRefreshTokenAndRetrieveAuthInfo()
|
||||
if (!this._clineAuthInfo) {
|
||||
try {
|
||||
const clineAccountAuthToken = this._clineAuthInfo?.idToken
|
||||
if (!this._clineAuthInfo || !clineAccountAuthToken) {
|
||||
// Not authenticated
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if (await this._provider?.shouldRefreshIdToken(clineAccountAuthToken, this._clineAuthInfo.expiresAt)) {
|
||||
console.log("Provider indicates token needs refresh")
|
||||
const updatedAuthInfo = await this._provider?.retrieveClineAuthInfo(this._controller)
|
||||
if (updatedAuthInfo) {
|
||||
this._clineAuthInfo = updatedAuthInfo
|
||||
this._authenticated = true
|
||||
} else {
|
||||
this._clineAuthInfo = null
|
||||
this._authenticated = false
|
||||
}
|
||||
await this.sendAuthStatusUpdate()
|
||||
}
|
||||
// IMPORTANT: Prefix with 'workos:' so backend can route verification to WorkOS provider
|
||||
const prefix = this._provider?.name === "cline" ? "workos:" : ""
|
||||
return clineAccountAuthToken ? `${prefix}${clineAccountAuthToken}` : null
|
||||
} catch (error) {
|
||||
console.error("Error getting auth token:", error)
|
||||
return null
|
||||
}
|
||||
return this._clineAuthInfo.idToken
|
||||
}
|
||||
|
||||
protected _setProvider(providerName: string): void {
|
||||
const providerConfig = authProviders.find((provider) => provider.name === providerName)
|
||||
if (!providerConfig) {
|
||||
throw new Error(`Auth provider "${providerName}" not found`)
|
||||
// Only ClineAuthProvider is supported going forward
|
||||
// Keeping the providerName param for forward compatibility/telemetrye
|
||||
switch (providerName) {
|
||||
case "cline":
|
||||
this._provider = new ClineAuthProvider(clineEnvConfig)
|
||||
break
|
||||
case "firebase":
|
||||
default:
|
||||
this._provider = new FirebaseAuthProvider(clineEnvConfig)
|
||||
break
|
||||
}
|
||||
|
||||
this._provider = providerConfig
|
||||
}
|
||||
|
||||
getInfo(): AuthState {
|
||||
@@ -187,17 +190,14 @@ export class AuthService {
|
||||
return String.create({ value: "Already authenticated" })
|
||||
}
|
||||
|
||||
if (!this._config.URI) {
|
||||
throw new Error("Authentication URI is not configured")
|
||||
if (!this._provider) {
|
||||
return String.create({ value: "Authentication provider is not configured" })
|
||||
}
|
||||
|
||||
const callbackHost = await HostProvider.get().getCallbackUrl()
|
||||
const callbackUrl = `${callbackHost}/auth`
|
||||
|
||||
// Use URL object for more graceful query construction
|
||||
const authUrl = new URL(this._config.URI)
|
||||
authUrl.searchParams.set("callback_url", callbackUrl)
|
||||
|
||||
const authUrl = await this._provider.getAuthRequest(callbackUrl)
|
||||
const authUrlString = authUrl.toString()
|
||||
|
||||
await openExternal(authUrlString)
|
||||
@@ -219,14 +219,14 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async handleAuthCallback(token: string, provider: string): Promise<void> {
|
||||
async handleAuthCallback(authorizationCode: string, provider: string): Promise<void> {
|
||||
if (!this._provider) {
|
||||
throw new Error("Auth provider is not set")
|
||||
}
|
||||
|
||||
try {
|
||||
this._clineAuthInfo = await this._provider.provider.signIn(this._controller, token, provider)
|
||||
this._authenticated = true
|
||||
this._clineAuthInfo = await this._provider.signIn(this._controller, authorizationCode, provider)
|
||||
this._authenticated = this._clineAuthInfo?.idToken !== undefined
|
||||
|
||||
await this.sendAuthStatusUpdate()
|
||||
} catch (error) {
|
||||
@@ -244,16 +244,16 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the authentication token from the extension's storage.
|
||||
* Restores the authentication data from the extension's storage.
|
||||
* This is typically called when the extension is activated.
|
||||
*/
|
||||
async restoreRefreshTokenAndRetrieveAuthInfo(): Promise<void> {
|
||||
if (!this._provider || !this._provider.provider) {
|
||||
if (!this._provider) {
|
||||
throw new Error("Auth provider is not set")
|
||||
}
|
||||
|
||||
try {
|
||||
this._clineAuthInfo = await this._provider.provider.retrieveClineAuthInfo(this._controller)
|
||||
this._clineAuthInfo = await this._provider.retrieveClineAuthInfo(this._controller)
|
||||
if (this._clineAuthInfo) {
|
||||
this._authenticated = true
|
||||
await this.sendAuthStatusUpdate()
|
||||
@@ -286,10 +286,12 @@ export class AuthService {
|
||||
console.log("Subscribing to authStatusUpdate")
|
||||
|
||||
// Add this subscription to the active subscriptions
|
||||
this._activeAuthStatusUpdateSubscriptions.add([controller, responseStream])
|
||||
this._activeAuthStatusUpdateHandlers.add(responseStream)
|
||||
this._handlerToController.set(responseStream, controller)
|
||||
// Register cleanup when the connection is closed
|
||||
const cleanup = () => {
|
||||
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
|
||||
this._activeAuthStatusUpdateHandlers.delete(responseStream)
|
||||
this._handlerToController.delete(responseStream)
|
||||
}
|
||||
// Register the cleanup function with the request registry if we have a requestId
|
||||
if (requestId) {
|
||||
@@ -302,7 +304,8 @@ export class AuthService {
|
||||
} catch (error) {
|
||||
console.error("Error sending initial auth status:", error)
|
||||
// Remove the subscription if there was an error
|
||||
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
|
||||
this._activeAuthStatusUpdateHandlers.delete(responseStream)
|
||||
this._handlerToController.delete(responseStream)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,35 +313,41 @@ export class AuthService {
|
||||
* Send an authStatusUpdate event to all active subscribers
|
||||
*/
|
||||
async sendAuthStatusUpdate(): Promise<void> {
|
||||
// Send the event to all active subscribers
|
||||
const promises = Array.from(this._activeAuthStatusUpdateSubscriptions).map(async ([controller, responseStream]) => {
|
||||
try {
|
||||
const authInfo: AuthState = this.getInfo()
|
||||
// Compute once per broadcast
|
||||
const authInfo: AuthState = this.getInfo()
|
||||
const uniqueControllers = new Set<Controller>()
|
||||
|
||||
// Send the event to all active subscribers
|
||||
const streamSends = Array.from(this._activeAuthStatusUpdateHandlers).map(async (responseStream) => {
|
||||
const controller = this._handlerToController.get(responseStream)
|
||||
if (controller) {
|
||||
uniqueControllers.add(controller)
|
||||
}
|
||||
try {
|
||||
await responseStream(
|
||||
authInfo,
|
||||
false, // Not the last message
|
||||
)
|
||||
|
||||
// Identify the user in telemetry if available
|
||||
// Fetch the feature flags for the user
|
||||
if (this._clineAuthInfo?.userInfo?.id) {
|
||||
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
|
||||
featureFlagsService.reset()
|
||||
await featureFlagsService.poll()
|
||||
}
|
||||
|
||||
// Update the state in the webview
|
||||
if (controller) {
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending authStatusUpdate event:", error)
|
||||
// Remove the subscription if there was an error
|
||||
this._activeAuthStatusUpdateSubscriptions.delete([controller, responseStream])
|
||||
this._activeAuthStatusUpdateHandlers.delete(responseStream)
|
||||
this._handlerToController.delete(responseStream)
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
await Promise.all(streamSends)
|
||||
|
||||
// Identify the user in telemetry if available
|
||||
if (this._clineAuthInfo?.userInfo?.id) {
|
||||
telemetryService.identifyAccount(this._clineAuthInfo.userInfo)
|
||||
// Reset feature flags to ensure they are fetched for the new/logged in user
|
||||
featureFlagsService.reset()
|
||||
}
|
||||
// Poll feature flags to ensure they are up to date for all users
|
||||
await featureFlagsService.poll()
|
||||
|
||||
// Update state in webviews once per unique controller
|
||||
await Promise.all(Array.from(uniqueControllers).map((c) => c.postStateToWebview()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { String } from "@shared/proto/cline/common"
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import type { UserResponse } from "@/shared/ClineAccount"
|
||||
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
|
||||
import { AuthService } from "./AuthService"
|
||||
|
||||
// TODO: Consider adding a mock auth provider implementing IAuthProvider for more realistic testing
|
||||
export class AuthServiceMock extends AuthService {
|
||||
protected constructor(controller: Controller) {
|
||||
super(controller)
|
||||
@@ -13,8 +14,9 @@ export class AuthServiceMock extends AuthService {
|
||||
throw new Error("AuthServiceMock should only be used in local environment for testing purposes.")
|
||||
}
|
||||
|
||||
this._config = { URI: clineEnvConfig.apiBaseUrl }
|
||||
this._setProvider("firebase")
|
||||
// Support both auth providers, default to firebase for compatibility
|
||||
const authProvider = process.env.E2E_TEST_AUTH_PROVIDER || "firebase"
|
||||
this._setProvider(authProvider)
|
||||
this._controller = controller
|
||||
}
|
||||
|
||||
@@ -53,16 +55,20 @@ export class AuthServiceMock extends AuthService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch user data from mock server
|
||||
const meUri = new URL("/api/v1/users/me", clineEnvConfig.apiBaseUrl)
|
||||
// Use token exchange endpoint like ClineAuthProvider
|
||||
const tokenExchangeUri = new URL(CLINE_API_ENDPOINT.TOKEN_EXCHANGE, clineEnvConfig.apiBaseUrl)
|
||||
const tokenType = "personal"
|
||||
const testToken = `test-${tokenType}-token`
|
||||
const response = await fetch(meUri, {
|
||||
method: "GET",
|
||||
const testCode = `test-${tokenType}-token`
|
||||
|
||||
const response = await fetch(tokenExchangeUri, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${testToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: testCode,
|
||||
grantType: "authorization_code",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -75,30 +81,32 @@ export class AuthServiceMock extends AuthService {
|
||||
throw new Error("Invalid response from mock server")
|
||||
}
|
||||
|
||||
const userData = responseData.data as UserResponse
|
||||
const authData = responseData.data
|
||||
|
||||
// Convert UserResponse to ClineAuthInfo format
|
||||
// Convert to ClineAuthInfo format matching ClineAuthProvider
|
||||
this._clineAuthInfo = {
|
||||
idToken: testToken,
|
||||
idToken: authData.accessToken,
|
||||
refreshToken: authData.refreshToken,
|
||||
expiresAt: new Date(authData.expiresAt).getTime() / 1000,
|
||||
userInfo: {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
displayName: userData.displayName,
|
||||
createdAt: userData.createdAt,
|
||||
organizations: userData.organizations.map((org) => ({
|
||||
active: org.active,
|
||||
memberId: org.memberId,
|
||||
name: org.name,
|
||||
organizationId: org.organizationId,
|
||||
roles: org.roles,
|
||||
})),
|
||||
id: authData.userInfo.clineUserId || authData.userInfo.subject,
|
||||
email: authData.userInfo.email,
|
||||
displayName: authData.userInfo.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
organizations: authData.organizations,
|
||||
appBaseUrl: clineEnvConfig.appBaseUrl,
|
||||
subject: authData.userInfo.subject,
|
||||
},
|
||||
}
|
||||
|
||||
console.log(`Successfully authenticated with mock server as ${userData.displayName} (${userData.email})`)
|
||||
console.log(`Successfully authenticated with mock server as ${authData.userInfo.name} (${authData.userInfo.email})`)
|
||||
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.handleAuthCallback(testToken, "mock")
|
||||
|
||||
// Use appropriate provider name for callback
|
||||
const providerName = this._provider?.name || "mock"
|
||||
// Simulate handling the auth callback as if from a real provider
|
||||
await visibleWebview?.controller.handleAuthCallback(authData.accessToken, providerName)
|
||||
} catch (error) {
|
||||
console.error("Error signing in with mock server:", error)
|
||||
this._authenticated = false
|
||||
|
||||
@@ -2,7 +2,7 @@ import { OcaAuthState, OcaUserInfo } from "@shared/proto/cline/oca_account"
|
||||
import axios from "axios"
|
||||
import { jwtDecode } from "jwt-decode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { getProxyAgents } from "@/services/auth/oca/utils/utils"
|
||||
import { getAxiosSettings } from "@/services/auth/oca/utils/utils"
|
||||
|
||||
import { generateCodeVerifier, generateRandomString, pkceChallengeFromVerifier } from "../utils/utils"
|
||||
|
||||
@@ -93,7 +93,7 @@ export class OcaAuthProvider {
|
||||
}
|
||||
try {
|
||||
const { idcs_url, client_id } = this._config
|
||||
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getProxyAgents() })
|
||||
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getAxiosSettings() })
|
||||
const tokenEndpoint = discovery.data.token_endpoint
|
||||
const params: any = {
|
||||
grant_type: "refresh_token",
|
||||
@@ -102,7 +102,7 @@ export class OcaAuthProvider {
|
||||
}
|
||||
const tokenResponse = await axios.post(tokenEndpoint, new URLSearchParams(params), {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
...getProxyAgents(),
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
const accessToken = tokenResponse.data.access_token
|
||||
const userInfo: OcaUserInfo = await this.getUserAccountInfo(accessToken)
|
||||
@@ -159,7 +159,7 @@ export class OcaAuthProvider {
|
||||
}
|
||||
const { code_verifier, nonce, redirect_uri } = entry
|
||||
OcaAuthProvider.pkceStateMap.delete(state)
|
||||
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getProxyAgents() })
|
||||
const discovery = await axios.get(`${idcs_url}/.well-known/openid-configuration`, { ...getAxiosSettings() })
|
||||
const tokenEndpoint = discovery.data.token_endpoint
|
||||
const params: any = {
|
||||
grant_type: "authorization_code",
|
||||
@@ -170,7 +170,7 @@ export class OcaAuthProvider {
|
||||
}
|
||||
const tokenResponse = await axios.post(tokenEndpoint, new URLSearchParams(params), {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
...getProxyAgents(),
|
||||
...getAxiosSettings(),
|
||||
})
|
||||
// Step 1: Nonce validation
|
||||
const idToken = tokenResponse.data.id_token
|
||||
@@ -179,6 +179,8 @@ export class OcaAuthProvider {
|
||||
if (decoded.nonce !== nonce) {
|
||||
throw new Error("OIDC nonce verification failed")
|
||||
}
|
||||
} else {
|
||||
throw new Error("No ID token received from OCA")
|
||||
}
|
||||
|
||||
// Step 2: Get access_token (this is what you'll use for APIs)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import crypto from "crypto"
|
||||
import fs from "fs"
|
||||
import { type JwtPayload, jwtDecode } from "jwt-decode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import {
|
||||
DEFAULT_IDCS_CLIENT_ID,
|
||||
DEFAULT_IDCS_PORT_CANDIDATES,
|
||||
@@ -70,11 +73,6 @@ export function pkceChallengeFromVerifier(verifier: string): string {
|
||||
.replace(/=+$/, "")
|
||||
}
|
||||
|
||||
import { HttpsProxyAgent } from "https-proxy-agent"
|
||||
import { type JwtPayload, jwtDecode } from "jwt-decode"
|
||||
import * as vscode from "vscode"
|
||||
import { name, version } from "../../../../../package.json"
|
||||
|
||||
/**
|
||||
* Generates a compliant customer opc-request-id segment.
|
||||
*
|
||||
@@ -117,32 +115,26 @@ export async function generateOpcRequestId(taskId: string, token: string): Promi
|
||||
|
||||
export async function createOcaHeaders(accessToken: string, taskId: string): Promise<Record<string, string>> {
|
||||
const opcRequestId = await generateOpcRequestId(taskId, accessToken)
|
||||
const host = await HostProvider.env.getHostVersion({})
|
||||
const clineVersion = ExtensionRegistryInfo.version
|
||||
|
||||
return {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
client: "Cline",
|
||||
"client-version": `${name}-${version}`,
|
||||
"client-ide": vscode.env.appName,
|
||||
"client-ide-version": vscode.version,
|
||||
"client-version": `${clineVersion}`,
|
||||
"client-ide": host.platform || "unknown",
|
||||
"client-ide-version": host.version || "unknown",
|
||||
"opc-request-id": opcRequestId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy helpers for HTTPS/HTTP proxies via environment variables.
|
||||
* - Prioritizes HTTPS_PROXY over HTTP_PROXY
|
||||
* - Returns axios-compatible agent options when a proxy is configured
|
||||
*
|
||||
* @returns Axios settings including fetch adapter for compatibility
|
||||
*/
|
||||
export function getProxyUrl(): string | undefined {
|
||||
return process.env.HTTPS_PROXY || process.env.HTTP_PROXY
|
||||
}
|
||||
|
||||
export function getProxyAgents(): { httpAgent?: any; httpsAgent?: any } {
|
||||
const proxyUrl = getProxyUrl()
|
||||
if (!proxyUrl) return {}
|
||||
const agent = new HttpsProxyAgent(proxyUrl)
|
||||
return { httpAgent: agent as any, httpsAgent: agent as any }
|
||||
export function getAxiosSettings(): { adapter?: any } {
|
||||
return { adapter: "fetch" as any }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { clineEnvConfig, EnvironmentConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { CLINE_API_ENDPOINT } from "@/shared/cline/api"
|
||||
import type { ClineAuthInfo } from "../AuthService"
|
||||
import { IAuthProvider } from "./IAuthProvider"
|
||||
|
||||
interface ClineAuthApiUser {
|
||||
subject: string | null
|
||||
email: string
|
||||
name: string
|
||||
clineUserId: string | null
|
||||
accounts: string[] | null
|
||||
}
|
||||
|
||||
// Unified API response data shape for token exchange/refresh
|
||||
interface ClineAuthResponseData {
|
||||
/**
|
||||
* Auth token to be used for authenticated requests
|
||||
*/
|
||||
accessToken: string
|
||||
/**
|
||||
* Refresh token to be used for refreshing the access token
|
||||
*/
|
||||
refreshToken?: string
|
||||
/**
|
||||
* Token type
|
||||
* E.g. "Bearer"
|
||||
*/
|
||||
tokenType: string
|
||||
/**
|
||||
* Access token expiration time in ISO 8601 format
|
||||
* E.g. "2025-09-17T04:32:24.842636548Z"
|
||||
*/
|
||||
expiresAt: string
|
||||
/**
|
||||
* User information associated with the token
|
||||
*/
|
||||
userInfo: ClineAuthApiUser
|
||||
}
|
||||
|
||||
export interface ClineAuthApiTokenExchangeResponse {
|
||||
success: boolean
|
||||
data: ClineAuthResponseData
|
||||
}
|
||||
|
||||
export interface ClineAuthApiTokenRefreshResponse {
|
||||
success: boolean
|
||||
data: ClineAuthResponseData
|
||||
}
|
||||
|
||||
export class ClineAuthProvider implements IAuthProvider {
|
||||
readonly name = "cline"
|
||||
private _config
|
||||
|
||||
constructor(config: EnvironmentConfig) {
|
||||
this._config = config
|
||||
}
|
||||
|
||||
get config(): any {
|
||||
return this._config
|
||||
}
|
||||
|
||||
set config(value: any) {
|
||||
this._config = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the access token needs to be refreshed (expired or about to expire).
|
||||
* Since the new flow doesn't support refresh tokens, this will return true if token is expired.
|
||||
* @param _refreshToken - The existing refresh token to check.
|
||||
* @returns {Promise<boolean>} True if the token is expired or about to expire.
|
||||
*/
|
||||
async shouldRefreshIdToken(_refreshToken: string, expiresAt?: number): Promise<boolean> {
|
||||
try {
|
||||
// expiresAt is in seconds
|
||||
const expirationTime = expiresAt || 0
|
||||
const currentTime = Date.now() / 1000
|
||||
const next5Min = currentTime + 5 * 60
|
||||
|
||||
// Check if token is expired or will expire in the next 5 minutes
|
||||
return expirationTime < next5Min // Access token is expired or about to expire
|
||||
} catch (error) {
|
||||
Logger.error("Error checking token expiration:", error)
|
||||
return true // If we can't decode the token, assume it needs refresh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves Cline auth info using the stored access token.
|
||||
* @param controller - The controller instance to access stored secrets.
|
||||
* @returns {Promise<ClineAuthInfo | null>} A promise that resolves with the auth info or null.
|
||||
*/
|
||||
async retrieveClineAuthInfo(controller: Controller): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
// Get the stored auth data from secure storage
|
||||
const storedAuthDataString = controller.stateManager.getSecretKey("clineAccountId")
|
||||
|
||||
if (!storedAuthDataString) {
|
||||
Logger.debug("No stored authentication data found")
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse the stored auth data
|
||||
let storedAuthData: ClineAuthInfo
|
||||
try {
|
||||
storedAuthData = JSON.parse(storedAuthDataString)
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stored auth data:", e)
|
||||
controller.stateManager.setSecret("clineAccountId", undefined)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!storedAuthData.refreshToken || !storedAuthData?.idToken) {
|
||||
console.error("No valid token found in stored authentication data")
|
||||
controller.stateManager.setSecret("clineAccountId", undefined)
|
||||
return null
|
||||
}
|
||||
|
||||
if (await this.shouldRefreshIdToken(storedAuthData.refreshToken, storedAuthData.expiresAt)) {
|
||||
// Try to refresh the token using the refresh token
|
||||
const authInfo = await this.refreshToken(storedAuthData.refreshToken)
|
||||
return authInfo || null
|
||||
}
|
||||
|
||||
// Is the token valid?
|
||||
if (storedAuthData.idToken && storedAuthData.refreshToken && storedAuthData.userInfo.id) {
|
||||
return storedAuthData
|
||||
}
|
||||
|
||||
// Verify the token structure
|
||||
const tokenParts = storedAuthData.idToken.split(".")
|
||||
if (tokenParts.length !== 3) {
|
||||
throw new Error("Invalid token format")
|
||||
}
|
||||
|
||||
// Decode the token to verify it's a valid JWT
|
||||
const payload = JSON.parse(Buffer.from(tokenParts[1], "base64").toString("utf-8"))
|
||||
if (payload.external_id) {
|
||||
storedAuthData.userInfo.id = payload.external_id
|
||||
}
|
||||
|
||||
console.log("Successfully retrieved and validated stored auth token")
|
||||
return storedAuthData
|
||||
} catch (error) {
|
||||
console.error("Error retrieving stored authentication credential:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an access token using a refresh token.
|
||||
* @param refreshToken - The refresh token.
|
||||
* @returns {Promise<ClineAuthInfo>} The new access token and user info.
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<ClineAuthInfo> {
|
||||
try {
|
||||
// Get the callback URL that was used during the initial auth request
|
||||
const endpoint = new URL(CLINE_API_ENDPOINT.REFRESH_TOKEN, this._config.apiBaseUrl)
|
||||
const response = await fetch(endpoint.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken, // short_lived_auth_code
|
||||
grantType: "refresh_token", // must be "authorization_code"
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
const errorMessage = errorData?.error || "Invalid or expired authorization code"
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: ClineAuthApiTokenExchangeResponse = await response.json()
|
||||
|
||||
if (!data.success || !data.data.refreshToken || !data.data.accessToken) {
|
||||
throw new Error("Failed to exchange authorization code for access token")
|
||||
}
|
||||
|
||||
return {
|
||||
idToken: data.data.accessToken,
|
||||
// data.data.expiresAt example: "2025-09-17T03:43:57Z"; store in seconds
|
||||
expiresAt: new Date(data.data.expiresAt).getTime() / 1000,
|
||||
refreshToken: data.data.refreshToken || refreshToken,
|
||||
userInfo: {
|
||||
createdAt: new Date().toISOString(),
|
||||
email: data.data.userInfo.email || "",
|
||||
id: data.data.userInfo.clineUserId || "",
|
||||
displayName: data.data.userInfo.name || "",
|
||||
organizations: [],
|
||||
appBaseUrl: this._config.appBaseUrl,
|
||||
subject: data.data.userInfo.subject || "",
|
||||
},
|
||||
}
|
||||
} catch (error: any) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthRequest(callbackUrl: string): Promise<string> {
|
||||
const authUrl = new URL(CLINE_API_ENDPOINT.AUTH, clineEnvConfig.apiBaseUrl)
|
||||
authUrl.searchParams.set("client_type", "extension")
|
||||
authUrl.searchParams.set("callback_url", callbackUrl)
|
||||
// Ensure the redirect_uri is properly encoded and included
|
||||
authUrl.searchParams.set("redirect_uri", callbackUrl)
|
||||
|
||||
// The server will respond with a 302 redirect to the OAuth provider
|
||||
// We need to follow the redirect and get the final URL
|
||||
let response: Response
|
||||
try {
|
||||
// Set redirect: 'manual' to handle the redirect manually
|
||||
response = await fetch(authUrl.toString(), {
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
credentials: "include", // Important for cookies if needed
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
// If we get a redirect status (3xx), get the Location header
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const redirectUrl = response.headers.get("Location")
|
||||
if (!redirectUrl) {
|
||||
throw new Error("No redirect URL found in the response")
|
||||
}
|
||||
|
||||
return redirectUrl
|
||||
}
|
||||
|
||||
// If we didn't get a redirect, try to parse the response as JSON
|
||||
const responseData = await response.json()
|
||||
if (responseData.redirect_url) {
|
||||
return responseData.redirect_url
|
||||
}
|
||||
|
||||
throw new Error("Unexpected response from auth server")
|
||||
} catch (error) {
|
||||
console.error("Error during authentication request:", error)
|
||||
throw new Error(`Authentication failed: ${error instanceof Error ? error.message : "Unknown error"}`)
|
||||
}
|
||||
}
|
||||
|
||||
async signIn(controller: Controller, authorizationCode: string, provider: string): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
// Get the callback URL that was used during the initial auth request
|
||||
const callbackHost = await HostProvider.get().getCallbackUrl()
|
||||
const callbackUrl = `${callbackHost}/auth`
|
||||
|
||||
// Exchange the authorization code for tokens
|
||||
const tokenUrl = new URL(CLINE_API_ENDPOINT.TOKEN_EXCHANGE, clineEnvConfig.apiBaseUrl)
|
||||
|
||||
const response = await fetch(tokenUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: authorizationCode,
|
||||
client_type: "extension",
|
||||
redirect_uri: callbackUrl,
|
||||
provider: provider,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error_description || "Failed to exchange authorization code for tokens")
|
||||
}
|
||||
|
||||
const responseJSON = await response.json()
|
||||
console.log("Token data received:", responseJSON)
|
||||
|
||||
const responseType: ClineAuthApiTokenExchangeResponse = responseJSON
|
||||
const tokenData = responseType.data
|
||||
|
||||
if (!tokenData.accessToken || !tokenData.refreshToken || !tokenData.userInfo) {
|
||||
throw new Error("Invalid token response from server")
|
||||
}
|
||||
|
||||
// Store the tokens and user info
|
||||
const clineAuthInfo = {
|
||||
idToken: tokenData.accessToken,
|
||||
refreshToken: tokenData.refreshToken,
|
||||
userInfo: {
|
||||
id: tokenData.userInfo.clineUserId || "",
|
||||
email: tokenData.userInfo.email || "",
|
||||
displayName: tokenData.userInfo.name || "",
|
||||
createdAt: new Date().toISOString(),
|
||||
organizations: [],
|
||||
},
|
||||
expiresAt: new Date(tokenData.expiresAt).getTime() / 1000, // "2025-09-17T04:32:24.842636548Z"
|
||||
}
|
||||
|
||||
controller.stateManager.setSecret("clineAccountId", JSON.stringify(clineAuthInfo))
|
||||
|
||||
return clineAuthInfo
|
||||
} catch (error) {
|
||||
console.error("Error handling auth callback:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,19 @@ import axios from "axios"
|
||||
import { initializeApp } from "firebase/app"
|
||||
import { GithubAuthProvider, GoogleAuthProvider, getAuth, type OAuthCredential, signInWithCredential, User } from "firebase/auth"
|
||||
import { jwtDecode } from "jwt-decode"
|
||||
import { clineEnvConfig } from "@/config"
|
||||
import { clineEnvConfig, EnvironmentConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { ErrorService } from "@/services/error"
|
||||
import type { ClineAccountUserInfo, ClineAuthInfo } from "../AuthService"
|
||||
import { IAuthProvider } from "./IAuthProvider"
|
||||
|
||||
export class FirebaseAuthProvider {
|
||||
private _config: any
|
||||
export class FirebaseAuthProvider implements IAuthProvider {
|
||||
readonly name = "firebase"
|
||||
readonly callbackEndpoint = "/auth"
|
||||
|
||||
constructor(config: any) {
|
||||
private _config: EnvironmentConfig
|
||||
|
||||
constructor(config: EnvironmentConfig) {
|
||||
this._config = config || {}
|
||||
}
|
||||
|
||||
@@ -22,7 +26,7 @@ export class FirebaseAuthProvider {
|
||||
this._config = value
|
||||
}
|
||||
|
||||
async shouldRefreshIdToken(existingIdToken: string): Promise<boolean> {
|
||||
async shouldRefreshIdToken(existingIdToken: string, _expiresAt?: number): Promise<boolean> {
|
||||
const decodedToken = jwtDecode(existingIdToken)
|
||||
const exp = decodedToken.exp || 0 // 1752297633
|
||||
const expirationTime = exp * 1000
|
||||
@@ -48,23 +52,11 @@ export class FirebaseAuthProvider {
|
||||
}
|
||||
try {
|
||||
// Exchange refresh token for new access token using Firebase's secure token endpoint
|
||||
// https://stackoverflow.com/questions/38233687/how-to-use-the-firebase-refreshtoken-to-reauthenticate/57119131#57119131
|
||||
const firebaseApiKey = this._config.apiKey
|
||||
const googleAccessTokenResponse = await axios.post(
|
||||
`https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`,
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(userRefreshToken)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
)
|
||||
const { idToken } = await this.refreshToken(userRefreshToken)
|
||||
|
||||
// console.log("googleAccessTokenResponse", googleAccessTokenResponse)
|
||||
|
||||
// This returns an object with access_token, expires_in (3600), id_token (can be used as bearer token to authenticate requests, we'll use this in the future instead of firebase but need to be aware of how we use firebase sdk for e.g. user info like the profile image), project_id, refresh_token, token_type (always Bearer), and user_id
|
||||
const idToken = googleAccessTokenResponse.data.id_token
|
||||
// const idTokenExpirationDate = new Date(Date.now() + googleAccessTokenResponse.data.expires_in * 1000)
|
||||
if (!idToken) {
|
||||
throw new Error("No ID token received from refresh token exchange")
|
||||
}
|
||||
|
||||
// Now retrieve the user info from the backend (this was an easy solution to keep providing user profile details like name and email, but we should move to using the fetchMe() function instead)
|
||||
// Fetch user info from Cline API
|
||||
@@ -79,26 +71,39 @@ export class FirebaseAuthProvider {
|
||||
const userInfo: ClineAccountUserInfo = userResponse.data.data
|
||||
|
||||
return { idToken, userInfo }
|
||||
|
||||
// let userObject = JSON.parse(credentialJSON)
|
||||
// let user = User.
|
||||
// userObject = User.constructor._fromJSON(auth, user2);
|
||||
// const credentialData: AuthCredential = OAuthCredential.fromJSON(credentialJSON) as AuthCredential
|
||||
// const userCredential = await this._signInWithCredential(context, credentialData)
|
||||
// return userCredential.user
|
||||
} catch (error) {
|
||||
console.error("Firebase restore token error", error)
|
||||
ErrorService.get().logMessage("Firebase restore token error", "error")
|
||||
ErrorService.get().logException(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in the user using Firebase authentication with a custom token.
|
||||
* @returns {Promise<User>} A promise that resolves with the authenticated user.
|
||||
* @throws {Error} Throws an error if the sign-in fails.
|
||||
*/
|
||||
async refreshToken(userRefreshToken: string): Promise<Partial<ClineAuthInfo>> {
|
||||
// Exchange refresh token for new access token using Firebase's secure token endpoint
|
||||
// https://stackoverflow.com/questions/38233687/how-to-use-the-firebase-refreshtoken-to-reauthenticate/57119131#57119131
|
||||
const firebaseApiKey = this._config.firebase.apiKey
|
||||
const googleAccessTokenResponse = await axios.post(
|
||||
`https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`,
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(userRefreshToken)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// This returns an object with access_token, expires_in (3600), id_token (can be used as bearer token to authenticate requests, we'll use this in the future instead of firebase but need to be aware of how we use firebase sdk for e.g. user info like the profile image), project_id, refresh_token, token_type (always Bearer), and user_id
|
||||
// Store user data
|
||||
return { idToken: googleAccessTokenResponse.data.id_token }
|
||||
}
|
||||
|
||||
getAuthRequest(callbackUrl: string): Promise<string> {
|
||||
// Use URL object for more graceful query construction
|
||||
const authUrl = new URL(`${clineEnvConfig.appBaseUrl}/auth`)
|
||||
authUrl.searchParams.set("callback_url", callbackUrl)
|
||||
|
||||
return Promise.resolve(authUrl.toString())
|
||||
}
|
||||
|
||||
async signIn(controller: Controller, token: string, provider: string): Promise<ClineAuthInfo | null> {
|
||||
try {
|
||||
let credential: OAuthCredential
|
||||
@@ -113,7 +118,7 @@ export class FirebaseAuthProvider {
|
||||
throw new Error(`Unsupported provider: ${provider}`)
|
||||
}
|
||||
// we've received the short-lived tokens from google/github, now we need to sign in to firebase with them
|
||||
const firebaseConfig = Object.assign({}, this._config)
|
||||
const firebaseConfig = Object.assign({}, this._config.firebase)
|
||||
const app = initializeApp(firebaseConfig)
|
||||
const auth = getAuth(app)
|
||||
// this signs the user into firebase sdk internally
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { EnvironmentConfig } from "@/config"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { ClineAuthInfo } from "../AuthService"
|
||||
|
||||
export interface IAuthProvider {
|
||||
readonly name: string
|
||||
config: EnvironmentConfig
|
||||
shouldRefreshIdToken(token: string, expiresAt?: number): Promise<boolean>
|
||||
retrieveClineAuthInfo(controller: Controller): Promise<ClineAuthInfo | null>
|
||||
refreshToken(refreshToken: string): Promise<Partial<ClineAuthInfo>>
|
||||
getAuthRequest(callbackUrl: string): Promise<string>
|
||||
signIn(controller: Controller, authorizationCode: string, provider: string): Promise<ClineAuthInfo | null>
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { ChildProcess, spawn } from "node:child_process"
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { AUDIO_PROGRAM_CONFIG } from "@/shared/audioProgramConstants"
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export class AudioRecordingService {
|
||||
private recordingProcess: ChildProcess | null = null
|
||||
private startTime: number = 0
|
||||
private outputFile: string = ""
|
||||
|
||||
constructor() {}
|
||||
|
||||
/**
|
||||
* Determines if recording is currently active by checking process state
|
||||
*/
|
||||
private get isRecording(): boolean {
|
||||
return this.recordingProcess !== null && !this.recordingProcess.killed && this.recordingProcess.exitCode === null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the recording state variables
|
||||
*/
|
||||
private resetRecordingState(): void {
|
||||
this.recordingProcess = null
|
||||
this.startTime = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the temporary audio file
|
||||
*/
|
||||
private async cleanupTempFile(): Promise<void> {
|
||||
if (this.outputFile && fs.existsSync(this.outputFile)) {
|
||||
try {
|
||||
fs.unlinkSync(this.outputFile)
|
||||
Logger.info("Temporary audio file cleaned up")
|
||||
} catch (error) {
|
||||
Logger.warn("Failed to cleanup temporary audio file: " + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
this.outputFile = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates the recording process gracefully
|
||||
*/
|
||||
private async terminateProcess(): Promise<void> {
|
||||
if (!this.recordingProcess) {
|
||||
return
|
||||
}
|
||||
|
||||
Logger.info("Terminating recording process...")
|
||||
this.recordingProcess.kill("SIGINT")
|
||||
|
||||
// Wait for the process to finish with timeout
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
Logger.warn("Process termination timed out after 5 seconds")
|
||||
resolve()
|
||||
}, 5000)
|
||||
|
||||
this.recordingProcess?.on("exit", (code) => {
|
||||
clearTimeout(timeoutId)
|
||||
Logger.info(`Recording process exited with code: ${code}`)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs comprehensive cleanup of recording resources
|
||||
* @param options - Cleanup options
|
||||
* @param options.keepFile - If true, preserves the temporary file
|
||||
*/
|
||||
private async performCleanup(options?: { keepFile?: boolean }): Promise<void> {
|
||||
await this.terminateProcess()
|
||||
this.resetRecordingState()
|
||||
|
||||
if (!options?.keepFile) {
|
||||
await this.cleanupTempFile()
|
||||
}
|
||||
}
|
||||
|
||||
async startRecording(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Defensive cleanup before starting - ensures clean state
|
||||
if (this.recordingProcess || this.outputFile) {
|
||||
Logger.info("Performing pre-recording cleanup of stale resources...")
|
||||
await this.performCleanup()
|
||||
}
|
||||
|
||||
if (this.isRecording) {
|
||||
return { success: false, error: "Already recording" }
|
||||
}
|
||||
|
||||
// Check if recording software is available
|
||||
const checkResult = this.checkRecordingDependencies()
|
||||
if (!checkResult.available) {
|
||||
return { success: false, error: checkResult.error }
|
||||
}
|
||||
|
||||
// Create temporary file for audio output
|
||||
const tempDir = os.tmpdir()
|
||||
this.outputFile = path.join(tempDir, `cline_recording_${Date.now()}.webm`)
|
||||
|
||||
Logger.info("Starting audio recording...")
|
||||
|
||||
// Get the recording program path
|
||||
const recordProgram = this.getRecordProgram()
|
||||
if (!recordProgram) {
|
||||
return { success: false, error: "Recording program not found" }
|
||||
}
|
||||
Logger.info(`Using recording program: ${recordProgram.path}`)
|
||||
|
||||
// Set up recording arguments
|
||||
const args = recordProgram.getArgs(this.outputFile)
|
||||
|
||||
// Spawn the recording process
|
||||
this.recordingProcess = spawn(recordProgram.path, args)
|
||||
this.startTime = Date.now()
|
||||
|
||||
// Handle process errors
|
||||
this.recordingProcess.on("error", (error) => {
|
||||
Logger.error(`Recording process error: ${error.message}`)
|
||||
this.resetRecordingState()
|
||||
})
|
||||
|
||||
// Handle process exit
|
||||
this.recordingProcess.on("exit", (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
Logger.warn(`Recording process exited with code: ${code}`)
|
||||
}
|
||||
})
|
||||
|
||||
this.recordingProcess.stderr?.on("data", (data) => {
|
||||
const message = data.toString().trim()
|
||||
if (message && !message.includes("In:") && !message.includes("Out:")) {
|
||||
Logger.info(`Recording stderr: ${message}`)
|
||||
}
|
||||
})
|
||||
|
||||
Logger.info("Audio recording started successfully")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
await this.performCleanup()
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error("Failed to start audio recording: " + errorMessage)
|
||||
return { success: false, error: `Failed to start recording: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording(): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
|
||||
try {
|
||||
if (!this.isRecording) {
|
||||
return { success: false, error: "Not currently recording" }
|
||||
}
|
||||
|
||||
Logger.info("Stopping audio recording...")
|
||||
|
||||
// Terminate the process but keep the file for reading
|
||||
await this.terminateProcess()
|
||||
this.resetRecordingState()
|
||||
|
||||
// Wait a moment for file to be fully written
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
// Read the audio file and convert to base64
|
||||
if (!fs.existsSync(this.outputFile)) {
|
||||
return { success: false, error: "Recording file not found" }
|
||||
}
|
||||
|
||||
const audioBuffer = fs.readFileSync(this.outputFile)
|
||||
const audioBase64 = audioBuffer.toString("base64")
|
||||
|
||||
// Clean up temporary file after reading
|
||||
await this.cleanupTempFile()
|
||||
|
||||
Logger.info("Audio recording stopped and converted to base64")
|
||||
return { success: true, audioBase64 }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error("Failed to stop audio recording: " + errorMessage)
|
||||
|
||||
// Ensure cleanup happens even on error
|
||||
await this.performCleanup()
|
||||
|
||||
return { success: false, error: `Failed to stop recording: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
async cancelRecording(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (!this.isRecording) {
|
||||
return { success: false, error: "Not currently recording" }
|
||||
}
|
||||
|
||||
Logger.info("Canceling audio recording...")
|
||||
|
||||
// Perform full cleanup including file deletion
|
||||
await this.performCleanup()
|
||||
|
||||
Logger.info("Audio recording canceled successfully")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error("Failed to cancel audio recording: " + errorMessage)
|
||||
|
||||
// Ensure cleanup happens even on error
|
||||
await this.performCleanup()
|
||||
|
||||
return { success: false, error: `Failed to cancel recording: ${errorMessage}` }
|
||||
}
|
||||
}
|
||||
|
||||
getRecordingStatus(): { isRecording: boolean; durationSeconds: number; error?: string } {
|
||||
const durationSeconds = this.isRecording ? (Date.now() - this.startTime) / 1000 : 0
|
||||
return {
|
||||
isRecording: this.isRecording,
|
||||
durationSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
private checkRecordingDependencies(): { available: boolean; error?: string } {
|
||||
const program = this.getRecordProgram()
|
||||
if (!program) {
|
||||
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
|
||||
const config = AUDIO_PROGRAM_CONFIG[platform]
|
||||
const error = config ? config.error : `Audio recording is not supported on platform: ${platform}`
|
||||
return { available: false, error }
|
||||
}
|
||||
return { available: true }
|
||||
}
|
||||
|
||||
private getRecordProgram(): { path: string; getArgs: (outputFile: string) => string[] } | undefined {
|
||||
const platform = os.platform() as keyof typeof AUDIO_PROGRAM_CONFIG
|
||||
const config = AUDIO_PROGRAM_CONFIG[platform]
|
||||
|
||||
if (!config) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 1. Check if the command is in the system's PATH
|
||||
const pathDirs = (process.env.PATH || "").split(path.delimiter)
|
||||
for (const dir of pathDirs) {
|
||||
const fullPath = path.join(dir, config.command)
|
||||
if (fs.existsSync(fullPath) && isExecutable(fullPath)) {
|
||||
return { path: fullPath, getArgs: config.getArgs }
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check fallback paths if not in PATH
|
||||
for (const p of config.fallbackPaths) {
|
||||
if (fs.existsSync(p) && isExecutable(p)) {
|
||||
return { path: p, getArgs: config.getArgs }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Public cleanup method for service shutdown
|
||||
*/
|
||||
cleanup(): void {
|
||||
// Use async cleanup but don't await since this is often called in sync contexts
|
||||
this.performCleanup().catch((error) => {
|
||||
Logger.error("Error during cleanup: " + (error instanceof Error ? error.message : String(error)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const audioRecordingService = new AudioRecordingService()
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import axios from "axios"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
|
||||
// Network error matchers using Map for O(1) lookup
|
||||
const NETWORK_ERROR_MAP = new Map<string, string>([
|
||||
["enotfound", "No internet connection. Please check your network and try again."],
|
||||
["econnrefused", "Cannot connect to transcription service. Please check your internet connection."],
|
||||
["etimedout", "Connection timed out. Please check your internet connection and try again."],
|
||||
["econnreset", "Connection timed out. Please check your internet connection and try again."],
|
||||
["network error", "Network error. Please check your internet connection."],
|
||||
])
|
||||
|
||||
// HTTP status code error messages using Map for O(1) lookup
|
||||
const STATUS_ERROR_MAP = new Map<number, string>([
|
||||
[401, "Authentication failed. Please reauthenticate your Cline account"],
|
||||
[402, "Insufficient credits for transcription service."],
|
||||
[500, "Transcription server error. Please try again later."],
|
||||
])
|
||||
|
||||
// Special 400 error patterns that need custom handling
|
||||
const BAD_REQUEST_ERROR_PATTERNS = [
|
||||
{
|
||||
patterns: ["insufficient balance", "insufficient credits"],
|
||||
message: "Insufficient credits for transcription service.",
|
||||
},
|
||||
{
|
||||
patterns: ["invalid audio", "invalid format"],
|
||||
message: "Invalid audio format. Please try recording again.",
|
||||
},
|
||||
]
|
||||
|
||||
export class VoiceTranscriptionService {
|
||||
private readonly clineAccountService: ClineAccountService
|
||||
|
||||
constructor() {
|
||||
this.clineAccountService = ClineAccountService.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses transcription errors and returns user-friendly error messages
|
||||
* @param error The error object from the transcription attempt
|
||||
* @returns An object with the error message
|
||||
*/
|
||||
private parseTranscriptionError(error: unknown): { error: string } {
|
||||
// Handle axios errors with proper status code mapping
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status
|
||||
// Extract error message from server response - check both 'error' and 'message' fields
|
||||
const rawMessage = error.response?.data?.error || error.response?.data?.message || error.message
|
||||
const lowerMessage = rawMessage.toLowerCase()
|
||||
|
||||
// Check for network errors using the Map (these don't have status codes)
|
||||
for (const [keyword, response] of NETWORK_ERROR_MAP) {
|
||||
if (lowerMessage.includes(keyword)) {
|
||||
return { error: response }
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have a simple status code mapping
|
||||
if (status && STATUS_ERROR_MAP.has(status)) {
|
||||
return { error: STATUS_ERROR_MAP.get(status)! }
|
||||
}
|
||||
|
||||
// Handle special 400 errors with pattern matching
|
||||
if (status === 400) {
|
||||
// Check for specific error patterns
|
||||
for (const { patterns, message } of BAD_REQUEST_ERROR_PATTERNS) {
|
||||
if (patterns.some((pattern) => lowerMessage.includes(pattern))) {
|
||||
return { error: message }
|
||||
}
|
||||
}
|
||||
|
||||
// Check for limit exceeded messages (preserve original message)
|
||||
if (lowerMessage.includes("exceeds") && lowerMessage.includes("limit")) {
|
||||
return { error: rawMessage }
|
||||
}
|
||||
|
||||
// For other 400 errors, show the server's message if available, otherwise use generic
|
||||
return { error: rawMessage || "Invalid audio format or request data." }
|
||||
}
|
||||
|
||||
// Default case for unhandled status codes
|
||||
return {
|
||||
error: "Transcription failed. Please try again later or raise an issue on https://github.com/cline/cline/issues",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-axios errors (general network errors)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const lowerErrorMessage = errorMessage.toLowerCase()
|
||||
|
||||
// Check network errors using the Map
|
||||
for (const [keyword, response] of NETWORK_ERROR_MAP) {
|
||||
if (lowerErrorMessage.includes(keyword)) {
|
||||
return { error: response }
|
||||
}
|
||||
}
|
||||
|
||||
return { error: `Network error: ${errorMessage}` }
|
||||
}
|
||||
|
||||
async transcribeAudio(audioBase64: string, language?: string): Promise<{ text?: string; error?: string }> {
|
||||
try {
|
||||
Logger.info("Transcribing audio with Cline transcription service...")
|
||||
|
||||
// Check if using organization account for telemetry
|
||||
const userInfo = await this.clineAccountService.fetchMe()
|
||||
const activeOrg = userInfo?.organizations?.find((org) => org.active)
|
||||
const isOrgAccount = !!activeOrg
|
||||
|
||||
const result = await this.clineAccountService.transcribeAudio(audioBase64, language)
|
||||
|
||||
Logger.info("Transcription successful")
|
||||
|
||||
// Capture telemetry with account type - use dynamic import to avoid circular dependency
|
||||
const { telemetryService } = await import("@/services/telemetry")
|
||||
telemetryService.captureVoiceTranscriptionCompleted(
|
||||
undefined, // taskId
|
||||
result.text?.length,
|
||||
undefined, // duration
|
||||
language,
|
||||
isOrgAccount,
|
||||
)
|
||||
|
||||
return { text: result.text }
|
||||
} catch (error) {
|
||||
Logger.error("Voice transcription error:", error)
|
||||
return this.parseTranscriptionError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazily construct the service to avoid circular import initialization issues
|
||||
let _voiceTranscriptionServiceInstance: VoiceTranscriptionService | null = null
|
||||
export function getVoiceTranscriptionService(): VoiceTranscriptionService {
|
||||
if (!_voiceTranscriptionServiceInstance) {
|
||||
_voiceTranscriptionServiceInstance = new VoiceTranscriptionService()
|
||||
}
|
||||
return _voiceTranscriptionServiceInstance
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
|
||||
* When adding a new category, add it both here and to the initial values in telemetryCategoryEnabled
|
||||
* Ensure `if (!this.isCategoryEnabled('<category_name>')` is added to the capture method
|
||||
*/
|
||||
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain"
|
||||
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain" | "dictation"
|
||||
|
||||
/**
|
||||
* Enum for terminal output failure reasons
|
||||
@@ -76,6 +76,7 @@ export class TelemetryService {
|
||||
private telemetryCategoryEnabled: Map<TelemetryCategory, boolean> = new Map([
|
||||
["checkpoints", true], // Checkpoints telemetry enabled
|
||||
["browser", true], // Browser telemetry enabled
|
||||
["dictation", true], // Dictation telemetry enabled
|
||||
["focus_chain", true], // Focus Chain telemetry enabled
|
||||
])
|
||||
|
||||
@@ -88,6 +89,19 @@ export class TelemetryService {
|
||||
TELEMETRY_ENABLED: "user.telemetry_enabled",
|
||||
EXTENSION_ACTIVATED: "user.extension_activated",
|
||||
},
|
||||
DICTATION: {
|
||||
// Tracks when voice recording is started
|
||||
RECORDING_STARTED: "voice.recording_started",
|
||||
// Tracks when voice recording is stopped
|
||||
RECORDING_STOPPED: "voice.recording_stopped",
|
||||
// Tracks when voice transcription is started
|
||||
TRANSCRIPTION_STARTED: "voice.transcription_started",
|
||||
// Tracks when voice transcription is completed successfully
|
||||
TRANSCRIPTION_COMPLETED: "voice.transcription_completed",
|
||||
// Tracks when voice transcription fails
|
||||
TRANSCRIPTION_ERROR: "voice.transcription_error",
|
||||
// Tracks when voice feature is enabled or disabled in settings
|
||||
},
|
||||
// Workspace-related events for multi-root support
|
||||
WORKSPACE: {
|
||||
// Track workspace initialization
|
||||
@@ -285,7 +299,126 @@ export class TelemetryService {
|
||||
setDistinctId(userInfo.id)
|
||||
}
|
||||
}
|
||||
// Dictation events
|
||||
/**
|
||||
* Records when voice recording is started
|
||||
* @param taskId Optional task identifier if recording was started during a task
|
||||
* @param platform The platform where recording is happening (macOS, Windows, Linux)
|
||||
*/
|
||||
public captureVoiceRecordingStarted(taskId?: string, platform?: string) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.RECORDING_STARTED,
|
||||
properties: {
|
||||
taskId,
|
||||
platform: platform ?? process.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice recording is stopped
|
||||
* @param taskId Optional task identifier if recording was stopped during a task
|
||||
* @param durationMs Duration of the recording in milliseconds
|
||||
* @param success Whether the recording was successful
|
||||
* @param platform The platform where recording happened
|
||||
*/
|
||||
public captureVoiceRecordingStopped(taskId?: string, durationMs?: number, success?: boolean, platform?: string) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.RECORDING_STOPPED,
|
||||
properties: {
|
||||
taskId,
|
||||
durationMs,
|
||||
success,
|
||||
platform: platform ?? process.platform,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription is started
|
||||
* @param taskId Optional task identifier if transcription was started during a task
|
||||
* @param language Language hint provided for transcription
|
||||
*/
|
||||
public captureVoiceTranscriptionStarted(taskId?: string, language?: string) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_STARTED,
|
||||
properties: {
|
||||
taskId,
|
||||
language,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription is completed successfully
|
||||
* @param taskId Optional task identifier if transcription was completed during a task
|
||||
* @param transcriptionLength Length of the transcribed text
|
||||
* @param durationMs Time taken for transcription in milliseconds
|
||||
* @param language Language used for transcription
|
||||
* @param isOrgAccount Whether the transcription was done using an organization account
|
||||
*/
|
||||
public captureVoiceTranscriptionCompleted(
|
||||
taskId?: string,
|
||||
transcriptionLength?: number,
|
||||
durationMs?: number,
|
||||
language?: string,
|
||||
isOrgAccount?: boolean,
|
||||
) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_COMPLETED,
|
||||
properties: {
|
||||
taskId,
|
||||
transcriptionLength,
|
||||
durationMs,
|
||||
language,
|
||||
accountType: isOrgAccount ? "organization" : "personal",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when voice transcription fails
|
||||
* @param taskId Optional task identifier if transcription failed during a task
|
||||
* @param errorType Type of error that occurred (e.g., "no_openai_key", "api_error", "network_error")
|
||||
* @param errorMessage The error message
|
||||
* @param durationMs Time taken before failure in milliseconds
|
||||
*/
|
||||
public captureVoiceTranscriptionError(taskId?: string, errorType?: string, errorMessage?: string, durationMs?: number) {
|
||||
if (!this.isCategoryEnabled("dictation")) {
|
||||
return
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.DICTATION.TRANSCRIPTION_ERROR,
|
||||
properties: {
|
||||
taskId,
|
||||
errorType,
|
||||
errorMessage,
|
||||
durationMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
// Task events
|
||||
/**
|
||||
* Records when a new task/conversation is started
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect } from "chai"
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import * as sinon from "sinon"
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { ErrorService } from "../error"
|
||||
import { Logger } from "../logging/Logger"
|
||||
import { SharedUriHandler } from "./SharedUriHandler"
|
||||
|
||||
describe("SharedUriHandler", () => {
|
||||
@@ -9,9 +11,27 @@ describe("SharedUriHandler", () => {
|
||||
let handleOpenRouterCallbackStub: sinon.SinonStub
|
||||
let handleAuthCallbackStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock Logger methods to avoid HostProvider dependency
|
||||
sandbox.stub(Logger, "info").returns()
|
||||
sandbox.stub(Logger, "error").returns()
|
||||
// Mock ErrorService to avoid telemetry dependency
|
||||
const mockErrorService = {
|
||||
logMessage: sandbox.stub(),
|
||||
logException: sandbox.stub(),
|
||||
toClineError: sandbox.stub(),
|
||||
isEnabled: sandbox.stub().returns(false),
|
||||
getSettings: sandbox.stub().returns({ enabled: false, hostEnabled: false }),
|
||||
getProvider: sandbox.stub(),
|
||||
dispose: sandbox.stub().resolves(),
|
||||
}
|
||||
sandbox.stub(ErrorService, "initialize").resolves(mockErrorService as any)
|
||||
sandbox.stub(ErrorService, "get").returns(mockErrorService as any)
|
||||
|
||||
await ErrorService.initialize()
|
||||
|
||||
handleOpenRouterCallbackStub = sandbox.stub().resolves()
|
||||
handleAuthCallbackStub = sandbox.stub().resolves()
|
||||
const mockWebviewProvider = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebviewProvider } from "@/core/webview"
|
||||
import { Logger } from "../logging/Logger"
|
||||
|
||||
/**
|
||||
* Shared URI handler that processes both VSCode URI events and HTTP server callbacks
|
||||
@@ -18,16 +19,19 @@ export class SharedUriHandler {
|
||||
const queryString = parsedUrl.search.slice(1) // Remove leading '?'
|
||||
const query = new URLSearchParams(queryString.replace(/\+/g, "%2B"))
|
||||
|
||||
console.log("SharedUriHandler: Processing URI:", {
|
||||
path: path,
|
||||
query: query,
|
||||
scheme: parsedUrl.protocol,
|
||||
})
|
||||
Logger.info(
|
||||
"SharedUriHandler: Processing URI:" +
|
||||
JSON.stringify({
|
||||
path: path,
|
||||
query: query,
|
||||
scheme: parsedUrl.protocol,
|
||||
}),
|
||||
)
|
||||
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
|
||||
if (!visibleWebview) {
|
||||
console.warn("SharedUriHandler: No visible webview found")
|
||||
Logger.warn("SharedUriHandler: No visible webview found")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -44,15 +48,15 @@ export class SharedUriHandler {
|
||||
}
|
||||
case "/auth": {
|
||||
const provider = query.get("provider")
|
||||
const token = query.get("idToken")
|
||||
|
||||
console.log("SharedUriHandler: Auth callback received:", { path: path, provider: provider })
|
||||
Logger.info(`SharedUriHandler - Auth callback received for ${provider} - ${path}`)
|
||||
|
||||
const token = query.get("refreshToken") || query.get("idToken") || query.get("code")
|
||||
if (token) {
|
||||
await visibleWebview.controller.handleAuthCallback(token, provider)
|
||||
return true
|
||||
}
|
||||
console.warn("SharedUriHandler: Missing idToken parameter for auth callback")
|
||||
Logger.warn("SharedUriHandler: Missing idToken parameter for auth callback")
|
||||
return false
|
||||
}
|
||||
case "/auth/oca": {
|
||||
@@ -69,11 +73,11 @@ export class SharedUriHandler {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
console.warn(`SharedUriHandler: Unknown path: ${path}`)
|
||||
Logger.warn(`SharedUriHandler: Unknown path: ${path}`)
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SharedUriHandler: Error processing URI:", error)
|
||||
Logger.error("SharedUriHandler: Error processing URI:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ClineFeatureSetting {
|
||||
// Setting is enabled or disabled by user
|
||||
user: boolean
|
||||
// Setting is enabled or disabled by feature flag
|
||||
featureFlag: boolean
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface DictationSettings {
|
||||
featureEnabled: boolean // Feature flag - whether dictation feature is available
|
||||
dictationEnabled: boolean // User preference - whether user has enabled dictation
|
||||
dictationLanguage: string
|
||||
}
|
||||
|
||||
export const DEFAULT_DICTATION_SETTINGS: DictationSettings = {
|
||||
featureEnabled: false, // Feature flag, will be set by the extension based on platform
|
||||
dictationEnabled: false, // Default is false while this service is in Experimental status
|
||||
dictationLanguage: "en",
|
||||
}
|
||||
|
||||
export interface LanguageItem {
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export const SUPPORTED_DICTATION_LANGUAGES: LanguageItem[] = [
|
||||
{ name: "English", code: "en" },
|
||||
{ name: "Spanish (Español)", code: "es" },
|
||||
{ name: "Chinese (中文)", code: "zh" },
|
||||
{ name: "Japanese (日本語)", code: "ja" },
|
||||
{ name: "Afrikaans", code: "af" },
|
||||
{ name: "Arabic (العربية)", code: "ar" },
|
||||
{ name: "Armenian (Հայերեն)", code: "hy" },
|
||||
{ name: "Azerbaijani (Azərbaycan)", code: "az" },
|
||||
{ name: "Belarusian (Беларуская)", code: "be" },
|
||||
{ name: "Bosnian (Bosanski)", code: "bs" },
|
||||
{ name: "Bulgarian (Български)", code: "bg" },
|
||||
{ name: "Catalan (Català)", code: "ca" },
|
||||
{ name: "Croatian (Hrvatski)", code: "hr" },
|
||||
{ name: "Czech (Čeština)", code: "cs" },
|
||||
{ name: "Danish (Dansk)", code: "da" },
|
||||
{ name: "Dutch (Nederlands)", code: "nl" },
|
||||
{ name: "Estonian (Eesti)", code: "et" },
|
||||
{ name: "Finnish (Suomi)", code: "fi" },
|
||||
{ name: "French (Français)", code: "fr" },
|
||||
{ name: "Galician (Galego)", code: "gl" },
|
||||
{ name: "German (Deutsch)", code: "de" },
|
||||
{ name: "Greek (Ελληνικά)", code: "el" },
|
||||
{ name: "Hebrew (עברית)", code: "he" },
|
||||
{ name: "Hindi (हिन्दी)", code: "hi" },
|
||||
{ name: "Hungarian (Magyar)", code: "hu" },
|
||||
{ name: "Icelandic (Íslenska)", code: "is" },
|
||||
{ name: "Indonesian (Bahasa Indonesia)", code: "id" },
|
||||
{ name: "Italian (Italiano)", code: "it" },
|
||||
{ name: "Kannada (ಕನ್ನಡ)", code: "kn" },
|
||||
{ name: "Kazakh (Қазақша)", code: "kk" },
|
||||
{ name: "Korean (한국어)", code: "ko" },
|
||||
{ name: "Latvian (Latviešu)", code: "lv" },
|
||||
{ name: "Lithuanian (Lietuvių)", code: "lt" },
|
||||
{ name: "Macedonian (Македонски)", code: "mk" },
|
||||
{ name: "Malay (Bahasa Melayu)", code: "ms" },
|
||||
{ name: "Marathi (मराठी)", code: "mr" },
|
||||
{ name: "Maori (Te Reo Māori)", code: "mi" },
|
||||
{ name: "Nepali (नेपाली)", code: "ne" },
|
||||
{ name: "Norwegian (Norsk)", code: "no" },
|
||||
{ name: "Persian (فارسی)", code: "fa" },
|
||||
{ name: "Polish (Polski)", code: "pl" },
|
||||
{ name: "Portuguese (Português)", code: "pt" },
|
||||
{ name: "Romanian (Română)", code: "ro" },
|
||||
{ name: "Russian (Русский)", code: "ru" },
|
||||
{ name: "Serbian (Српски)", code: "sr" },
|
||||
{ name: "Slovak (Slovenčina)", code: "sk" },
|
||||
{ name: "Slovenian (Slovenščina)", code: "sl" },
|
||||
{ name: "Swahili (Kiswahili)", code: "sw" },
|
||||
{ name: "Swedish (Svenska)", code: "sv" },
|
||||
{ name: "Tagalog", code: "tl" },
|
||||
{ name: "Tamil (தமிழ்)", code: "ta" },
|
||||
{ name: "Thai (ไทย)", code: "th" },
|
||||
{ name: "Turkish (Türkçe)", code: "tr" },
|
||||
{ name: "Ukrainian (Українська)", code: "uk" },
|
||||
{ name: "Urdu (اردو)", code: "ur" },
|
||||
{ name: "Vietnamese (Tiếng Việt)", code: "vi" },
|
||||
{ name: "Welsh (Cymraeg)", code: "cy" },
|
||||
]
|
||||
@@ -4,14 +4,15 @@ import { WorkspaceRoot } from "../core/workspace"
|
||||
import { AutoApprovalSettings } from "./AutoApprovalSettings"
|
||||
import { ApiConfiguration } from "./api"
|
||||
import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ClineFeatureSetting } from "./ClineFeatureSetting"
|
||||
import { ClineRulesToggles } from "./cline-rules"
|
||||
import { DictationSettings } from "./DictationSettings"
|
||||
import { FocusChainSettings } from "./FocusChainSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpDisplayMode } from "./McpDisplayMode"
|
||||
import { Mode, OpenaiReasoningEffort } from "./storage/types"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
type: "grpc_response" // New type for gRPC responses
|
||||
@@ -70,12 +71,16 @@ export interface ExtensionState {
|
||||
yoloModeToggled?: boolean
|
||||
useAutoCondense?: boolean
|
||||
focusChainSettings: FocusChainSettings
|
||||
dictationSettings: DictationSettings
|
||||
customPrompt?: string
|
||||
autoCondenseThreshold?: number
|
||||
favoritedModelIds: string[]
|
||||
// NEW: Add workspace information
|
||||
workspaceRoots: WorkspaceRoot[]
|
||||
primaryRootIndex: number
|
||||
isMultiRootWorkspace: boolean
|
||||
multiRootSetting: ClineFeatureSetting
|
||||
lastDismissedInfoBannerVersion: number
|
||||
}
|
||||
|
||||
export interface ClineMessage {
|
||||
|
||||
+12
-1
@@ -262,6 +262,7 @@ export const CLAUDE_SONNET_4_1M_TIERS = [
|
||||
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
|
||||
export type AnthropicModelId = keyof typeof anthropicModels
|
||||
export const anthropicDefaultModelId: AnthropicModelId = "claude-sonnet-4-20250514"
|
||||
export const ANTHROPIC_MIN_THINKING_BUDGET = 1_024
|
||||
export const anthropicModels = {
|
||||
"claude-sonnet-4-20250514:1m": {
|
||||
maxTokens: 8192,
|
||||
@@ -596,7 +597,7 @@ export const openRouterDefaultModelInfo: ModelInfo = {
|
||||
|
||||
// Cline custom model - code-supernova
|
||||
export const clineCodeSupernovaModelInfo: ModelInfo = {
|
||||
contextWindow: 200000,
|
||||
contextWindow: 1000000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
@@ -2509,6 +2510,16 @@ export const nebiusDefaultModelId = "Qwen/Qwen2.5-32B-Instruct-fast" satisfies N
|
||||
export type XAIModelId = keyof typeof xaiModels
|
||||
export const xaiDefaultModelId: XAIModelId = "grok-4"
|
||||
export const xaiModels = {
|
||||
"grok-4-fast-reasoning": {
|
||||
maxTokens: 30000,
|
||||
contextWindow: 2000000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.2,
|
||||
cacheReadsPrice: 0.05,
|
||||
outputPrice: 0.5,
|
||||
description: "xAI's Grok 4 Fast (free) multimodal model with 2M context.",
|
||||
},
|
||||
"grok-4": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 262144,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export const AUDIO_PROGRAM_CONFIG = {
|
||||
darwin: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: ["/usr/local/bin/ffmpeg", "/opt/homebrew/bin/ffmpeg"],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"avfoundation",
|
||||
"-i",
|
||||
":default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
dependencyName: "FFmpeg",
|
||||
installCommand: "brew install ffmpeg",
|
||||
error: "FFmpeg is required for voice recording but is not installed on your system.",
|
||||
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
|
||||
},
|
||||
linux: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/snap/bin/ffmpeg"],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"alsa",
|
||||
"-i",
|
||||
"default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
dependencyName: "FFmpeg",
|
||||
installCommand: "sudo apt-get update && sudo apt-get install -y ffmpeg",
|
||||
error: "FFmpeg is required for voice recording but is not installed on your system.",
|
||||
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
|
||||
},
|
||||
win32: {
|
||||
command: "ffmpeg",
|
||||
fallbackPaths: [
|
||||
"C:\\ffmpeg\\bin\\ffmpeg.exe",
|
||||
"C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe",
|
||||
"C:\\Program Files (x86)\\ffmpeg\\bin\\ffmpeg.exe",
|
||||
],
|
||||
getArgs: (outputFile: string) => [
|
||||
"-f",
|
||||
"wasapi",
|
||||
"-i",
|
||||
"audio=default",
|
||||
"-c:a",
|
||||
"libopus",
|
||||
"-b:a",
|
||||
"32k",
|
||||
"-application",
|
||||
"voip",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
outputFile,
|
||||
],
|
||||
dependencyName: "FFmpeg",
|
||||
installCommand: "winget install Gyan.FFmpeg",
|
||||
error: "FFmpeg is required for voice recording but is not installed on your system.",
|
||||
installDescription: "FFmpeg is a multimedia framework that Cline uses to record audio from your microphone.",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
enum CLINE_API_AUTH_ENDPOINTS {
|
||||
AUTH = "/api/v1/auth/authorize",
|
||||
REFRESH_TOKEN = "/api/v1/auth/refresh",
|
||||
}
|
||||
|
||||
enum CLINE_API_ENDPOINT_V1 {
|
||||
TOKEN_EXCHANGE = "/api/v1/auth/token",
|
||||
USER_INFO = "/api/v1/users/me",
|
||||
ACTIVE_ACCOUNT = "/api/v1/users/active-account",
|
||||
}
|
||||
|
||||
export const CLINE_API_ENDPOINT = {
|
||||
...CLINE_API_AUTH_ENDPOINTS,
|
||||
...CLINE_API_ENDPOINT_V1,
|
||||
}
|
||||
+1
-1
@@ -111,4 +111,4 @@ export interface McpDownloadResponse {
|
||||
requiresApiKey: boolean
|
||||
}
|
||||
|
||||
export type McpViewTab = "marketplace" | "addRemote" | "installed"
|
||||
export type McpViewTab = "marketplace" | "addRemote" | "configure"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export enum FeatureFlag {
|
||||
CUSTOM_INSTRUCTIONS = "custom-instructions",
|
||||
DEV_ENV_POSTHOG = "dev-env-posthog",
|
||||
DICTATION = "dictation",
|
||||
FOCUS_CHAIN_CHECKLIST = "focus_chain_checklist",
|
||||
MULTI_ROOT_WORKSPACE = "multi_root_workspace",
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ function createHealthClient(address: string) {
|
||||
const healthDef = protoLoader.loadSync(health.protoPath)
|
||||
const grpcObj = grpc.loadPackageDefinition(healthDef) as unknown as any
|
||||
const Health = grpcObj.grpc.health.v1.Health
|
||||
return new Health(address, grpc.credentials.createInsecure())
|
||||
const opts: grpc.ChannelOptions = { "grpc.enable_http_proxy": 0 }
|
||||
return new Health(address, grpc.credentials.createInsecure(), opts)
|
||||
}
|
||||
|
||||
async function checkHealthOnce(client: any): Promise<boolean> {
|
||||
|
||||
@@ -48,19 +48,6 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
|
||||
const chatInputBox = sidebar.getByTestId("chat-input")
|
||||
await expect(chatInputBox).toBeVisible()
|
||||
|
||||
// Verify the help improve banner is visible and can be closed.
|
||||
const telemetryBanner = sidebar.getByText("Help Improve Cline")
|
||||
await expect(telemetryBanner).toBeVisible()
|
||||
await sidebar.getByText("settings").click() // Click on the settings link in the banner
|
||||
await expect(sidebar.getByText("General Settings")).toBeVisible() // Default view should be set to General tab
|
||||
await sidebar.getByTestId("tab-api-config").click()
|
||||
await expect(sidebar.locator("h4").getByText("API Configuration")).toBeVisible()
|
||||
await sidebar.getByTestId("tab-about").click()
|
||||
await expect(sidebar.getByRole("heading", { name: "About" }).locator("div").first()).toBeVisible()
|
||||
|
||||
// Exit the Settings view by clicking the Done button
|
||||
await sidebar.getByRole("button", { name: "Done" }).click()
|
||||
|
||||
// Verify the release banner is visible for new installs and can be closed.
|
||||
const releaseBanner = sidebar.getByRole("heading", {
|
||||
name: /^🎉 New in v\d/,
|
||||
@@ -68,5 +55,4 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
|
||||
await expect(releaseBanner).toBeVisible()
|
||||
await sidebar.getByTestId("close-button").locator("span").first().click()
|
||||
await expect(releaseBanner).not.toBeVisible()
|
||||
await expect(telemetryBanner).not.toBeVisible()
|
||||
})
|
||||
|
||||
+48
-72
@@ -1,89 +1,65 @@
|
||||
import { expect } from "@playwright/test"
|
||||
import { E2E_WORKSPACE_TYPES, e2e } from "./utils/helpers"
|
||||
import { e2e } from "./utils/helpers"
|
||||
|
||||
e2e.describe("Chat - can send messages and switch between modes", () => {
|
||||
E2E_WORKSPACE_TYPES.forEach(({ title, workspaceType }) => {
|
||||
e2e.extend({
|
||||
workspaceType,
|
||||
})(title, async ({ helper, sidebar, page }) => {
|
||||
// Sign in
|
||||
await helper.signin(sidebar)
|
||||
e2e("Chat - can send messages and switch between modes", async ({ helper, sidebar, page }) => {
|
||||
// Sign in
|
||||
await helper.signin(sidebar)
|
||||
|
||||
// Submit a message
|
||||
const inputbox = sidebar.getByTestId("chat-input")
|
||||
await expect(inputbox).toBeVisible()
|
||||
await inputbox.fill("Hello, Cline!")
|
||||
await expect(inputbox).toHaveValue("Hello, Cline!")
|
||||
await sidebar.getByTestId("send-button").click({ delay: 100 })
|
||||
await expect(inputbox).toHaveValue("")
|
||||
// Submit a message
|
||||
const inputbox = sidebar.getByTestId("chat-input")
|
||||
await expect(inputbox).toBeVisible()
|
||||
await inputbox.fill("Hello, Cline!")
|
||||
await expect(inputbox).toHaveValue("Hello, Cline!")
|
||||
await sidebar.getByTestId("send-button").click({ delay: 100 })
|
||||
await expect(inputbox).toHaveValue("")
|
||||
|
||||
// Loading State initially
|
||||
await expect(sidebar.getByText("API Request...")).toBeVisible()
|
||||
// Loading State initially
|
||||
await expect(sidebar.getByText("API Request...")).toBeVisible()
|
||||
|
||||
// The request should eventually fail
|
||||
await expect(sidebar.getByText("API Request Failed")).toBeVisible()
|
||||
// Starting a new task should clear the current chat view and show the recent tasks
|
||||
await sidebar.getByRole("button", { name: "New Task" }).click()
|
||||
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
|
||||
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
|
||||
|
||||
await expect(inputbox).toBeVisible()
|
||||
// Makes sure the act and plan switches are working correctly
|
||||
// Aria-checked state should be true for Act and false for Plan
|
||||
const actButton = sidebar.getByRole("switch", { name: "Act" })
|
||||
const planButton = sidebar.getByRole("switch", { name: "Plan" })
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "Retry" })).toBeVisible()
|
||||
await expect(sidebar.getByRole("button", { name: "Start New Task" })).toBeVisible()
|
||||
await expect(actButton).toBeChecked()
|
||||
await expect(planButton).not.toBeChecked()
|
||||
|
||||
// Starting a new task should clear the current chat view and show the recent tasks
|
||||
await sidebar.getByRole("button", { name: "Start New Task" }).click()
|
||||
await expect(sidebar.getByText("API Request Failed")).not.toBeVisible()
|
||||
await expect(sidebar.getByText("Recent Tasks")).toBeVisible()
|
||||
await expect(sidebar.getByText("Hello, Cline!")).toBeVisible()
|
||||
await actButton.click()
|
||||
await expect(actButton).not.toBeChecked()
|
||||
await expect(planButton).toBeChecked()
|
||||
|
||||
// Makes sure the act and plan switches are working correctly
|
||||
// Aria-checked state should be true for Act and false for Plan
|
||||
const actButton = sidebar.getByRole("switch", { name: "Act" })
|
||||
const planButton = sidebar.getByRole("switch", { name: "Plan" })
|
||||
// === slash commands preserve following text ===
|
||||
await expect(inputbox).toHaveValue("")
|
||||
// Type partial slash command to trigger menu
|
||||
await inputbox.pressSequentially("/new", { delay: 100 })
|
||||
|
||||
await expect(actButton).toBeChecked()
|
||||
await expect(planButton).not.toBeChecked()
|
||||
// Wait for menu to be visible and select first option with Tab
|
||||
await inputbox.press("Tab")
|
||||
await expect(inputbox).toHaveValue("/newtask ")
|
||||
|
||||
await actButton.click()
|
||||
await expect(actButton).not.toBeChecked()
|
||||
await expect(planButton).toBeChecked()
|
||||
// Add following text to verify it works correctly
|
||||
await inputbox.pressSequentially("following text should be preserved")
|
||||
await expect(inputbox).toHaveValue("/newtask following text should be preserved")
|
||||
|
||||
await inputbox.fill("Plan mode submission")
|
||||
await sidebar.getByTestId("send-button").click()
|
||||
// === @ mentions preserve following text ===
|
||||
await inputbox.fill("")
|
||||
await expect(inputbox).toHaveValue("")
|
||||
|
||||
await expect(sidebar.getByText("API Request Failed")).toBeVisible()
|
||||
// Type partial @ mention to trigger menu
|
||||
await inputbox.pressSequentially("@prob")
|
||||
|
||||
// === slash commands preserve following text ===
|
||||
await inputbox.fill("")
|
||||
await expect(inputbox).toHaveValue("")
|
||||
await inputbox.focus()
|
||||
// Wait for menu to be visible and select first option with Tab
|
||||
await inputbox.press("Tab")
|
||||
await expect(inputbox).toHaveValue("@problems ")
|
||||
|
||||
// Type partial slash command to trigger menu
|
||||
await inputbox.pressSequentially("/new")
|
||||
// Add following text to verify it works correctly
|
||||
await inputbox.pressSequentially("following text should be preserved")
|
||||
await expect(inputbox).toHaveValue("@problems following text should be preserved")
|
||||
|
||||
// Wait for menu to be visible and select first option with Tab
|
||||
await inputbox.press("Tab")
|
||||
await expect(inputbox).toHaveValue("/newtask ")
|
||||
|
||||
// Add following text to verify it works correctly
|
||||
await inputbox.pressSequentially("following text should be preserved")
|
||||
await expect(inputbox).toHaveValue("/newtask following text should be preserved")
|
||||
|
||||
// === @ mentions preserve following text ===
|
||||
await inputbox.fill("")
|
||||
await expect(inputbox).toHaveValue("")
|
||||
await inputbox.focus()
|
||||
|
||||
// Type partial @ mention to trigger menu
|
||||
await inputbox.pressSequentially("@prob")
|
||||
|
||||
// Wait for menu to be visible and select first option with Tab
|
||||
await inputbox.press("Tab")
|
||||
await expect(inputbox).toHaveValue("@problems ")
|
||||
|
||||
// Add following text to verify it works correctly
|
||||
await inputbox.pressSequentially("following text should be preserved")
|
||||
await expect(inputbox).toHaveValue("@problems following text should be preserved")
|
||||
|
||||
await page.close()
|
||||
})
|
||||
})
|
||||
await page.close()
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ export const E2E_REGISTERED_MOCK_ENDPOINTS = {
|
||||
"/users/{userId}/usages",
|
||||
"/users/{userId}/payments",
|
||||
],
|
||||
POST: ["/chat/completions"],
|
||||
POST: ["/chat/completions", "/auth/token"],
|
||||
PUT: ["/users/active-account"],
|
||||
},
|
||||
"/.test": {
|
||||
|
||||
@@ -117,7 +117,7 @@ export class ClineDataMock {
|
||||
public getUserByToken(token?: string): UserResponse {
|
||||
// Use default personal token if none provided
|
||||
const actualToken = token || ClineDataMock.getDefaultToken("personal") || "test-personal-token"
|
||||
const currentUser = this._getUserByToken(actualToken)
|
||||
const currentUser = this._getUserByToken(actualToken.replace("_access", "")) // Remove _access suffix if present
|
||||
this.setCurrentUser(currentUser)
|
||||
return currentUser
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ export class ClineApiServerMock {
|
||||
|
||||
// Authentication middleware
|
||||
const authHeader = req.headers.authorization
|
||||
const isAuthRequired = !path.startsWith("/.test/") && path !== "/health"
|
||||
const isAuthRequired = !path.startsWith("/.test/") && path !== "/health" && path !== "/api/v1/auth/token"
|
||||
|
||||
if (isAuthRequired && (!authHeader || !authHeader.startsWith("Bearer "))) {
|
||||
return sendApiError("Unauthorized", 401)
|
||||
@@ -296,6 +296,71 @@ export class ClineApiServerMock {
|
||||
return sendApiResponse("Account switched successfully")
|
||||
}
|
||||
|
||||
// Auth token exchange endpoint
|
||||
if (endpoint === "/auth/token" && method === "POST") {
|
||||
const body = await readBody()
|
||||
const parsed = JSON.parse(body)
|
||||
const { code, grantType } = parsed
|
||||
|
||||
if (grantType !== "authorization_code" || !code) {
|
||||
return sendApiError("Invalid request", 400)
|
||||
}
|
||||
|
||||
const user = controller.API_USER.getUserByToken(code)
|
||||
if (!user) {
|
||||
return sendApiError("Invalid or expired authorization code", 400)
|
||||
}
|
||||
|
||||
// Return format matching ClineAuthProvider expectations
|
||||
return sendApiResponse({
|
||||
accessToken: code + "_access",
|
||||
refreshToken: code + "_refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
|
||||
userInfo: {
|
||||
subject: user.id,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
clineUserId: user.id,
|
||||
accounts: null,
|
||||
organizations: user.organizations,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Auth refresh token endpoint
|
||||
if (endpoint === "/auth/refresh" && method === "POST") {
|
||||
const body = await readBody()
|
||||
const parsed = JSON.parse(body)
|
||||
const { refreshToken, grantType } = parsed
|
||||
|
||||
if (grantType !== "refresh_token" || !refreshToken) {
|
||||
return sendApiError("Invalid request", 400)
|
||||
}
|
||||
|
||||
// Extract original token from refresh token
|
||||
const originalToken = refreshToken.replace("_refresh", "")
|
||||
const user = controller.API_USER.getUserByToken(originalToken)
|
||||
if (!user) {
|
||||
return sendApiError("Invalid or expired refresh token", 400)
|
||||
}
|
||||
|
||||
// Return format matching ClineAuthProvider expectations
|
||||
return sendApiResponse({
|
||||
accessToken: originalToken + "_access_refreshed",
|
||||
refreshToken: refreshToken, // Keep same refresh token
|
||||
tokenType: "Bearer",
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), // 1 hour from now
|
||||
userInfo: {
|
||||
subject: user.id,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
clineUserId: user.id,
|
||||
accounts: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Chat completions endpoint
|
||||
if (endpoint === "/chat/completions" && method === "POST") {
|
||||
if (!controller.userHasOrganization && controller.userBalance <= 0) {
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { ServiceClients } from "@adapters/grpcAdapter"
|
||||
|
||||
export interface Meta {
|
||||
synthetic: boolean
|
||||
/**
|
||||
* Optional subset of the expected response to validate.
|
||||
* Only the fields specified here will be compared against the actual response.
|
||||
* Useful for partial validation of nested objects or arrays.
|
||||
*/
|
||||
expected?: any
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
requestId: string
|
||||
service: keyof ServiceClients
|
||||
@@ -7,6 +17,7 @@ export interface Entry {
|
||||
request: any
|
||||
response?: any
|
||||
status: string
|
||||
meta: Meta
|
||||
}
|
||||
|
||||
export interface SpecFile {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "fs"
|
||||
import { diff } from "jest-diff"
|
||||
import _ from "lodash"
|
||||
import path from "path"
|
||||
|
||||
export function loadJson(filePath: string): any {
|
||||
@@ -10,7 +11,20 @@ export function pretty(obj: any): string {
|
||||
return JSON.stringify(obj, null, 2)
|
||||
}
|
||||
|
||||
// Normalize object and ignore specified fields
|
||||
/**
|
||||
* Recursively normalizes an object or array by:
|
||||
* 1. Ignoring specified fields.
|
||||
* 2. Sorting arrays in a stable manner.
|
||||
* 3. Parsing JSON strings where possible.
|
||||
*
|
||||
* This ensures that comparison between objects/arrays is consistent
|
||||
* and ignores non-deterministic or irrelevant fields.
|
||||
*
|
||||
* @param obj - The object/array/value to normalize.
|
||||
* @param ignoreFields - List of field names or dot-paths to ignore during normalization.
|
||||
* @param parentPath - Internal use for tracking nested paths (used for ignoreFields).
|
||||
* @returns A normalized object/array/value suitable for comparison.
|
||||
*/
|
||||
function normalize(obj: any, ignoreFields: string[] = [], parentPath = ""): any {
|
||||
if (Array.isArray(obj)) {
|
||||
// Normalize each element, then sort in a stable way
|
||||
@@ -44,21 +58,96 @@ function normalize(obj: any, ignoreFields: string[] = [], parentPath = ""): any
|
||||
return obj
|
||||
}
|
||||
|
||||
// Compare two objects, ignoring specified fields & array order
|
||||
export function compareResponse(actual: any, expected: any, ignoreFields: string[] = []): { success: boolean; diffs: string[] } {
|
||||
const diffs: string[] = []
|
||||
/**
|
||||
* Recursively picks only the keys specified in `filter` from `actual`.
|
||||
* This is used for partial comparison of objects and arrays.
|
||||
*
|
||||
* Behavior:
|
||||
* 1. For objects: keeps only keys present in `filter`, recursively.
|
||||
* 2. For arrays: assumes `filter` is an array and picks corresponding keys
|
||||
* from each element in `actual` array.
|
||||
* 3. For primitives: returns the actual value.
|
||||
*
|
||||
* @param actual - The full object/array/value received.
|
||||
* @param filter - The subset of keys/structure to keep from `actual`.
|
||||
* @returns A new object/array/value that only contains keys from `filter`.
|
||||
*
|
||||
* Example:
|
||||
* actual = {
|
||||
* a: 1,
|
||||
* b: { x: 10, y: 20 },
|
||||
* c: [{ id: 1, val: "x" }, { id: 2, val: "y" }]
|
||||
* }
|
||||
*
|
||||
* filter = {
|
||||
* b: { y: 20 },
|
||||
* c: [{ val: "x" }]
|
||||
* }
|
||||
*
|
||||
* Result:
|
||||
* {
|
||||
* b: { y: 20 },
|
||||
* c: [{ val: "x" }, undefined]
|
||||
* }
|
||||
*/
|
||||
function pickDeep(actual: any, filter: any): any {
|
||||
if (_.isArray(filter)) {
|
||||
if (!_.isArray(actual)) return actual
|
||||
|
||||
const normalizedActual = normalize(actual, ignoreFields)
|
||||
const normalizedExpected = normalize(expected, ignoreFields)
|
||||
|
||||
if (JSON.stringify(normalizedActual) !== JSON.stringify(normalizedExpected)) {
|
||||
const difference = diff(normalizedExpected, normalizedActual, {
|
||||
expand: false, // compact diff
|
||||
})
|
||||
diffs.push(difference || "Objects differ but no diff generated.")
|
||||
// Compare arrays element by element, picking only keys from filter
|
||||
return filter.map((f, i) => pickDeep(actual[i], f))
|
||||
}
|
||||
|
||||
return { success: diffs.length === 0, diffs }
|
||||
if (_.isPlainObject(filter)) {
|
||||
return _.mapValues(filter, (v, k) => (actual && k in actual ? pickDeep(actual[k], v) : undefined))
|
||||
}
|
||||
|
||||
return actual
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares an actual response against an expected response, with optional:
|
||||
* - Ignored fields
|
||||
* - Partial comparison (via expectedSubset)
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Normalizes actual and expected objects (sorting arrays, parsing JSON strings).
|
||||
* 2. If expectedSubset is provided, only compares the keys/structure defined in it.
|
||||
* 3. Returns a boolean success flag and an array of diffs (for reporting mismatches).
|
||||
*
|
||||
* @param actual - The response received from gRPC call.
|
||||
* @param expected - The full expected response from the spec file.
|
||||
* @param ignoreFields - Fields or paths to ignore in comparison.
|
||||
* @param expectedSubset - Optional subset of fields to validate (for meta.expected).
|
||||
* @returns Object with `success` (true/false) and `diffs` (array of string diffs).
|
||||
*/
|
||||
export function compareResponse(
|
||||
actual: any,
|
||||
expected: any,
|
||||
ignoreFields: string[] = [],
|
||||
expectedSubset?: any,
|
||||
): { success: boolean; diffs: string[] } {
|
||||
const actualToCompare = normalize(actual, ignoreFields)
|
||||
const expectedToCompare = normalize(expected, ignoreFields)
|
||||
|
||||
if (expectedSubset) {
|
||||
// Extract only the subset we care about
|
||||
const actualSubset = pickDeep(actualToCompare, expectedSubset)
|
||||
const success = _.isEqual(actualSubset, expectedSubset)
|
||||
if (!success) {
|
||||
const difference = diff(expectedSubset, actualSubset, { expand: false })
|
||||
return { success: false, diffs: [difference || "Objects differ"] }
|
||||
}
|
||||
return { success: true, diffs: [] }
|
||||
}
|
||||
|
||||
// Fallback: full comparison
|
||||
const success = _.isEqual(actualToCompare, expectedToCompare)
|
||||
if (!success) {
|
||||
const difference = diff(expectedToCompare, actualToCompare, { expand: false })
|
||||
return { success: false, diffs: [difference || "Objects differ"] }
|
||||
}
|
||||
return { success: true, diffs: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -55,7 +55,12 @@ async function runSpec(specPath: string, grpcAdapter: GrpcAdapter) {
|
||||
await retry(async () => {
|
||||
actualResponse = await grpcAdapter.call(entry.service, entry.method, entry.request)
|
||||
|
||||
const { success, diffs } = compareResponse(actualResponse, entry?.response?.message, NON_DETERMINISTIC_FIELDS)
|
||||
const { success, diffs } = compareResponse(
|
||||
actualResponse,
|
||||
entry?.response?.message,
|
||||
NON_DETERMINISTIC_FIELDS,
|
||||
entry.meta?.expected,
|
||||
)
|
||||
|
||||
if (success) {
|
||||
console.log("✅ Response matched! RequestID: %s", entry.requestId)
|
||||
|
||||
Generated
+16
@@ -11,6 +11,8 @@
|
||||
"jest-diff": "^30.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.20",
|
||||
"lodash": "^4.17.21",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
@@ -120,6 +122,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
|
||||
@@ -254,6 +263,13 @@
|
||||
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/make-error": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"jest-diff": "^30.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.17.20",
|
||||
"lodash": "^4.17.21",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"response": {
|
||||
"message": {
|
||||
"stateJson": "{\"version\":\"3.30.3\",\"apiConfiguration\":{\"openRouterApiKey\":\"test-api-key\",\"openAiHeaders\":{},\"planModeApiProvider\":\"openrouter\",\"planModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\",\"actModeApiProvider\":\"openrouter\",\"actModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\"},\"clineMessages\":[],\"currentFocusChainChecklist\":null,\"autoApprovalSettings\":{\"version\":1,\"enabled\":true,\"actions\":{\"readFiles\":true,\"readFilesExternally\":false,\"editFiles\":false,\"editFilesExternally\":false,\"executeSafeCommands\":true,\"executeAllCommands\":false,\"useBrowser\":false,\"useMcp\":false},\"maxRequests\":20,\"enableNotifications\":false,\"favorites\":[\"enableAutoApprove\",\"readFiles\",\"editFiles\"]},\"browserSettings\":{\"viewport\":{\"width\":900,\"height\":600},\"remoteBrowserEnabled\":false,\"remoteBrowserHost\":\"http://localhost:9222\",\"chromeExecutablePath\":\"\",\"disableToolUse\":false,\"customArgs\":\"\"},\"focusChainSettings\":{\"enabled\":true,\"remindClineInterval\":6},\"preferredLanguage\":\"English\",\"openaiReasoningEffort\":\"medium\",\"mode\":\"act\",\"strictPlanModeEnabled\":true,\"yoloModeToggled\":false,\"useAutoCondense\":false,\"mcpMarketplaceEnabled\":true,\"mcpDisplayMode\":\"plain\",\"telemetrySetting\":\"unset\",\"planActSeparateModelsSetting\":false,\"enableCheckpointsSetting\":true,\"distinctId\":\"fake-machine-id-Joses-MacBook-Pro.local\",\"globalClineRulesToggles\":{},\"localClineRulesToggles\":{},\"localWindsurfRulesToggles\":{},\"localCursorRulesToggles\":{},\"localWorkflowToggles\":{},\"globalWorkflowToggles\":{},\"shellIntegrationTimeout\":4000,\"terminalReuseEnabled\":true,\"defaultTerminalProfile\":\"default\",\"isNewUser\":true,\"welcomeViewCompleted\":true,\"mcpResponsesCollapsed\":false,\"terminalOutputLineLimit\":500,\"taskHistory\":[],\"platform\":\"darwin\",\"shouldShowAnnouncement\":true,\"favoritedModelIds\":[],\"workspaceRoots\":[],\"primaryRootIndex\":0,\"isMultiRootWorkspace\":false}"
|
||||
"stateJson": "{\"version\":\"3.30.3\",\"apiConfiguration\":{\"openRouterApiKey\":\"test-api-key\",\"openAiHeaders\":{},\"planModeApiProvider\":\"openrouter\",\"planModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\",\"actModeApiProvider\":\"openrouter\",\"actModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\"},\"clineMessages\":[],\"currentFocusChainChecklist\":null,\"autoApprovalSettings\":{\"version\":1,\"enabled\":true,\"actions\":{\"readFiles\":true,\"readFilesExternally\":false,\"editFiles\":false,\"editFilesExternally\":false,\"executeSafeCommands\":true,\"executeAllCommands\":false,\"useBrowser\":false,\"useMcp\":false},\"maxRequests\":20,\"enableNotifications\":false,\"favorites\":[\"enableAutoApprove\",\"readFiles\",\"editFiles\"]},\"browserSettings\":{\"viewport\":{\"width\":900,\"height\":600},\"remoteBrowserEnabled\":false,\"remoteBrowserHost\":\"http://localhost:9222\",\"chromeExecutablePath\":\"\",\"disableToolUse\":false,\"customArgs\":\"\"},\"focusChainSettings\":{\"enabled\":true,\"remindClineInterval\":6},\"dictationSettings\":{\"featureEnabled\":true,\"dictationEnabled\":false,\"dictationLanguage\":\"en\"},\"preferredLanguage\":\"English\",\"openaiReasoningEffort\":\"medium\",\"mode\":\"act\",\"strictPlanModeEnabled\":true,\"yoloModeToggled\":false,\"useAutoCondense\":false,\"mcpMarketplaceEnabled\":true,\"mcpDisplayMode\":\"plain\",\"telemetrySetting\":\"unset\",\"planActSeparateModelsSetting\":false,\"enableCheckpointsSetting\":true,\"distinctId\":\"fake-machine-id-Joses-MacBook-Pro.local\",\"globalClineRulesToggles\":{},\"localClineRulesToggles\":{},\"localWindsurfRulesToggles\":{},\"localCursorRulesToggles\":{},\"localWorkflowToggles\":{},\"globalWorkflowToggles\":{},\"shellIntegrationTimeout\":4000,\"terminalReuseEnabled\":true,\"defaultTerminalProfile\":\"default\",\"isNewUser\":true,\"welcomeViewCompleted\":true,\"mcpResponsesCollapsed\":false,\"terminalOutputLineLimit\":500,\"taskHistory\":[],\"platform\":\"darwin\",\"shouldShowAnnouncement\":true,\"favoritedModelIds\":[],\"workspaceRoots\":[],\"primaryRootIndex\":0,\"isMultiRootWorkspace\":false}"
|
||||
}
|
||||
},
|
||||
"duration": 0
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
},
|
||||
"response": {
|
||||
"message": {
|
||||
"stateJson": "{\"version\":\"3.30.3\",\"apiConfiguration\":{\"openAiHeaders\":{},\"sapAiCoreUseOrchestrationMode\":true,\"planModeApiProvider\":\"cline\",\"planModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\",\"actModeApiProvider\":\"cline\",\"actModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\"},\"clineMessages\":[],\"currentFocusChainChecklist\":null,\"autoApprovalSettings\":{\"version\":1,\"enabled\":true,\"actions\":{\"readFiles\":true,\"readFilesExternally\":false,\"editFiles\":false,\"editFilesExternally\":false,\"executeSafeCommands\":true,\"executeAllCommands\":false,\"useBrowser\":false,\"useMcp\":false},\"maxRequests\":20,\"enableNotifications\":false,\"favorites\":[\"enableAutoApprove\",\"readFiles\",\"editFiles\"]},\"browserSettings\":{\"viewport\":{\"width\":900,\"height\":600},\"remoteBrowserEnabled\":false,\"remoteBrowserHost\":\"http://localhost:9222\",\"chromeExecutablePath\":\"\",\"disableToolUse\":false,\"customArgs\":\"\"},\"focusChainSettings\":{\"enabled\":true,\"remindClineInterval\":6},\"preferredLanguage\":\"English\",\"openaiReasoningEffort\":\"medium\",\"mode\":\"act\",\"strictPlanModeEnabled\":true,\"yoloModeToggled\":false,\"useAutoCondense\":false,\"mcpMarketplaceEnabled\":true,\"mcpDisplayMode\":\"plain\",\"telemetrySetting\":\"unset\",\"planActSeparateModelsSetting\":false,\"enableCheckpointsSetting\":true,\"distinctId\":\"fake-machine-id-Joses-MacBook-Pro.local\",\"globalClineRulesToggles\":{},\"localClineRulesToggles\":{},\"localWindsurfRulesToggles\":{},\"localCursorRulesToggles\":{},\"localWorkflowToggles\":{},\"globalWorkflowToggles\":{},\"shellIntegrationTimeout\":4000,\"terminalReuseEnabled\":true,\"defaultTerminalProfile\":\"default\",\"isNewUser\":true,\"welcomeViewCompleted\":true,\"mcpResponsesCollapsed\":false,\"terminalOutputLineLimit\":500,\"taskHistory\":[],\"platform\":\"darwin\",\"shouldShowAnnouncement\":true,\"favoritedModelIds\":[],\"workspaceRoots\":[],\"primaryRootIndex\":0,\"isMultiRootWorkspace\":false}"
|
||||
"stateJson": "{\"version\":\"3.30.3\",\"apiConfiguration\":{\"openAiHeaders\":{},\"sapAiCoreUseOrchestrationMode\":true,\"planModeApiProvider\":\"cline\",\"planModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\",\"actModeApiProvider\":\"cline\",\"actModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\"},\"clineMessages\":[],\"currentFocusChainChecklist\":null,\"autoApprovalSettings\":{\"version\":1,\"enabled\":true,\"actions\":{\"readFiles\":true,\"readFilesExternally\":false,\"editFiles\":false,\"editFilesExternally\":false,\"executeSafeCommands\":true,\"executeAllCommands\":false,\"useBrowser\":false,\"useMcp\":false},\"maxRequests\":20,\"enableNotifications\":false,\"favorites\":[\"enableAutoApprove\",\"readFiles\",\"editFiles\"]},\"browserSettings\":{\"viewport\":{\"width\":900,\"height\":600},\"remoteBrowserEnabled\":false,\"remoteBrowserHost\":\"http://localhost:9222\",\"chromeExecutablePath\":\"\",\"disableToolUse\":false,\"customArgs\":\"\"},\"focusChainSettings\":{\"enabled\":true,\"remindClineInterval\":6},\"dictationSettings\":{\"featureEnabled\":true,\"dictationEnabled\":false,\"dictationLanguage\":\"en\"},\"preferredLanguage\":\"English\",\"openaiReasoningEffort\":\"medium\",\"mode\":\"act\",\"strictPlanModeEnabled\":true,\"yoloModeToggled\":false,\"useAutoCondense\":false,\"mcpMarketplaceEnabled\":true,\"mcpDisplayMode\":\"plain\",\"telemetrySetting\":\"unset\",\"planActSeparateModelsSetting\":false,\"enableCheckpointsSetting\":true,\"distinctId\":\"fake-machine-id-Joses-MacBook-Pro.local\",\"globalClineRulesToggles\":{},\"localClineRulesToggles\":{},\"localWindsurfRulesToggles\":{},\"localCursorRulesToggles\":{},\"localWorkflowToggles\":{},\"globalWorkflowToggles\":{},\"shellIntegrationTimeout\":4000,\"terminalReuseEnabled\":true,\"defaultTerminalProfile\":\"default\",\"isNewUser\":true,\"welcomeViewCompleted\":true,\"mcpResponsesCollapsed\":false,\"terminalOutputLineLimit\":500,\"taskHistory\":[],\"platform\":\"darwin\",\"shouldShowAnnouncement\":true,\"favoritedModelIds\":[],\"workspaceRoots\":[],\"primaryRootIndex\":0,\"isMultiRootWorkspace\":false}"
|
||||
}
|
||||
},
|
||||
"duration": 0
|
||||
@@ -169,7 +169,7 @@
|
||||
},
|
||||
"response": {
|
||||
"message": {
|
||||
"stateJson": "{\"version\":\"3.30.3\",\"apiConfiguration\":{\"openAiHeaders\":{},\"sapAiCoreUseOrchestrationMode\":true,\"planModeApiProvider\":\"cline\",\"planModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\",\"actModeApiProvider\":\"cline\",\"actModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\"},\"clineMessages\":[],\"currentFocusChainChecklist\":null,\"autoApprovalSettings\":{\"version\":1,\"enabled\":true,\"actions\":{\"readFiles\":true,\"readFilesExternally\":false,\"editFiles\":false,\"editFilesExternally\":false,\"executeSafeCommands\":true,\"executeAllCommands\":false,\"useBrowser\":false,\"useMcp\":false},\"maxRequests\":20,\"enableNotifications\":false,\"favorites\":[\"enableAutoApprove\",\"readFiles\",\"editFiles\"]},\"browserSettings\":{\"viewport\":{\"width\":900,\"height\":600},\"remoteBrowserEnabled\":false,\"remoteBrowserHost\":\"http://localhost:9222\",\"chromeExecutablePath\":\"\",\"disableToolUse\":false,\"customArgs\":\"\"},\"focusChainSettings\":{\"enabled\":true,\"remindClineInterval\":6},\"preferredLanguage\":\"English\",\"openaiReasoningEffort\":\"medium\",\"mode\":\"act\",\"strictPlanModeEnabled\":true,\"yoloModeToggled\":false,\"useAutoCondense\":false,\"mcpMarketplaceEnabled\":true,\"mcpDisplayMode\":\"plain\",\"telemetrySetting\":\"unset\",\"planActSeparateModelsSetting\":false,\"enableCheckpointsSetting\":true,\"distinctId\":\"fake-machine-id-Joses-MacBook-Pro.local\",\"globalClineRulesToggles\":{},\"localClineRulesToggles\":{},\"localWindsurfRulesToggles\":{},\"localCursorRulesToggles\":{},\"localWorkflowToggles\":{},\"globalWorkflowToggles\":{},\"shellIntegrationTimeout\":4000,\"terminalReuseEnabled\":true,\"defaultTerminalProfile\":\"default\",\"isNewUser\":true,\"welcomeViewCompleted\":true,\"mcpResponsesCollapsed\":false,\"terminalOutputLineLimit\":500,\"taskHistory\":[],\"platform\":\"darwin\",\"shouldShowAnnouncement\":true,\"favoritedModelIds\":[],\"workspaceRoots\":[],\"primaryRootIndex\":0,\"isMultiRootWorkspace\":false}"
|
||||
"stateJson": "{\"version\":\"3.30.3\",\"apiConfiguration\":{\"openAiHeaders\":{},\"sapAiCoreUseOrchestrationMode\":true,\"planModeApiProvider\":\"cline\",\"planModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\",\"actModeApiProvider\":\"cline\",\"actModeFireworksModelId\":\"accounts/fireworks/models/kimi-k2-instruct-0905\"},\"clineMessages\":[],\"currentFocusChainChecklist\":null,\"autoApprovalSettings\":{\"version\":1,\"enabled\":true,\"actions\":{\"readFiles\":true,\"readFilesExternally\":false,\"editFiles\":false,\"editFilesExternally\":false,\"executeSafeCommands\":true,\"executeAllCommands\":false,\"useBrowser\":false,\"useMcp\":false},\"maxRequests\":20,\"enableNotifications\":false,\"favorites\":[\"enableAutoApprove\",\"readFiles\",\"editFiles\"]},\"browserSettings\":{\"viewport\":{\"width\":900,\"height\":600},\"remoteBrowserEnabled\":false,\"remoteBrowserHost\":\"http://localhost:9222\",\"chromeExecutablePath\":\"\",\"disableToolUse\":false,\"customArgs\":\"\"},\"focusChainSettings\":{\"enabled\":true,\"remindClineInterval\":6},\"dictationSettings\":{\"featureEnabled\":true,\"dictationEnabled\":false,\"dictationLanguage\":\"en\"},\"preferredLanguage\":\"English\",\"openaiReasoningEffort\":\"medium\",\"mode\":\"act\",\"strictPlanModeEnabled\":true,\"yoloModeToggled\":false,\"useAutoCondense\":false,\"mcpMarketplaceEnabled\":true,\"mcpDisplayMode\":\"plain\",\"telemetrySetting\":\"unset\",\"planActSeparateModelsSetting\":false,\"enableCheckpointsSetting\":true,\"distinctId\":\"fake-machine-id-Joses-MacBook-Pro.local\",\"globalClineRulesToggles\":{},\"localClineRulesToggles\":{},\"localWindsurfRulesToggles\":{},\"localCursorRulesToggles\":{},\"localWorkflowToggles\":{},\"globalWorkflowToggles\":{},\"shellIntegrationTimeout\":4000,\"terminalReuseEnabled\":true,\"defaultTerminalProfile\":\"default\",\"isNewUser\":true,\"welcomeViewCompleted\":true,\"mcpResponsesCollapsed\":false,\"terminalOutputLineLimit\":500,\"taskHistory\":[],\"platform\":\"darwin\",\"shouldShowAnnouncement\":true,\"favoritedModelIds\":[],\"workspaceRoots\":[],\"primaryRootIndex\":0,\"isMultiRootWorkspace\":false}"
|
||||
}
|
||||
},
|
||||
"duration": 0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user