mirror of
https://github.com/cline/cline.git
synced 2026-09-03 12:14:00 +08:00
Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b092284f63 | |||
| 19c4439877 | |||
| ec01e1f19d | |||
| 4a0a40ead1 | |||
| 124569c391 | |||
| 5a08f977c3 | |||
| 4ec85af1fb | |||
| 00f88050f7 | |||
| c4f73e7752 | |||
| e84b8669a6 | |||
| 5b6eae2905 | |||
| c153077bde | |||
| 1796558733 | |||
| 2f7f8bff20 | |||
| dc9fb4ddb8 | |||
| f9c46f898f | |||
| 2d2eb5dd36 | |||
| 9562671bcc | |||
| 6a2aef27ea | |||
| c7d6173e20 | |||
| 62da7fe720 | |||
| f97f3e47cc | |||
| f47be1a7bb | |||
| 0cd2e69551 | |||
| 4b19010d56 | |||
| 49803bf958 | |||
| 33704dcdc1 | |||
| 78552287ae | |||
| a29689dfd3 | |||
| 2ca4738b59 | |||
| b4a2d5a06d | |||
| 7f2de3956c | |||
| 059b14e494 | |||
| 4d95817eec | |||
| d8c13db683 | |||
| a35e1ff8df | |||
| a0c5276fb1 | |||
| 696a2c4e48 | |||
| e983358dde | |||
| 9ae90c99ad | |||
| 4cd914970e | |||
| 4919424faf | |||
| e65fa89566 | |||
| 935a68fedc | |||
| 5c78f64978 | |||
| 328e49bd3c | |||
| 0f7c0205b6 | |||
| af0cd9bdff | |||
| 647a964853 | |||
| 58305dd6b3 | |||
| 677fc3a446 | |||
| 88c33ee96d | |||
| 23a0d41646 | |||
| 974f698e54 | |||
| 12f16a47e3 | |||
| bc7d6f0c66 | |||
| c8b446ab6c | |||
| 2cc64973fb | |||
| 599c3f6717 | |||
| 1c491bf719 | |||
| 10ac28b9d6 | |||
| 95b2e327a8 | |||
| 53d11c88bb | |||
| 7ef0f6d1b6 | |||
| 9012d6a1fc | |||
| e3d363c56f | |||
| 62a04ad38a | |||
| 1774cf8b79 | |||
| 6133eb4864 | |||
| 087f1d3be3 | |||
| 8cc7759584 | |||
| f9d70ceb6c | |||
| 3c516023c8 | |||
| 4c0c0c947b | |||
| 2ac6af5b08 | |||
| 7e68dd8d98 | |||
| e8bf106965 | |||
| b528640bd5 | |||
| 470778aa2f | |||
| 14f726c5e8 | |||
| 89e1086981 | |||
| c762583d38 | |||
| 73a340c75f | |||
| d3bb6ff456 | |||
| 93cd64f031 | |||
| 36836ad4d6 | |||
| 4ade5daeff | |||
| 24fe5c9c98 | |||
| 18d4a268e2 | |||
| 686f51c248 | |||
| 9aa04a376f | |||
| ca17a06618 | |||
| 48ea04f2ea | |||
| e11ad166e6 | |||
| 6b9c2a1a45 | |||
| bf0bbb4a16 | |||
| a8a24fcb62 | |||
| e570662d89 | |||
| 78f5faf6d2 | |||
| cc28851ba8 | |||
| 0afa4c4fd8 | |||
| 0f0512363f | |||
| ddc91a46c0 | |||
| 11a32d9a3a | |||
| 45ddaf99e1 | |||
| 6f21cb98cc | |||
| 544d4503ac | |||
| a5663e7cf8 | |||
| 09b0873e94 | |||
| e54623bc7e | |||
| 8909ee77fc | |||
| 3a3943623b | |||
| 2446b16828 | |||
| 30f1d72eb5 | |||
| c67edf4b91 | |||
| 07cf5cddad | |||
| eda2a60a69 | |||
| c1b9fcd05c | |||
| d470ba9150 | |||
| ea302ff4f1 | |||
| f11bddc4be | |||
| aceb7e8ddd | |||
| 8ca0568005 | |||
| 42b17cfbaa | |||
| 359dac9f20 | |||
| 3948b952cc | |||
| 867f36dabe | |||
| 822647c3e9 | |||
| fa0c4e5c36 | |||
| 59dc92c65c | |||
| 6972b2a975 | |||
| 973d18f4f3 | |||
| 5ab50a41b1 | |||
| d92292e405 | |||
| d0728c58af | |||
| 30f6a3ca29 | |||
| 6aad12dc01 | |||
| 9cd0a5ed6a | |||
| 311a4d6ee8 | |||
| 4e2f3d9b0e | |||
| bdccc40a0f | |||
| 5d10a0f89a | |||
| a41bb3a70a | |||
| 42e0c74c93 | |||
| ef76c8ba5a | |||
| 80542434c5 | |||
| 69bda35df8 | |||
| e3b87ae56f | |||
| daf88ba19e | |||
| 4759586a17 | |||
| 39359307dc | |||
| 9fa8a7a7a8 | |||
| 7eaf1a7cbc | |||
| 66911b89de | |||
| 0de047116f | |||
| 60cd10eec6 | |||
| 209d0830c6 | |||
| 8f938b34ab | |||
| ca4fdcf3c4 | |||
| a2572ea233 | |||
| b0b9a662e0 | |||
| 6033d2286c | |||
| 6c8accd585 | |||
| 14ed1c088c | |||
| 55e0586d95 | |||
| 5e5a6a6c21 | |||
| 7f5d03c258 | |||
| d584a8b0fb | |||
| 112eeddcd8 | |||
| 43f9541385 | |||
| ead395022d | |||
| ab47580073 | |||
| ae77bcec4a | |||
| 33f94728de | |||
| 49f0c92456 | |||
| 9c5a1fa917 | |||
| ffae0b9985 | |||
| b4b307e449 | |||
| 8900df7285 | |||
| faa5e4dd61 | |||
| 3a44bcf784 | |||
| ccba2ed8f8 | |||
| 0fc2f474a4 | |||
| 0ce66efc19 | |||
| e231cfd3e5 | |||
| 10f8637fb8 | |||
| bf985064b9 | |||
| 922183793f | |||
| 2c02547088 | |||
| df65f5f5dd | |||
| 542ce36a80 | |||
| 6baac79138 | |||
| 6a2172e156 | |||
| 3ca529dc62 | |||
| 7728c0304c | |||
| f837ecefeb | |||
| 2d5dcf6a14 | |||
| fcf6734949 | |||
| d44fd44a81 | |||
| d103f8ee62 | |||
| 3d34de4908 | |||
| dcd275480f | |||
| aea06ce659 | |||
| 65f582bb0d | |||
| f608b914b8 | |||
| 9f5df9836c | |||
| 0cdbdb057e | |||
| 4846476f6c | |||
| a6f189c5c5 | |||
| ad8e0b19be | |||
| 22b2e50210 | |||
| af0d2f31a7 | |||
| 8313fa099c | |||
| 9f418b10f6 | |||
| 1b56c3fc11 |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
Add Bedrock prompt caching support (optional).
|
||||
|
||||
This feature protected under checkbox because it is not yet rolled out to everyone, and if you will try to send cache headers, and its not enabled for you, you will get error.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
feat(bedrock): adding Amazon Nova
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve file handling for NextJS folder naming conventions and increase file listing limits. Fix glob pattern interpretation issues with parentheses in folder names
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Handle input too large Anthropic
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix "See more" not showing up for tasks after task un-fold
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix gpt-4.5-preview's supportsPromptCache value to true
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added a script to create test tasks in dev mode
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
updated move context management out of cline
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Added support for SambaNova QwQ-32B model
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add OpenAI "dynamic" model chatgpt-4o-latest
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
DangerButton.tsx to Tailwind
|
||||
+1
-1
@@ -252,7 +252,7 @@ class Cline {
|
||||
} catch (error) {
|
||||
// 4. Error handling with retry
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
await delay(1000)
|
||||
await setTimeoutPromise(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
yield* this.attemptApiRequest(previousApiReqIndex)
|
||||
return
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash
|
||||
* @saoudrizwan @ocasta181 @NightTrek @pashpashpash @dcbartlett @saito-sv
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
name: Changeset Release
|
||||
run-name: Changeset Release ${{ github.actor != 'cline-bot' && '- Create PR' || '- Update Changelog' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [closed, opened, labeled]
|
||||
|
||||
env:
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
|
||||
|
||||
jobs:
|
||||
# Job 1: Create version bump PR when changesets are merged to main
|
||||
changeset-pr-version-bump:
|
||||
if: >
|
||||
( github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'cline-bot' ) ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.GIT_REF }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm run install:all
|
||||
|
||||
# Check if there are any new changesets to process
|
||||
- name: Check for changesets
|
||||
id: check-changesets
|
||||
run: |
|
||||
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
|
||||
echo "Changesets diff with previous version: $NEW_CHANGESETS"
|
||||
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
|
||||
|
||||
# Create version bump PR using changesets/action if there are new changesets
|
||||
- name: Changeset Pull Request
|
||||
if: steps.check-changesets.outputs.new_changesets != '0'
|
||||
id: changesets
|
||||
uses: changesets/action@e9cc34b540dd3ad1b030c57fd97269e8f6ad905a # v1
|
||||
with:
|
||||
commit: "changeset version bump"
|
||||
title: "Changeset version bump"
|
||||
version: npm run version-packages # This performs the changeset version bump
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Job 2: Process version bump PR created by cline-bot
|
||||
changeset-pr-edit-approve:
|
||||
name: Auto approve and merge Bump version PRs
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor == 'cline-bot' &&
|
||||
contains(github.event.pull_request.title, 'Changeset version bump')
|
||||
steps:
|
||||
- name: Determine checkout ref
|
||||
id: checkout-ref
|
||||
run: |
|
||||
echo "Event action: ${{ github.event.action }}"
|
||||
echo "Actor: ${{ github.actor }}"
|
||||
echo "Head ref: ${{ github.head_ref }}"
|
||||
echo "PR SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
if [[ "${{ github.event.action }}" == "opened" && "${{ github.actor }}" == "cline-bot" ]]; then
|
||||
echo "Using branch ref: ${{ github.head_ref }}"
|
||||
echo "git_ref=${{ github.head_ref }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Using SHA ref: ${{ github.event.pull_request.head.sha }}"
|
||||
echo "git_ref=${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
ref: ${{ steps.checkout-ref.outputs.git_ref }}
|
||||
|
||||
# Get current and previous versions to edit changelog entry
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(git show HEAD:package.json | jq -r '.version')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
|
||||
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION"
|
||||
echo "prev_version=$PREV_VERSION"
|
||||
|
||||
# Update CHANGELOG.md with proper format
|
||||
- name: Update Changelog Format
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
env:
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
|
||||
run: python .github/scripts/overwrite_changeset_changelog.py
|
||||
|
||||
# Commit and push changelog updates
|
||||
- name: Push Changelog updates
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
run: |
|
||||
git config user.name "cline-bot"
|
||||
git config user.email github-actions@github.com
|
||||
echo "Running git add and commit..."
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Updating CHANGELOG.md format"
|
||||
git status
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
echo "Pushing to remote..."
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
git push
|
||||
|
||||
# Add label to indicate changelog has been formatted
|
||||
- name: Add changelog-ready label
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'changelog-ready') }}
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['changelog-ready']
|
||||
});
|
||||
|
||||
# Auto-approve PR only after it has been labeled
|
||||
- name: Auto approve PR
|
||||
if: contains(github.event.pull_request.labels.*.name, 'changelog-ready')
|
||||
uses: hmarr/auto-approve-action@de8bf34d0402c38aa2c8346973342b2cb02c4435 # v4
|
||||
with:
|
||||
review-message: "I'm approving since it's a bump version PR"
|
||||
|
||||
# Auto-merge PR
|
||||
- name: Automerge on PR
|
||||
if: false # Needs enablePullRequestAutoMerge in repo settings to work contains(github.event.pull_request.labels.*.name, 'changelog-ready')
|
||||
run: gh pr merge --auto --merge ${{ github.event.pull_request.number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,5 +1,67 @@
|
||||
# Changelog
|
||||
|
||||
## [3.8.2]
|
||||
|
||||
- Fix bug where switching to plan/act would result in VS Code LM/OpenRouter model being reset
|
||||
|
||||
## [3.8.0]
|
||||
|
||||
- Add 'Add to Cline' as an option when you right-click in a file or the terminal, making it easier to add context to your current task
|
||||
- Add 'Fix with Cline' code action - when you see a lightbulb icon in your editor, you can now select 'Fix with Cline' to send the code and associated errors for Cline to fix. (Cursor users can also use the 'Quick Fix (CMD + .)' menu to see this option)
|
||||
- Add Account view to display billing and usage history for Cline account users. You can now keep track of credits used and transaction history right in the extension!
|
||||
- Add 'Sort underling provider routing' setting to Cline/OpenRouter allowing you to sort provider used by throughput, price, latency, or the default (combination of price and uptime)
|
||||
- Improve rich MCP display with dynamic image loading and support for GIFs
|
||||
- Add 'Documentation' menu item to easily access Cline's docs
|
||||
- Add OpenRouter's new usage_details feature for more reliable cost reporting
|
||||
- Display total space Cline takes on disk next to 'Delete all Tasks' button in History view
|
||||
- Fix 'Context Window Exceeded' error for OpenRouter/Cline Accounts (additional support coming soon)
|
||||
- Fix bug where OpenRouter model ID would be set to invalid value
|
||||
- Add button to delete MCP servers in a failure state
|
||||
|
||||
## [3.7.1]
|
||||
|
||||
- Fix issue with 'See more' button in task header not showing when starting new tasks
|
||||
- Fix issue with checkpoints using local git commit hooks
|
||||
|
||||
## [3.7.0]
|
||||
|
||||
- Cline now displays selectable options when asking questions or presenting a plan, saving you from having to type out responses!
|
||||
- Add support for a `.clinerules/` directory to load multiple files at once (thanks @ryo-ma!)
|
||||
- Prevent Cline from reading extremely large files into context that would overload context window
|
||||
- Improve checkpoints loading performance and display warning for large projects not suited for checkpoints
|
||||
- Add SambaNova API provider (thanks @saad-noodleseed!)
|
||||
- Add VPC endpoint option for AWS Bedrock profiles (thanks @minorunara!)
|
||||
- Add DeepSeek-R1 to AWS Bedrock (thanks @watany-dev!)
|
||||
|
||||
## [3.6.5]
|
||||
|
||||
- Add 'Delete all Task History' button to History view
|
||||
- Add toggle to disable model switching between Plan/Act modes in Settings (new users default to disabled)
|
||||
- Add temperature option to OpenAI Compatible
|
||||
- Add Kotlin support to tree-sitter parser (thanks @fumiya-kume!)
|
||||
|
||||
## [3.6.3]
|
||||
|
||||
- Improve QwQ support for Alibaba (thanks @meglinge!) and OpenRouter
|
||||
- Improve diff edit prompting to prevent immediately reverting to write_to_file when a model uses search patterns that don't match anything in the file
|
||||
- Fix bug where new checkpoints system would revert file changes when switching between tasks
|
||||
- Fix issue with incorrect token count for some OpenAI compatible providers
|
||||
|
||||
## [3.6.0]
|
||||
|
||||
- Add Cline API as a provider option, allowing new users to sign up and get started with Cline for free
|
||||
- Optimize checkpoints with branch-per-task strategy, reducing storage required and first task load times
|
||||
- Fix problem with Plan/Act toggle keyboard shortcut not working in Windows (thanks @yt3trees!)
|
||||
- Add new Gemini models to GCP Vertex (thanks @shohei-ihaya!) and Claude models AskSage (thanks @swhite24!)
|
||||
- Improve OpenRouter/Cline error reporting
|
||||
|
||||
## [3.5.1]
|
||||
|
||||
- Add timeout option to MCP servers
|
||||
- Add Gemini Flash models to Vertex provider (thanks @jpaodev!)
|
||||
- Add prompt caching support for AWS Bedrock provider (thanks @buger!)
|
||||
- Add AskSage provider (thanks @swhite24!)
|
||||
|
||||
## [3.5.0]
|
||||
|
||||
- Add 'Enable extended thinking' option for Claude 3.7 Sonnet, with ability to set different budgets for Plan and Act modes
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center"><sub>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline – \#1 on OpenRouter
|
||||
|
||||
+1
-116
@@ -1,116 +1 @@
|
||||
# Cline Privacy Policy
|
||||
|
||||
Cline Bot Inc. ("Cline," "we," "our," and/or "us") values the privacy of individuals who use our VS Code extension and related services (collectively, our "Services"). This privacy policy explains how we collect, use, and disclose information from users of our Services.
|
||||
|
||||
## Key Points
|
||||
|
||||
- Cline operates entirely client-side as a VS Code extension
|
||||
- No code or data is collected, stored, or transmitted to Cline's servers
|
||||
- Your data is only sent to your chosen AI provider (e.g., Anthropic, OpenAI) when you explicitly request assistance
|
||||
- All processing happens locally on your machine
|
||||
- API keys are stored securely in VS Code's built-in settings storage
|
||||
|
||||
## Information We Process
|
||||
|
||||
### A. Information You Provide
|
||||
|
||||
- **API Keys**: When you choose to use certain AI model providers (OpenRouter, Anthropic, OpenAI, etc.), you provide API keys. These are stored securely and locally in your VS Code settings.
|
||||
- **Communications**: If you contact us directly (e.g., via Discord or email), we may receive information like your name, email address, and message contents.
|
||||
|
||||
### B. Information Processing
|
||||
|
||||
Cline functions solely as a client-side VS Code extension that facilitates communication between your editor and your chosen AI model provider:
|
||||
|
||||
1. **File Contents**:
|
||||
|
||||
- Only sent to your chosen AI provider when you explicitly request assistance
|
||||
- Never stored or transmitted to Cline's servers
|
||||
- Only the specific files/content you select are included
|
||||
|
||||
2. **Terminal Commands**:
|
||||
|
||||
- Processed entirely locally on your machine
|
||||
- Require explicit user confirmation before execution
|
||||
- No command history is transmitted to Cline
|
||||
|
||||
3. **Browser Integration**:
|
||||
- Screenshots and console logs are processed locally
|
||||
- Temporary data is cleared after task completion
|
||||
|
||||
## Data Security
|
||||
|
||||
1. **Local-Only Processing**:
|
||||
|
||||
- All operations happen on your local machine
|
||||
- No central servers or data collection by default
|
||||
- Anonymous telemetry and usage statistics are only collected if you explicitly opt in
|
||||
- No account creation required
|
||||
|
||||
2. **API Key Security**:
|
||||
|
||||
- Stored using VS Code's secure settings storage system
|
||||
- Never transmitted to Cline's servers
|
||||
- You can remove/modify keys at any time
|
||||
|
||||
3. **User Control**:
|
||||
- Explicit approval required for file changes
|
||||
- Terminal commands require confirmation
|
||||
- Browser actions need explicit permission
|
||||
- You control which AI provider to use
|
||||
|
||||
## Communication with AI Providers
|
||||
|
||||
When you request assistance:
|
||||
|
||||
1. Selected content is sent directly to your chosen AI provider
|
||||
2. No data passes through Cline's servers
|
||||
3. Provider's own privacy policy applies to this communication:
|
||||
- [Anthropic Privacy Policy](https://www.anthropic.com/privacy)
|
||||
- [OpenAI Privacy Policy](https://openai.com/privacy)
|
||||
- [OpenRouter Privacy Policy](https://openrouter.ai/privacy)
|
||||
|
||||
## Error Handling & Debugging
|
||||
|
||||
- Error logs are processed locally
|
||||
- No automatic error reporting to Cline
|
||||
- Optional anonymous telemetry and error reporting via PostHog if you opt in
|
||||
- You control what information to include when manually reporting issues
|
||||
|
||||
## Children's Privacy
|
||||
|
||||
We do not knowingly collect, maintain, or use personal information from children under 18 years of age, and no part of our Service(s) is directed to children. If you learn that a child has provided us with personal information in violation of this Privacy Policy, then you may alert us at support@cline.bot.
|
||||
|
||||
## Changes to Privacy Policy
|
||||
|
||||
We will post any changes to this policy on our GitHub repository. Significant changes will be announced in our Discord community.
|
||||
|
||||
## Security Concerns & Auditing
|
||||
|
||||
- Cline is open source and available for security audit
|
||||
- Our client-side architecture ensures no central point of data collection
|
||||
- You can inspect exactly what data is being sent to AI providers
|
||||
- Enterprise users can implement additional access controls through VS Code
|
||||
|
||||
## Telemetry & Usage Statistics
|
||||
|
||||
If you choose to opt in to anonymous telemetry:
|
||||
|
||||
- Basic usage statistics and error reports are collected via PostHog
|
||||
- A stable, anonymous identifier (VS Code's `machineId`) is used to understand unique usage patterns
|
||||
- This identifier is not linked to any personal information
|
||||
- It helps us understand how features are used across sessions
|
||||
- It cannot be used to identify you personally
|
||||
- All data is anonymized and cannot be linked to individual users
|
||||
- No code content or sensitive information is ever included
|
||||
- You can opt out at any time through:
|
||||
- VS Code Settings > Cline > Enable Telemetry
|
||||
- VS Code Settings > Telemetry > Telemetry Level (setting this to anything other than "all" will disable Cline's telemetry)
|
||||
- Collected data helps us improve the extension's functionality and stability
|
||||
|
||||
## Contact Us
|
||||
|
||||
For privacy-related questions or concerns:
|
||||
|
||||
- Open an issue on our [GitHub repository](https://github.com/cline/cline)
|
||||
- Join our [Discord community](https://discord.gg/cline)
|
||||
- Email: support@cline.bot
|
||||
See [https://cline.bot/privacy](https://cline.bot/privacy) for our privacy policy.
|
||||
@@ -128,6 +128,27 @@ Cline's system prompt, on the other hand, is not user-editable ([here's where yo
|
||||
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
|
||||
- Test and Iterate: Experiment to find what works best for your workflow.
|
||||
|
||||
|
||||
### Support for Loading Files from the `.clinerules/` Directory
|
||||
All files under the `.clinerules/` directory are recursively loaded, and their contents are merged into clineRulesFileInstructions.
|
||||
|
||||
#### Example 1:
|
||||
```
|
||||
.clinerules/
|
||||
├── .local-clinerules
|
||||
└── .project-clinerules
|
||||
```
|
||||
|
||||
#### Example 2:
|
||||
```
|
||||
.clinerules/
|
||||
├── .clinerules-nextjs
|
||||
├── .clinerules-serverside
|
||||
└── tests/
|
||||
├── .pytest-clinerules
|
||||
└── .jest-clinerules
|
||||
```
|
||||
|
||||
## Prompting Cline 💬
|
||||
|
||||
**Prompting is how you communicate your needs for a given task in the back-and-forth chat with Cline.** Cline understands natural language, so write conversationally.
|
||||
|
||||
@@ -52,6 +52,7 @@ const copyWasmFiles = {
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
"kotlin",
|
||||
]
|
||||
|
||||
languages.forEach((lang) => {
|
||||
|
||||
@@ -158,5 +158,5 @@ Um zum Projekt beizutragen, beginnen Sie mit unserem [Beitragsleitfaden](CONTRIB
|
||||
|
||||
## Lizenz
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
|
||||
@@ -158,4 +158,4 @@ Para contribuir al proyecto, comience con nuestra [guía de contribución](CONTR
|
||||
|
||||
## Licencia
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -158,4 +158,4 @@ Clineがタスクを進める中で、拡張機能は各ステップでワーク
|
||||
|
||||
## ライセンス
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# 기여자 행동 강령
|
||||
|
||||
## 서약
|
||||
|
||||
우리는 개방적이고 환영하는 환경을 조성하기 위해 노력하며, 기여자 및 유지 관리자로서 모든 사람이 차별과 괴롭힘 없이 프로젝트와 커뮤니티에 참여할 수 있도록 최선을 다할 것을 서약합니다. 이는 연령, 체형, 장애, 민족성, 성적 특성, 성 정체성 및 표현, 경험 수준, 교육 수준, 사회·경제적 지위, 국적, 외모, 인종, 종교, 성 정체성과 성적 지향에 관계없이 모든 사람에게 적용됩니다.
|
||||
|
||||
## 행동 기준
|
||||
|
||||
긍정적인 환경을 조성하기 위한 바람직한 행동의 예시:
|
||||
|
||||
- 환영하고 포용적인 언어 사용하기
|
||||
- 서로 다른 관점과 경험을 존중하기
|
||||
- 건설적인 비판을 우아하게 수용하기
|
||||
- 커뮤니티에 최선이 되는 것에 집중하기
|
||||
- 다른 커뮤니티 구성원들에 대한 공감 보여주기
|
||||
|
||||
참여자가 해서는 안 되는 행동의 예시:
|
||||
|
||||
- 성적인 언어와 이미지 사용, 원치 않는 성적 관심이나 접근
|
||||
- 트롤링, 모욕적/경멸적인 댓글, 개인적 또는 정치적 공격
|
||||
- 공개적 또는 사적인 괴롭힘
|
||||
- 상대방의 동의 없이 개인정보(실제 주소나 전자 주소 등) 공개하기
|
||||
- 전문적 환경에서 부적절하다고 여겨질 수 있는 기타 행위
|
||||
|
||||
## 책임
|
||||
|
||||
프로젝트 유지 관리자는 허용 가능한 행동 기준을 명확히 설명할 책임이 있으며, 부적절한 행동이 발생할 경우 적절하고 공정한 시정 조치를 취해야 합니다.
|
||||
|
||||
프로젝트 유지 관리자는 본 행동 강령에 부합하지 않는 댓글, 커밋, 코드, 위키 수정, 이슈 및 기타 기여를 삭제, 수정 또는 거부할 권리와 책임이 있으며, 부적절하다고 판단되는 행동(위협적이거나, 공격적이거나, 해로운 행위 등)을 한 기여자를 일시적 또는 영구적으로 차단할 권리를 가집니다.
|
||||
|
||||
## 범위
|
||||
|
||||
이 행동 강령은 프로젝트 공간과 개인이 프로젝트나 커뮤니티를 대표하는 공개 공간에서 모두 적용됩니다. 프로젝트 또는 커뮤니티를 대표하는 예로는 공식 프로젝트 이메일 주소 사용, 공식 소셜 미디어 계정을 통한 게시, 온라인 또는 오프라인 행사에서 지정된 대표자로 활동하는 경우 등이 포함됩니다. 프로젝트의 대표성은 프로젝트 유지 관리자가 추가로 정의하고 명확히 할 수 있습니다.
|
||||
|
||||
## 집행
|
||||
|
||||
학대, 괴롭힘 또는 기타 용납할 수 없는 행동은 프로젝트 팀에 hi@cline.bot을 통해 신고 할 수 있습니다. 모든 신고는 검토 및 조사되며, 상황에 따라 필요하고 적절한 조치가 취해질 것입니다. 프로젝트 팀은 사건 신고자의 신원을 보호할 의무가 있습니다. 특정 시행 정책에 대한 추가 세부 사항은 별도로 게시될 수 있습니다.
|
||||
|
||||
행동 강령을 성실히 준수하거나 집행하지 않는 프로젝트 유지관리자는 프로젝트 리더십의 구성원에 의해 일시적 또는 영구적인 제재를 받을 수 있습니다.
|
||||
|
||||
## 출처
|
||||
|
||||
이 행동 강령은 [Contributor Covenant][homepage] 버전 1.4에서 수정되었으며, https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 에서 확인할 수 있습니다.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
이 행동 강령에 대한 일반적인 질문에 대한 답변은 https://www.contributor-covenant.org/faq 를 참조하시기 바랍니다.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Cline에 기여하기
|
||||
|
||||
Cline에 기여하는 것에 관심을 가져주셔서 감사합니다! 버그 수정, 기능 추가, 문서 개선 등 모든 기여는 Cline을 더욱 스마트하게 만드는 데 기여합니다. 활기차고 환영하는 커뮤니티를 유지하기 위해 모든 구성원은 [행동 강령](CODE_OF_CONDUCT.md)을 준수해야 합니다.
|
||||
|
||||
## 버그와 문제 보고
|
||||
|
||||
버그 보고는 Cline을 모두에게 더 나은 것으로 만드는 데 도움이 됩니다! 새로운 이슈를 생성하기 전에, 중복을 피하기 위해 [기존 이슈를 검색](https://github.com/cline/cline/issues)해 주세요. 버그를 보고할 준비가 되었다면, [이슈 페이지](https://github.com/cline/cline/issues/new/choose)로 이동하여 관련 정보를 작성하기 위한 템플릿을 사용해 주세요.
|
||||
|
||||
<blockquote class='warning-note'>
|
||||
🔐 <b>중요:</b> 보안 취약점을 발견한 경우, <a href="https://github.com/cline/cline/security/advisories/new">GitHub 보안 도구를 사용하여 비공개로 보고</a>해 주세요.
|
||||
</blockquote>
|
||||
|
||||
## 작업 내용 결정하기
|
||||
|
||||
첫 기여를 찾고 계신가요? ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue)나 ["help wanted"](https://github.com/cline/cline/labels/help%20wanted) 라벨이 붙은 이슈를 확인해 보세요. 이러한 이슈들은 새로운 기여자를 위해 특별히 선정된 작업으로, 도움이 필요한 영역이 표시되어 있습니다!
|
||||
|
||||
또한, [문서](https://github.com/cline/cline/tree/main/docs)에 대한 기여도 환영합니다! 오타 수정, 기존 가이드 개선, 새로운 교육 콘텐츠 작성 등, 커뮤니티 주도의 리소스 저장소를 구축하는 데 여러분의 도움이 필요합니다. `/docs`를 살펴보고 개선이 필요한 부분을 찾아보세요.
|
||||
|
||||
큰 기능에 대해 작업할 계획이 있다면, 먼저 [기능 요청](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop)을 생성하여 이것이 Cline의 비전과 부합하는지 논의하는 것이 좋습니다.
|
||||
|
||||
## 개발 환경 설정
|
||||
|
||||
1. **VS Code 확장 프로그램**
|
||||
|
||||
- 프로젝트를 열면 VS Code가 권장 확장 프로그램 설치를 안내합니다
|
||||
- 개발을 위해 이 확장 프로그램들이 필요하므로, 설치 안내를 수락해 주세요.
|
||||
- 프롬프트를 닫은 경우 확장 프로그램 패널에서 수동으로 설치할 수 있습니다
|
||||
|
||||
2. **로컬 개발**
|
||||
- `npm run install:all`을 실행하여 의존성을 설치합니다
|
||||
- `npm run test`를 실행하여 로컬에서 테스트를 실행합니다
|
||||
- PR을 제출하기 전에 `npm run format:fix`를 실행하여 코드를 포맷팅합니다
|
||||
|
||||
## 코드 작성과 제출
|
||||
|
||||
누구나 Cline에 코드를 기여할 수 있지만, 기여가 원활하게 통합되도록 다음 가이드라인을 따라주세요:
|
||||
|
||||
1. **Pull Request 집중하기**
|
||||
|
||||
- PR은 단일 기능 또는 버그 수정으로 제한해 주세요
|
||||
- 큰 변경사항은 작은 관련 PR로 분할해 주세요
|
||||
- 논리적으로 독립적인 커밋 단위로 나누어 리뷰가 용이하도록 구성하세요.
|
||||
|
||||
2. **코드 품질**
|
||||
|
||||
- `npm run lint`를 실행하여 코드 스타일을 체크합니다
|
||||
- `npm run format`을 실행하여 코드를 자동으로 포맷팅합니다
|
||||
- 모든 PR은 린팅과 포맷팅을 포함한 CI 체크를 통과해야 합니다
|
||||
- 제출 전에 ESLint 경고나 에러를 모두 해결해 주세요
|
||||
- TypeScript 모범 사례를 따르고, 타입 안전성을 유지해 주세요
|
||||
|
||||
3. **테스트**
|
||||
|
||||
- 새로운 기능에는 테스트를 추가해 주세요
|
||||
- `npm test`를 실행하여 모든 테스트가 통과하는지 확인해 주세요
|
||||
- 변경사항이 기존 테스트에 영향을 미치는 경우 해당 테스트를 업데이트해 주세요
|
||||
- 적절한 경우 단위 테스트와 통합 테스트를 모두 포함해 주세요
|
||||
|
||||
4. **Changesets를 활용한 버전 관리**
|
||||
|
||||
- 사용자에게 영향을 미치는 변경 사항이 있는 경우, `npm run changeset`을 실행하여 changeset을 생성해 주세요
|
||||
- 적절한 버전 증가 옵션을 선택하세요:
|
||||
- `major` 호환되지 않는 변경 (1.0.0 → 2.0.0)
|
||||
- `minor` 새로운 기능 추가 (1.0.0 → 1.1.0)
|
||||
- `patch` 버그 수정 (1.0.0 → 1.0.1)
|
||||
- 영향을 설명하는 명확한 변경사항 메시지를 작성해 주세요
|
||||
- 문서 변경만 있는 경우 changeset이 필요하지 않습니다
|
||||
|
||||
5. **커밋 가이드라인**
|
||||
|
||||
- 명확하고 설명적인 커밋 메시지를 작성해 주세요
|
||||
- 컨벤셔널 커밋 형식(예: "feat:", "fix:", "docs:")을 사용해 주세요
|
||||
- 커밋에서 관련 이슈를 #issue-number를 사용하여 참조해 주세요
|
||||
|
||||
6. **제출 전 확인사항**
|
||||
|
||||
- 최신 main에 브랜치를 리베이스해 주세요
|
||||
- 브랜치가 정상적으로 빌드되는지 확인해 주세요
|
||||
- 모든 테스트가 통과하는지 다시 확인해 주세요
|
||||
- 디버그 코드나 콘솔 로그가 없는지 변경사항을 확인해 주세요
|
||||
|
||||
7. **Pull Request 설명**
|
||||
- 변경 내용을 명확하게 설명해 주세요
|
||||
- 변경사항을 테스트하는 방법을 포함해 주세요
|
||||
- 호환되지 않는 변경 사항이 있다면 목록으로 작성해주세요
|
||||
- UI 변경이 있는 경우, 스크린샷을 추가해 주세요
|
||||
|
||||
## 기여 동의서
|
||||
|
||||
Pull Request를 제출함으로써, 귀하의 기여가 프로젝트와 동일한 라이선스([Apache 2.0](/LICENSE)) 에 따라 제공됨에 동의하는 것입니다.
|
||||
|
||||
기억하세요: Cline에 기여하는 것은 코드를 작성하는 것뿐만 아니라, AI 지원 개발의 미래를 형성하는 커뮤니티의 일원이 되는 것입니다. 함께 멋진 것을 만들어봅시다! 🚀
|
||||
@@ -0,0 +1,172 @@
|
||||
# Cline - 최고의 OpenRouter
|
||||
|
||||
<p align="center">
|
||||
<img src="https://media.githubusercontent.com/media/cline/cline/main/assets/docs/demo.gif" width="100%" />
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tbody>
|
||||
<td align="center">
|
||||
<a href="https://marketplace.visualstudio.com/items?itemName=saoudrizwan.claude-dev" target="_blank"><strong>VS Marketplace에서 다운로드</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://discord.gg/cline" target="_blank"><strong>Discord</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://www.reddit.com/r/cline/" target="_blank"><strong>r/cline</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>기능 요청</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://cline.bot/join-us" target="_blank"><strong>채용 정보</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Cline을 만나보세요, **CLI** 및 **에디터**를 활용할 수 있는 AI 어시스턴트입니다.
|
||||
|
||||
[Claude 3.7 Sonnet의 에이전트형 코딩 기능](https://www.anthropic.com/claude/sonnet) 덕분에, Cline은 복잡한 소프트웨어 개발 작업을 단계별로 처리할 수 있습니다. 파일 생성과 편집, 대규모 프로젝트 탐색, 브라우저 사용, 터미널 명령 실행(권한 허가 필요) 등의 도구를 사용하여 단순 코드 완성이나 기술 지원을 넘어서는 도움을 제공합니다. Cline은 Model Context Protocol(MCP)를 사용하여 새로운 도구를 만들고 자신의 기능을 확장할 수도 있습니다. 자율적인 AI 스크립트는 일반적으로 샌드박스 환경에서 실행되지만, 이 확장 프로그램은 모든 파일 변경 및 터미널 명령을 승인할 수 있는 사람이 개입가능한 GUI를 제공하여, 에이전트형 AI의 잠재력을 보다 안전하고 쉽게 탐색할 수 있도록 합니다.
|
||||
|
||||
1. 작업을 입력하고, 목업을 기능하는 앱으로 변환하거나 스크린샷으로 버그를 수정합니다.
|
||||
2. Cline은 파일 구조와 소스코드 AST의 분석, 정규식 검색 실행, 관련 파일 읽기부터 시작하여 기존 프로젝트를 파악합니다. 또한, 어떤 정보를 컨텍스트에 추가할지를 신중하게 관리하여, 대규모 복잡한 프로젝트에서도 컨텍스트 윈도우를 과부하시키지 않으면서도 효과적인 지원을 제공합니다.
|
||||
3. Cline이 필요한 정보를 얻은 후 다음과 같은 작업을 할 수 있습니다:
|
||||
- 파일 생성과 편집 + 린터/컴파일러 오류 모니터링을 수행하여 누락된 임포트나 구문 오류 등의 문제를 자동으로 수정합니다.
|
||||
- 터미널에서 명령을 직접 실행하고 작업 중에 출력을 모니터링합니다. 이를 통해 파일 편집 후 개발 서버의 문제에 대응할 수 있습니다.
|
||||
- 웹 개발 작업에서는 헤드리스 브라우저로 사이트를 실행하고, 클릭, 입력, 스크롤, 스크린샷과 콘솔 로그 캡처를 수행하여 런타임 오류나 시각적 버그를 수정합니다.
|
||||
4. 작업이 완료되면 Cline은 `open -a "Google Chrome" index.html`과 같은 터미널 명령을 제공하여 버튼 클릭 한 번으로 결과를 확인할 수 있도록 합니다.
|
||||
|
||||
> [!TIP]
|
||||
> `CMD/CTRL + Shift + P` 단축키를 사용하여 명령 팔레트를 열고 "Cline: Open In New Tab"을 입력하여 에디터의 탭으로 확장 프로그램을 엽니다. 이를 통해 파일 탐색기와 병행하여 Cline을 사용하고 워크스페이스의 변경을 더 명확하게 확인할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
<img align="right" width="340" src="https://github.com/user-attachments/assets/3cf21e04-7ce9-4d22-a7b9-ba2c595e88a4">
|
||||
|
||||
### 어떤 API나 모델이든 사용 가능
|
||||
|
||||
Cline은 OpenRouter, Anthropic, OpenAI, Google Gemini, AWS Bedrock, Azure, GCP Vertex 등의 API 제공자를 지원합니다. 또한 OpenAI 호환 API를 설정하거나 LM Studio/Ollama를 통해 로컬 모델을 사용할 수도 있습니다. OpenRouter를 사용하는 경우, 확장 프로그램에서 최신 모델 목록을 가져와 바로 최신 모델을 사용할 수 있게 합니다.
|
||||
|
||||
또한, Cline은 전체 작업 루프와 개별 요청별로 토큰 사용량과 API 비용을 추적하여, 진행 중인 작업의 비용을 실시간으로 확인할 수 있도록 도와줍니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/81be79a8-1fdb-4028-9129-5fe055e01e76">
|
||||
|
||||
### 터미널에서 명령 실행
|
||||
|
||||
VSCode v1.93의 새로운 [셸 통합 업데이트](https://code.visualstudio.com/updates/v1_93#_terminal-shell-integration-api) 덕분에, Cline은 터미널에서 명령을 직접 실행하고 출력을 받을 수 있습니다. 이를 통해 패키지 설치나 빌드 스크립트 실행부터 애플리케이션 배포, 데이터베이스 관리, 테스트 실행까지 광범위한 작업을 수행할 수 있습니다. Cline은 개발 환경과 도구 체인에 맞추어 정확하게 작업을 실행합니다.
|
||||
|
||||
개발 서버와 같은 오래 실행되는 프로세스의 경우, "실행 중 계속"(Proceed While Running) 버튼을 사용하여 명령이 백그라운드에서 실행되는 동안 Cline이 작업을 계속할 수 있게 합니다. 작업이 진행되는 동안 Cline은 새로운 터미널 출력을 실시간으로 확인하여, 파일 편집 시 발생하는 컴파일 오류와 같은 문제에 즉시 대응할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="400" src="https://github.com/user-attachments/assets/c5977833-d9b8-491e-90f9-05f9cd38c588">
|
||||
|
||||
### 파일 생성과 편집
|
||||
|
||||
Cline은 에디터 내에서 파일을 생성 및 편집하고 변경의 Diff 뷰로 표시합니다. Diff 뷰 에디터에서 Cline의 변경을 직접 편집하거나 되돌릴 수 있으며, 채팅에서 피드백을 제공하여 만족할 때까지 개선 요청할 수 있습니다. Cline은 린터/컴파일러 오류(누락된 임포트, 구문 오류 등)도 모니터링하고 발생한 문제를 자동으로 수정합니다.
|
||||
|
||||
Cline에 의한 모든 변경은 파일의 타임라인에 기록되어 필요할 때 변경을 추적하고 되돌릴 수 있는 간단한 방법을 제공합니다.
|
||||
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="370" src="https://github.com/user-attachments/assets/bc2e85ba-dfeb-4fe6-9942-7cfc4703cbe5">
|
||||
|
||||
### 브라우저 사용
|
||||
|
||||
Claude 3.5 Sonnet의 새로운 [컴퓨터 사용](https://www.anthropic.com/news/3-5-models-and-computer-use) 기능으로 인해, Cline은 브라우저를 실행하고 요소를 클릭하고 텍스트를 입력하고 스크롤하며 각 단계에서 스크린샷과 콘솔 로그를 캡처할 수 있습니다. 이를 통해 인터랙티브한 디버깅, 엔드투엔드 테스트, 심지어 일반적인 웹 탐색까지 가능해집니다. 이로 인해 오류 로그를 수동으로 복사 & 붙여넣기 할 필요 없이 시각적 버그나 런타임 문제를 자율적으로 수정할 수 있습니다.
|
||||
|
||||
Cline에게 "앱을 테스트해줘"라고 요청하면, `npm run dev`와 같은 명령을 실행하고 로컬에서 실행 중인 개발 서버를 브라우저에서 실행하여 일련의 테스트를 수행하고 모든 것이 정상적으로 작동하는지 확인합니다. [데모는 여기를 참조하세요.](https://x.com/sdrzn/status/1850880547825823989)
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/ac0efa14-5c1f-4c26-a42d-9d7c56f5fadd">
|
||||
|
||||
### "도구를 추가 해주세요."
|
||||
|
||||
Cline은 [Model Context Protocol](https://github.com/modelcontextprotocol)을 활용하여 커스텀 도구를 생성하고 기능을 확장할 수 있습니다. 기존의 [커뮤니티 서버](https://github.com/modelcontextprotocol/servers)를 사용할 수도 있지만, Cline은 사용자의 워크플로우에 최적화된 도구를 직접 제작하고 설치할 수도 있습니다. "~ 도구를 추가해주세요."라고 요청만 하면, Cline은 새로운 MCP 서버 생성부터 확장 프로그램 내 설치까지 모두 자동으로 처리합니다. 이러한 커스텀 도구는 Cline의 툴키트의 일부가 되어 향후 작업에서 사용할 수 있게 됩니다.
|
||||
|
||||
- "Jira 티켓을 가져오는 도구를 추가해주세요": 티켓 AC를 가져와 Cline에게 작업을 요청
|
||||
- "AWS EC2를 관리하는 도구를 추가해주세요": 서버 메트릭을 확인하고 인스턴스를 확장 또는 축소
|
||||
- "최신 PagerDuty 인시던트를 가져오는 도구를 추가해주세요": 최신 장애 정보를 가져와 Cline에게 버그 수정 요청
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="left" width="360" src="https://github.com/user-attachments/assets/7fdf41e6-281a-4b4b-ac19-020b838b6970">
|
||||
|
||||
### 컨텍스트 추가
|
||||
|
||||
**`@url`:** URL을 붙여넣으면 확장이 해당 페이지를 가져와 Markdown으로 변환합니다. 최신 문서를 Cline에게 제공할 때 유용합니다.
|
||||
|
||||
**`@problems`:** Cline이 수정할 워크스페이스 오류와 경고(Problems' panel)를 추가합니다.
|
||||
|
||||
**`@file`:** 파일의 내용을 추가하여, 파일을 읽는 데 API 요청을 허비하지 않고도 Cline이 접근할 수 있도록 합니다. (+ 파일 검색 가능)
|
||||
|
||||
**`@folder`:** 폴더 내 모든 파일을 한 번에 추가하여 워크플로우를 더욱 빠르게 진행할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
<img align="right" width="350" src="https://github.com/user-attachments/assets/140c8606-d3bf-41b9-9a1f-4dbf0d4c90cb">
|
||||
|
||||
### 체크포인트: 비교 및 복원
|
||||
|
||||
Cline이 작업을 진행하는 동안 확장 프로그램은 각 단계에서 워크스페이스의 스냅샷을 저장합니다. “Compare” 버튼을 사용하여 스냅샷과 현재 워크스페이스의 차이를 확인하고, “Restore” 버튼을 사용하여 해당 시점으로 롤백할 수 있습니다.
|
||||
|
||||
예를 들어, 로컬 웹 서버에서 작업 중일 때 “Restore Workspace Only”을 사용하여 서로 다른 버전의 앱을 신속하게 테스트하고, “Restore Task and Workspace”을 사용하여 계속 진행할 버전을 찾을 수 있습니다. 이를 통해 진행 상황을 잃지 않고 안전하게 다양한 접근 방식을 실험할 수 있습니다.
|
||||
|
||||
<img width="2000" height="0" src="https://github.com/user-attachments/assets/ee14e6f7-20b8-4391-9091-8e8e25561929"><br>
|
||||
|
||||
## 기여
|
||||
|
||||
프로젝트에 기여하려면, [기여 가이드](CONTRIBUTING.md)에서 기본 사항을 익히세요. 또한, [Discord](https://discord.gg/cline)에 참여하여 `#contributors` 채널에서 다른 기여자들과 이야기할 수 있습니다. 풀타임 직업을 찾고 있다면, [채용 페이지](https://cline.bot/join-us)에서 열려있는 포지션을 확인하세요.
|
||||
|
||||
<details>
|
||||
<summary>로컬 개발 방법</summary>
|
||||
|
||||
1. 리포지토리를 클론합니다 _(Requires [git-lfs](https://git-lfs.com/))_:
|
||||
```bash
|
||||
git clone https://github.com/cline/cline.git
|
||||
```
|
||||
2. 프로젝트를 VSCode에서 엽니다:
|
||||
```bash
|
||||
code cline
|
||||
```
|
||||
3. 확장 프로그램과 webview-gui의 필요한 의존성을 설치합니다:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
4. `F5`를 눌러(또는 `Run`->`Start Debugging`), 확장 프로그램이 로드된 새로운 VSCode 창을 엽니다. (프로젝트 빌드에 문제가 있는 경우, [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers)을 설치해야 할 수도 있습니다.)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Pull Request 생성 방법</summary>
|
||||
|
||||
1. PR을 만들기 전, 변경 사항을 기록하는 changeset 항목을 생성:
|
||||
```bash
|
||||
npm run changeset
|
||||
```
|
||||
이후 프롬프트에서 다음 정보를 입력하세요:
|
||||
- 변경 유형 (major, minor, patch)
|
||||
- `major` → 호환되지 않는 변경 (1.0.0 → 2.0.0)
|
||||
- `minor` → 새로운 기능 추가 (1.0.0 → 1.1.0)
|
||||
- `patch` → 버그 수정 (1.0.0 → 1.0.1)
|
||||
- 변경 사항 설명 입력
|
||||
|
||||
2. 변경 사항과 생성된 `.changeset` 파일을 커밋 후 브랜치를 푸시하고 GitHub에서 PR을 생성하세요.
|
||||
|
||||
3. 브랜치를 푸시하고 GitHub에서 PR을 생성하세요. CI가 다음과 같은 작업을 수행합니다:
|
||||
- 테스트 및 코드 검증 실행
|
||||
- Changesetbot이 버전 변경 영향을 보여주는 코멘트를 생성
|
||||
- 브랜치가 메인에 머지되면, Changesetbot이 버전 패키지 PR을 생성
|
||||
- 버전 패키지 PR이 머지되면, 새로운 릴리즈가 게시됨
|
||||
|
||||
</details>
|
||||
|
||||
## 라이센스
|
||||
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](/LICENSE)
|
||||
@@ -158,4 +158,4 @@ Para contribuir com o projeto, comece com nosso [Guia de Contribuição](CONTRIB
|
||||
|
||||
## Licença
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
@@ -158,5 +158,5 @@ Cline 所做的所有更改都会记录在你的文件时间轴中,提供了
|
||||
|
||||
## 许可证
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
|
||||
@@ -158,4 +158,4 @@ Cline 所做的所有更改都會記錄在你的文件時間軸中,提供了
|
||||
|
||||
## 許可證
|
||||
|
||||
[Apache 2.0 © 2024 Cline Bot Inc.](./LICENSE)
|
||||
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
|
||||
|
||||
Generated
+3851
-234
File diff suppressed because it is too large
Load Diff
+73
-12
@@ -2,12 +2,8 @@
|
||||
"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.5.0",
|
||||
"version": "3.8.2",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#617A91",
|
||||
"theme": "dark"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
},
|
||||
@@ -73,7 +69,7 @@
|
||||
{
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"title": "MCP Servers",
|
||||
"icon": "$(extensions)"
|
||||
"icon": "$(server)"
|
||||
},
|
||||
{
|
||||
"command": "cline.historyButtonClicked",
|
||||
@@ -85,6 +81,11 @@
|
||||
"title": "Open in Editor",
|
||||
"icon": "$(link-external)"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"title": "Account",
|
||||
"icon": "$(account)"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"title": "Settings",
|
||||
@@ -94,6 +95,32 @@
|
||||
"command": "cline.openInNewTab",
|
||||
"title": "Open In New Tab",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.dev.createTestTasks",
|
||||
"title": "Create Test Tasks",
|
||||
"category": "Cline",
|
||||
"when": "cline.isDevMode"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"title": "Documentation",
|
||||
"icon": "$(book)"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"title": "Add to Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.fixWithCline",
|
||||
"title": "Fix with Cline",
|
||||
"category": "Cline"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -119,9 +146,32 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"command": "cline.openDocumentation",
|
||||
"group": "navigation@5",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@6",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@7",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
}
|
||||
],
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"group": "navigation",
|
||||
"when": "editorHasSelection"
|
||||
}
|
||||
],
|
||||
"terminal/context": [
|
||||
{
|
||||
"command": "cline.addTerminalOutputToChat",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -160,7 +210,7 @@
|
||||
"cline.enableCheckpoints": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enables extension to save checkpoints of workspace throughout the task."
|
||||
"description": "Enables extension to save checkpoints of workspace throughout the task. Uses git under the hood which may not work well with large workspaces."
|
||||
},
|
||||
"cline.disableBrowserTool": {
|
||||
"type": "boolean",
|
||||
@@ -226,7 +276,7 @@
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "npm run compile-tests && npm run compile && npm run lint",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint src --ext ts",
|
||||
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts",
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"test": "vscode-test",
|
||||
@@ -247,6 +297,7 @@
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "20.x",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/vscode": "^1.84.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
@@ -259,26 +310,34 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"should": "^13.2.3",
|
||||
"sinon": "^19.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.12.4",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.758.0",
|
||||
"@google-cloud/vertexai": "^1.9.3",
|
||||
"@google/generative-ai": "^0.18.0",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.39.1",
|
||||
"@opentelemetry/resources": "^1.30.1",
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"chokidar": "^4.0.1",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"delay": "^6.0.0",
|
||||
"diff": "^5.2.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -289,9 +348,11 @@
|
||||
"isbinaryfile": "^5.0.2",
|
||||
"mammoth": "^1.8.0",
|
||||
"monaco-vscode-textmate-theme-converter": "^0.1.7",
|
||||
"ollama": "^0.5.13",
|
||||
"open-graph-scraper": "^6.9.0",
|
||||
"openai": "^4.83.0",
|
||||
"os-name": "^6.0.0",
|
||||
"p-timeout": "^6.1.4",
|
||||
"p-wait-for": "^5.0.2",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"posthog-node": "^4.8.1",
|
||||
|
||||
+11
-1
@@ -9,19 +9,23 @@ import { OllamaHandler } from "./providers/ollama"
|
||||
import { LmStudioHandler } from "./providers/lmstudio"
|
||||
import { GeminiHandler } from "./providers/gemini"
|
||||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { ApiStream } from "./transform/stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
|
||||
import { DeepSeekHandler } from "./providers/deepseek"
|
||||
import { RequestyHandler } from "./providers/requesty"
|
||||
import { TogetherHandler } from "./providers/together"
|
||||
import { QwenHandler } from "./providers/qwen"
|
||||
import { MistralHandler } from "./providers/mistral"
|
||||
import { VsCodeLmHandler } from "./providers/vscode-lm"
|
||||
import { ClineHandler } from "./providers/cline"
|
||||
import { LiteLlmHandler } from "./providers/litellm"
|
||||
import { AskSageHandler } from "./providers/asksage"
|
||||
import { XAIHandler } from "./providers/xai"
|
||||
import { SambanovaHandler } from "./providers/sambanova"
|
||||
|
||||
export interface ApiHandler {
|
||||
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
|
||||
getModel(): { id: string; info: ModelInfo }
|
||||
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
|
||||
}
|
||||
|
||||
export interface SingleCompletionHandler {
|
||||
@@ -61,10 +65,16 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
return new MistralHandler(options)
|
||||
case "vscode-lm":
|
||||
return new VsCodeLmHandler(options)
|
||||
case "cline":
|
||||
return new ClineHandler(options)
|
||||
case "litellm":
|
||||
return new LiteLlmHandler(options)
|
||||
case "asksage":
|
||||
return new AskSageHandler(options)
|
||||
case "xai":
|
||||
return new XAIHandler(options)
|
||||
case "sambanova":
|
||||
return new SambanovaHandler(options)
|
||||
default:
|
||||
return new AnthropicHandler(options)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ export class AnthropicHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = budget_tokens !== 0 ? true : false
|
||||
const model = this.getModel()
|
||||
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
|
||||
const modelId = model.id
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
switch (modelId) {
|
||||
// 'latest' alias does not support cache_control
|
||||
case "claude-3-7-sonnet-20250219":
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ApiHandler } from ".."
|
||||
import {
|
||||
ApiHandlerOptions,
|
||||
ModelInfo,
|
||||
AskSageModelId,
|
||||
askSageModels,
|
||||
askSageDefaultModelId,
|
||||
askSageDefaultURL,
|
||||
} from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
type AskSageRequest = {
|
||||
system_prompt: string
|
||||
message: {
|
||||
user: "gpt" | "me"
|
||||
message: string
|
||||
}[]
|
||||
model: string
|
||||
dataset: "none"
|
||||
}
|
||||
|
||||
type AskSageResponse = {
|
||||
uuid: string
|
||||
status: number
|
||||
// Response status
|
||||
response: string
|
||||
// Generated response message
|
||||
message: string
|
||||
}
|
||||
|
||||
export class AskSageHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private apiUrl: string
|
||||
private apiKey: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
console.log("init api url", options.asksageApiUrl, askSageDefaultURL)
|
||||
this.options = options
|
||||
this.apiKey = options.asksageApiKey || ""
|
||||
this.apiUrl = options.asksageApiUrl || askSageDefaultURL
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error("AskSage API key is required")
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
try {
|
||||
const model = this.getModel()
|
||||
|
||||
// Transform messages into AskSageRequest format
|
||||
const formattedMessages = messages.map((msg) => {
|
||||
const content = Array.isArray(msg.content)
|
||||
? msg.content.map((block) => ("text" in block ? block.text : "")).join("")
|
||||
: msg.content
|
||||
|
||||
return {
|
||||
user: msg.role === "assistant" ? ("gpt" as const) : ("me" as const),
|
||||
message: content,
|
||||
}
|
||||
})
|
||||
|
||||
const request: AskSageRequest = {
|
||||
system_prompt: systemPrompt,
|
||||
message: formattedMessages,
|
||||
model: model.id,
|
||||
dataset: "none",
|
||||
}
|
||||
|
||||
// Make request to AskSage API
|
||||
const response = await fetch(`${this.apiUrl}/query`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-access-tokens": this.apiKey,
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`AskSage API error: ${error}`)
|
||||
}
|
||||
|
||||
const result = (await response.json()) as AskSageResponse
|
||||
|
||||
if (!result.message) {
|
||||
throw new Error("No content in AskSage response")
|
||||
}
|
||||
|
||||
// Return entire response as a single chunk since streaming is not supported
|
||||
yield {
|
||||
type: "text",
|
||||
text: result.message,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`AskSage request failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in askSageModels) {
|
||||
const id = modelId as AskSageModelId
|
||||
return { id, info: askSageModels[id] }
|
||||
}
|
||||
return {
|
||||
id: askSageDefaultModelId,
|
||||
info: askSageModels[askSageDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
+529
-23
@@ -1,9 +1,18 @@
|
||||
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { ApiHandlerOptions, bedrockDefaultModelId, BedrockModelId, bedrockModels, ModelInfo } from "../../shared/api"
|
||||
import { calculateApiCostOpenAI } from "../../utils/cost"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { fromNodeProviderChain } from "@aws-sdk/credential-providers"
|
||||
import {
|
||||
BedrockRuntimeClient,
|
||||
ConversationRole,
|
||||
ConverseStreamCommand,
|
||||
InvokeModelWithResponseStreamCommand,
|
||||
} from "@aws-sdk/client-bedrock-runtime"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
@@ -13,25 +22,82 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = budget_tokens !== 0 ? true : false
|
||||
// cross region inference requires prefixing the model id with the region
|
||||
let modelId = await this.getModelId()
|
||||
const model = this.getModel()
|
||||
|
||||
// create anthropic client, using sessions created or renewed after this handler's
|
||||
// Check if this is an Amazon Nova model
|
||||
if (modelId.includes("amazon.nova")) {
|
||||
yield* this.createNovaMessage(systemPrompt, messages, modelId, model)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a Deepseek model
|
||||
if (modelId.includes("deepseek")) {
|
||||
yield* this.createDeepseekMessage(systemPrompt, messages, modelId, model)
|
||||
return
|
||||
}
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
// Get model info and message indices for caching
|
||||
const userMsgIndices = messages.reduce((acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), [] as number[])
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
// Create anthropic client, using sessions created or renewed after this handler's
|
||||
// initialization, and allowing for session renewal if necessary as well
|
||||
let client = await this.getClient()
|
||||
const client = await this.getAnthropicClient()
|
||||
|
||||
const stream = await client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: this.getModel().info.maxTokens || 8192,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
...(this.options.awsBedrockUsePromptCache === true && {
|
||||
cache_control: { type: "ephemeral" },
|
||||
}),
|
||||
},
|
||||
],
|
||||
messages: messages.map((message, index) => {
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
...(this.options.awsBedrockUsePromptCache === true && {
|
||||
cache_control: { type: "ephemeral" },
|
||||
}),
|
||||
},
|
||||
]
|
||||
: message.content.map((content, contentIndex) =>
|
||||
contentIndex === message.content.length - 1
|
||||
? {
|
||||
...content,
|
||||
...(this.options.awsBedrockUsePromptCache === true && {
|
||||
cache_control: { type: "ephemeral" },
|
||||
}),
|
||||
}
|
||||
: content,
|
||||
),
|
||||
}
|
||||
}
|
||||
return message
|
||||
}),
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "message_start":
|
||||
@@ -40,6 +106,8 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
@@ -49,9 +117,22 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
@@ -68,6 +149,12 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -92,12 +179,21 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async getClient(): Promise<AnthropicBedrock> {
|
||||
// Create AWS credentials by executing a an AWS provider chain exactly as the
|
||||
// Anthropic SDK does it, by wrapping the default chain into a temporary process
|
||||
// environment.
|
||||
// Default AWS region
|
||||
private static readonly DEFAULT_REGION = "us-east-1"
|
||||
|
||||
/**
|
||||
* Gets AWS credentials using the provider chain
|
||||
* Centralizes credential retrieval logic for all AWS services
|
||||
*/
|
||||
private async getAwsCredentials(): Promise<{
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
sessionToken?: string
|
||||
}> {
|
||||
// Create AWS credentials by executing an AWS provider chain
|
||||
const providerChain = fromNodeProviderChain()
|
||||
const credentials = await AwsBedrockHandler.withTempEnv(
|
||||
return await AwsBedrockHandler.withTempEnv(
|
||||
() => {
|
||||
AwsBedrockHandler.setEnv("AWS_REGION", this.options.awsRegion)
|
||||
AwsBedrockHandler.setEnv("AWS_ACCESS_KEY_ID", this.options.awsAccessKey)
|
||||
@@ -107,33 +203,64 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
},
|
||||
() => providerChain(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the AWS region to use, with fallback to default
|
||||
*/
|
||||
private getRegion(): string {
|
||||
return this.options.awsRegion || AwsBedrockHandler.DEFAULT_REGION
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a BedrockRuntimeClient with the appropriate credentials
|
||||
*/
|
||||
private async getBedrockClient(): Promise<BedrockRuntimeClient> {
|
||||
const credentials = await this.getAwsCredentials()
|
||||
|
||||
return new BedrockRuntimeClient({
|
||||
region: this.getRegion(),
|
||||
credentials: {
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
sessionToken: credentials.sessionToken,
|
||||
},
|
||||
...(this.options.awsBedrockEndpoint && { endpoint: this.options.awsBedrockEndpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an AnthropicBedrock client with the appropriate credentials
|
||||
*/
|
||||
private async getAnthropicClient(): Promise<AnthropicBedrock> {
|
||||
const credentials = await this.getAwsCredentials()
|
||||
|
||||
// Return an AnthropicBedrock client with the resolved/assumed credentials.
|
||||
//
|
||||
// When AnthropicBedrock creates its AWS client, the chain will execute very
|
||||
// fast as the access/secret keys will already be already provided, and have
|
||||
// a higher precedence than the profiles.
|
||||
return new AnthropicBedrock({
|
||||
awsAccessKey: credentials.accessKeyId,
|
||||
awsSecretKey: credentials.secretAccessKey,
|
||||
awsSessionToken: credentials.sessionToken,
|
||||
awsRegion: this.options.awsRegion || "us-east-1",
|
||||
awsRegion: this.getRegion(),
|
||||
...(this.options.awsBedrockEndpoint && { baseURL: this.options.awsBedrockEndpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
private async getModelId(): Promise<string> {
|
||||
/**
|
||||
* Gets the appropriate model ID, accounting for cross-region inference if enabled
|
||||
*/
|
||||
async getModelId(): Promise<string> {
|
||||
if (this.options.awsUseCrossRegionInference) {
|
||||
let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
|
||||
let regionPrefix = this.getRegion().slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
return `us.${this.getModel().id}`
|
||||
case "eu-":
|
||||
return `eu.${this.getModel().id}`
|
||||
break
|
||||
case "ap-":
|
||||
return `apac.${this.getModel().id}`
|
||||
default:
|
||||
// cross region inference is not supported in this region, falling back to default model
|
||||
return this.getModel().id
|
||||
break
|
||||
}
|
||||
}
|
||||
return this.getModel().id
|
||||
@@ -150,9 +277,388 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private static async setEnv(key: string, value: string | undefined) {
|
||||
private static setEnv(key: string, value: string | undefined) {
|
||||
if (key !== "" && value !== undefined) {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using the Deepseek R1 model through AWS Bedrock
|
||||
*/
|
||||
private async *createDeepseekMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
modelId: string,
|
||||
model: { id: BedrockModelId; info: ModelInfo },
|
||||
): ApiStream {
|
||||
// Get Bedrock client with proper credentials
|
||||
const client = await this.getBedrockClient()
|
||||
|
||||
// Format prompt for DeepSeek R1 according to documentation
|
||||
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
|
||||
|
||||
// Prepare the request based on DeepSeek R1's expected format
|
||||
const command = new InvokeModelWithResponseStreamCommand({
|
||||
modelId: modelId,
|
||||
contentType: "application/json",
|
||||
accept: "application/json",
|
||||
body: JSON.stringify({
|
||||
prompt: formattedPrompt,
|
||||
max_tokens: model.info.maxTokens || 8000,
|
||||
temperature: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
// Track token usage
|
||||
const inputTokenEstimate = this.estimateInputTokens(systemPrompt, messages)
|
||||
let outputTokens = 0
|
||||
let isFirstChunk = true
|
||||
let accumulatedTokens = 0
|
||||
const TOKEN_REPORT_THRESHOLD = 100 // Report usage after accumulating this many tokens
|
||||
|
||||
// Execute the streaming request
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.body) {
|
||||
for await (const chunk of response.body) {
|
||||
if (chunk.chunk?.bytes) {
|
||||
try {
|
||||
// Parse the response chunk
|
||||
const decodedChunk = new TextDecoder().decode(chunk.chunk.bytes)
|
||||
const parsedChunk = JSON.parse(decodedChunk)
|
||||
|
||||
// Report usage on first chunk
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
const totalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, 0, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokenEstimate,
|
||||
outputTokens: 0,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle DeepSeek R1 response format
|
||||
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
|
||||
// For non-streaming response (full response)
|
||||
const text = parsedChunk.choices[0].text
|
||||
if (text) {
|
||||
const chunkTokens = this.estimateTokenCount(text)
|
||||
outputTokens += chunkTokens
|
||||
accumulatedTokens += chunkTokens
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: text,
|
||||
}
|
||||
|
||||
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
accumulatedTokens = 0
|
||||
}
|
||||
}
|
||||
} else if (parsedChunk.delta?.text) {
|
||||
// For streaming response (delta updates)
|
||||
const text = parsedChunk.delta.text
|
||||
const chunkTokens = this.estimateTokenCount(text)
|
||||
outputTokens += chunkTokens
|
||||
accumulatedTokens += chunkTokens
|
||||
|
||||
yield {
|
||||
type: "text",
|
||||
text: text,
|
||||
}
|
||||
// Report aggregated token usage only when threshold is reached
|
||||
if (accumulatedTokens >= TOKEN_REPORT_THRESHOLD) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
accumulatedTokens = 0
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing Deepseek response chunk:", error)
|
||||
// Propagate the error by yielding a text response with error information
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to parse Deepseek response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report any remaining accumulated tokens at the end of the stream
|
||||
if (accumulatedTokens > 0) {
|
||||
const totalCost = calculateApiCostOpenAI(model.info, 0, accumulatedTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: accumulatedTokens,
|
||||
totalCost: totalCost,
|
||||
}
|
||||
}
|
||||
|
||||
// Add final total cost calculation that includes both input and output tokens
|
||||
const finalTotalCost = calculateApiCostOpenAI(model.info, inputTokenEstimate, outputTokens, 0, 0)
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: inputTokenEstimate,
|
||||
outputTokens: outputTokens,
|
||||
totalCost: finalTotalCost,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats prompt for DeepSeek R1 model according to documentation
|
||||
* First uses convertToR1Format to merge consecutive messages with the same role,
|
||||
* then converts to the string format that DeepSeek R1 expects
|
||||
*/
|
||||
private formatDeepseekR1Prompt(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): string {
|
||||
// First use convertToR1Format to merge consecutive messages with the same role
|
||||
const r1Messages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
|
||||
// Then convert to the special string format expected by DeepSeek R1
|
||||
let combinedContent = ""
|
||||
|
||||
for (const message of r1Messages) {
|
||||
let content = ""
|
||||
|
||||
if (message.content) {
|
||||
if (typeof message.content === "string") {
|
||||
content = message.content
|
||||
} else {
|
||||
// Extract text content from message parts
|
||||
content = message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
combinedContent += message.role === "user" ? "User: " + content + "\n" : "Assistant: " + content + "\n"
|
||||
}
|
||||
|
||||
// Format according to DeepSeek R1's expected prompt format
|
||||
return `<|begin▁of▁sentence|><|User|>${combinedContent}<|Assistant|><think>\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token count based on text length (approximate)
|
||||
* Note: This is a rough estimation, as the actual token count depends on the tokenizer
|
||||
*/
|
||||
private estimateInputTokens(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): number {
|
||||
// For Deepseek R1, we estimate the token count of the formatted prompt
|
||||
// The formatted prompt includes special tokens and consistent formatting
|
||||
const formattedPrompt = this.formatDeepseekR1Prompt(systemPrompt, messages)
|
||||
return Math.ceil(formattedPrompt.length / 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates token count for a text string
|
||||
*/
|
||||
private estimateTokenCount(text: string): number {
|
||||
// Approximate 4 characters per token
|
||||
return Math.ceil(text.length / 4)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using Amazon Nova models through AWS Bedrock
|
||||
* Implements support for Nova Micro, Nova Lite, and Nova Pro models
|
||||
*/
|
||||
private async *createNovaMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
modelId: string,
|
||||
model: { id: BedrockModelId; info: ModelInfo },
|
||||
): ApiStream {
|
||||
// Get Bedrock client with proper credentials
|
||||
const client = await this.getBedrockClient()
|
||||
|
||||
// Format messages for Nova model
|
||||
const formattedMessages = this.formatNovaMessages(messages)
|
||||
|
||||
// Prepare request for Nova model
|
||||
const command = new ConverseStreamCommand({
|
||||
modelId: modelId,
|
||||
messages: formattedMessages,
|
||||
system: systemPrompt ? [{ text: systemPrompt }] : undefined,
|
||||
inferenceConfig: {
|
||||
maxTokens: model.info.maxTokens || 5000,
|
||||
temperature: 0,
|
||||
// topP: 0.9, // Alternative: use topP instead of temperature
|
||||
},
|
||||
})
|
||||
|
||||
// Execute the streaming request and handle response
|
||||
try {
|
||||
const response = await client.send(command)
|
||||
|
||||
if (response.stream) {
|
||||
let hasReportedInputTokens = false
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
// Handle metadata events with token usage information
|
||||
if (chunk.metadata?.usage) {
|
||||
// Report complete token usage from the model itself
|
||||
const inputTokens = chunk.metadata.usage.inputTokens || 0
|
||||
const outputTokens = chunk.metadata.usage.outputTokens || 0
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalCost: calculateApiCostOpenAI(model.info, inputTokens, outputTokens, 0, 0),
|
||||
}
|
||||
hasReportedInputTokens = true
|
||||
}
|
||||
|
||||
// Handle content delta (text generation)
|
||||
if (chunk.contentBlockDelta?.delta?.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.contentBlockDelta.delta.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning content if present
|
||||
if (chunk.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.contentBlockDelta.delta.reasoningContent.text,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
if (chunk.internalServerException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Internal server error: ${chunk.internalServerException.message}`,
|
||||
}
|
||||
} else if (chunk.modelStreamErrorException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Model stream error: ${chunk.modelStreamErrorException.message}`,
|
||||
}
|
||||
} else if (chunk.validationException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Validation error: ${chunk.validationException.message}`,
|
||||
}
|
||||
} else if (chunk.throttlingException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Throttling error: ${chunk.throttlingException.message}`,
|
||||
}
|
||||
} else if (chunk.serviceUnavailableException) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Service unavailable: ${chunk.serviceUnavailableException.message}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing Nova model response:", error)
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to process Nova response: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats messages for Amazon Nova models according to the SDK specification
|
||||
*/
|
||||
private formatNovaMessages(messages: Anthropic.Messages.MessageParam[]): { role: ConversationRole; content: any[] }[] {
|
||||
return messages.map((message) => {
|
||||
// Determine role (user or assistant)
|
||||
const role = message.role === "user" ? ConversationRole.USER : ConversationRole.ASSISTANT
|
||||
|
||||
// Process content based on type
|
||||
let content: any[] = []
|
||||
|
||||
if (typeof message.content === "string") {
|
||||
// Simple text content
|
||||
content = [{ text: message.content }]
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Convert Anthropic content format to Nova content format
|
||||
content = message.content
|
||||
.map((item) => {
|
||||
// Text content
|
||||
if (item.type === "text") {
|
||||
return { text: item.text }
|
||||
}
|
||||
|
||||
// Image content
|
||||
if (item.type === "image") {
|
||||
// Handle different image source formats
|
||||
let imageData: Uint8Array
|
||||
let format = "jpeg" // default format
|
||||
|
||||
// Extract format from media_type if available
|
||||
if (item.source.media_type) {
|
||||
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
|
||||
const formatMatch = item.source.media_type.match(/image\/(\w+)/)
|
||||
if (formatMatch && formatMatch[1]) {
|
||||
format = formatMatch[1]
|
||||
// Ensure format is one of the allowed values
|
||||
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
|
||||
format = "jpeg" // Default to jpeg if not supported
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get image data
|
||||
try {
|
||||
if (typeof item.source.data === "string") {
|
||||
// Handle base64 encoded data
|
||||
const base64Data = item.source.data.replace(/^data:image\/\w+;base64,/, "")
|
||||
imageData = new Uint8Array(Buffer.from(base64Data, "base64"))
|
||||
} else if (item.source.data && typeof item.source.data === "object") {
|
||||
// Try to convert to Uint8Array
|
||||
imageData = new Uint8Array(Buffer.from(item.source.data as any))
|
||||
} else {
|
||||
console.error("Unsupported image data format")
|
||||
return null // Skip this item if format is not supported
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Could not convert image data to Uint8Array:", error)
|
||||
return null // Skip this item if conversion fails
|
||||
}
|
||||
|
||||
return {
|
||||
image: {
|
||||
format,
|
||||
source: {
|
||||
bytes: imageData,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Return null for unsupported content types
|
||||
return null
|
||||
})
|
||||
.filter(Boolean) // Remove any null items
|
||||
}
|
||||
|
||||
// Return formatted message
|
||||
return {
|
||||
role,
|
||||
content,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import axios from "axios"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class ClineHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.cline.bot/v1",
|
||||
apiKey: this.options.clineApiKey || "",
|
||||
})
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
try {
|
||||
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.clineApiKey}`,
|
||||
},
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
|
||||
const generation = response.data
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching cline generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.openRouterModelId
|
||||
const modelInfo = this.options.openRouterModelInfo
|
||||
if (modelId && modelInfo) {
|
||||
return { id: modelId, info: modelInfo }
|
||||
}
|
||||
return { id: openRouterDefaultModelId, info: openRouterDefaultModelInfo }
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,18 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
}
|
||||
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
|
||||
const isOminiModel = modelId.includes("o1-mini") || modelId.includes("o3-mini")
|
||||
let temperature: number | undefined = 0
|
||||
|
||||
if (isOminiModel) {
|
||||
temperature = undefined // does not support temperature
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
|
||||
messages: [systemMessage, ...formattedMessages],
|
||||
temperature: 0,
|
||||
temperature,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
+12
-17
@@ -1,40 +1,35 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { Message, Ollama } from "ollama"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openAiModelInfoSaneDefaults } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToOllamaMessages } from "../transform/ollama-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
|
||||
export class OllamaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
private client: Ollama
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: (this.options.ollamaBaseUrl || "http://localhost:11434") + "/v1",
|
||||
apiKey: "ollama",
|
||||
})
|
||||
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
const stream = await this.client.chat({
|
||||
model: this.getModel().id,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum) || 32768,
|
||||
},
|
||||
})
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
text: chunk.message.content,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiHandler } from "../index"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { ChatCompletionReasoningEffort } from "openai/resources/chat/completions.mjs"
|
||||
|
||||
export class OpenAiHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
@@ -14,7 +15,8 @@ export class OpenAiHandler implements ApiHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
if (this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
|
||||
if (this.options.azureApiVersion || this.options.openAiBaseUrl?.toLowerCase().includes("azure.com")) {
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
@@ -32,20 +34,30 @@ export class OpenAiHandler implements ApiHandler {
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const modelId = this.options.openAiModelId ?? ""
|
||||
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
|
||||
const isO3Mini = modelId.includes("o3-mini")
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
let temperature: number | undefined = this.options.openAiModelInfo?.temperature ?? openAiModelInfoSaneDefaults.temperature
|
||||
let reasoningEffort: ChatCompletionReasoningEffort | undefined = undefined
|
||||
|
||||
if (isDeepseekReasoner) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
if (isO3Mini) {
|
||||
openAiMessages = [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
|
||||
temperature = undefined // does not support temperature
|
||||
reasoningEffort = (this.options.o3MiniReasoningEffort as ChatCompletionReasoningEffort) || "medium"
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: modelId,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
temperature,
|
||||
reasoning_effort: reasoningEffort,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
+50
-172
@@ -1,17 +1,18 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import axios from "axios"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { withRetry } from "../retry"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { OpenRouterErrorResponse } from "./types"
|
||||
|
||||
export class OpenRouterHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
lastGenerationId?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
@@ -27,148 +28,32 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
this.lastGenerationId = undefined
|
||||
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
const stream = await createOpenRouterStream(
|
||||
this.client,
|
||||
systemPrompt,
|
||||
messages,
|
||||
this.getModel(),
|
||||
this.options.o3MiniReasoningEffort,
|
||||
this.options.thinkingBudgetTokens,
|
||||
this.options.openRouterProviderSorting,
|
||||
)
|
||||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-ignore-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
// @ts-ignore-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
|
||||
// (models usually default to max tokens allowed)
|
||||
let maxTokens: number | undefined
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
maxTokens = 8_192
|
||||
break
|
||||
}
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
let topP: number | undefined = undefined
|
||||
if (this.getModel().id.startsWith("deepseek/deepseek-r1") || this.getModel().id === "perplexity/sonar-reasoning") {
|
||||
// Recommended values from DeepSeek
|
||||
temperature = 0.7
|
||||
topP = 0.95
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
let reasoning: { max_tokens: number } | undefined = undefined
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = budget_tokens !== 0 ? true : false
|
||||
if (reasoningOn) {
|
||||
temperature = undefined // extended thinking does not support non-1 temperature
|
||||
reasoning = { max_tokens: budget_tokens }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
|
||||
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
|
||||
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
|
||||
if (model.id === "deepseek/deepseek-chat") {
|
||||
shouldApplyMiddleOutTransform = true
|
||||
}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
|
||||
include_reasoning: true,
|
||||
...(model.id === "openai/o3-mini" ? { reasoning_effort: this.options.o3MiniReasoningEffort || "medium" } : {}),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
})
|
||||
|
||||
let genId: string | undefined
|
||||
let didOutputUsage: boolean = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as { message?: string; code?: number }
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
throw new Error(`OpenRouter API Error ${error?.code}: ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
if (!genId && chunk.id) {
|
||||
genId = chunk.id
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
@@ -181,50 +66,42 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
// Reasoning tokens are returned separately from the content
|
||||
if ("reasoning" in delta && delta.reasoning) {
|
||||
// console.log("reasoning", delta.reasoning)
|
||||
yield {
|
||||
type: "reasoning",
|
||||
// @ts-ignore-next-line
|
||||
reasoning: delta.reasoning,
|
||||
}
|
||||
|
||||
// if (didStreamThinkTagInReasoning) {
|
||||
// yield {
|
||||
// type: "text",
|
||||
// // @ts-ignore-next-line
|
||||
// text: delta.reasoning,
|
||||
// }
|
||||
// } else {
|
||||
// yield {
|
||||
// type: "reasoning",
|
||||
// // @ts-ignore-next-line
|
||||
// text: delta.reasoning,
|
||||
// }
|
||||
|
||||
// // @ts-ignore-next-line
|
||||
// reasoningResponse += delta.reasoning
|
||||
// if (reasoningResponse.includes("</think>")) {
|
||||
// didStreamThinkTagInReasoning = true
|
||||
// console.log("did hit think tag", reasoningResponse)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
// if (chunk.usage) {
|
||||
// yield {
|
||||
// type: "usage",
|
||||
// inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
// outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// }
|
||||
// }
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
}
|
||||
|
||||
if (genId) {
|
||||
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
|
||||
if (this.lastGenerationId) {
|
||||
await setTimeoutPromise(500) // FIXME: necessary delay to ensure generation endpoint is ready
|
||||
try {
|
||||
const generationIterator = this.fetchGenerationDetails(genId)
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
// console.log("OpenRouter generation details:", generation)
|
||||
yield {
|
||||
return {
|
||||
type: "usage",
|
||||
// cacheWriteTokens: 0,
|
||||
// cacheReadTokens: 0,
|
||||
@@ -238,6 +115,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
console.error("Error fetching OpenRouter generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
|
||||
@@ -248,7 +126,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.options.openRouterApiKey}`,
|
||||
},
|
||||
timeout: 5_000, // this request hangs sometimes
|
||||
timeout: 15_000, // this request hangs sometimes
|
||||
})
|
||||
yield response.data?.data
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, QwenModelId, ModelInfo, qwenDefaultModelId, qwenModels } from "../../shared/api"
|
||||
import {
|
||||
ApiHandlerOptions,
|
||||
ModelInfo,
|
||||
mainlandQwenModels,
|
||||
internationalQwenModels,
|
||||
mainlandQwenDefaultModelId,
|
||||
internationalQwenDefaultModelId,
|
||||
MainlandQwenModelId,
|
||||
InternationalQwenModelId,
|
||||
} from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
@@ -21,15 +30,21 @@ export class QwenHandler implements ApiHandler {
|
||||
})
|
||||
}
|
||||
|
||||
getModel(): { id: QwenModelId; info: ModelInfo } {
|
||||
getModel(): { id: MainlandQwenModelId | InternationalQwenModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in qwenModels) {
|
||||
const id = modelId as QwenModelId
|
||||
return { id, info: qwenModels[id] }
|
||||
}
|
||||
return {
|
||||
id: qwenDefaultModelId,
|
||||
info: qwenModels[qwenDefaultModelId],
|
||||
// Branch based on API line to let poor typescript know what to do
|
||||
if (this.options.qwenApiLine === "china") {
|
||||
return {
|
||||
id: (modelId as MainlandQwenModelId) ?? mainlandQwenDefaultModelId,
|
||||
info: mainlandQwenModels[modelId as MainlandQwenModelId] ?? mainlandQwenModels[mainlandQwenDefaultModelId],
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
id: (modelId as InternationalQwenModelId) ?? internationalQwenDefaultModelId,
|
||||
info:
|
||||
internationalQwenModels[modelId as InternationalQwenModelId] ??
|
||||
internationalQwenModels[internationalQwenDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiHandlerOptions, ModelInfo, SambanovaModelId, sambanovaDefaultModelId, sambanovaModels } from "../../shared/api"
|
||||
import { ApiHandler } from "../index"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
|
||||
export class SambanovaHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: OpenAI
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.sambanova.ai/v1",
|
||||
apiKey: this.options.sambanovaApiKey,
|
||||
})
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
const modelId = model.id.toLowerCase()
|
||||
|
||||
if (modelId.includes("deepseek") || modelId.includes("qwen") || modelId.includes("qwq")) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
const stream = await this.client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
messages: openAiMessages,
|
||||
temperature: 0,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in sambanovaModels) {
|
||||
const id = modelId as SambanovaModelId
|
||||
return { id, info: sambanovaModels[id] }
|
||||
}
|
||||
return {
|
||||
id: sambanovaDefaultModelId,
|
||||
info: sambanovaModels[sambanovaDefaultModelId],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// For the following openrouter error type sources, see the docs here:
|
||||
// https://openrouter.ai/docs/api-reference/errors
|
||||
|
||||
export type OpenRouterErrorResponse = {
|
||||
error: {
|
||||
message: string
|
||||
code: number
|
||||
metadata?: OpenRouterProviderErrorMetadata | OpenRouterModerationErrorMetadata | Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenRouterProviderErrorMetadata = {
|
||||
provider_name: string // The name of the provider that encountered the error
|
||||
raw: unknown // The raw error from the provider
|
||||
}
|
||||
|
||||
export type OpenRouterModerationErrorMetadata = {
|
||||
reasons: string[] // Why your input was flagged
|
||||
flagged_input: string // The text segment that was flagged, limited to 100 characters. If the flagged input is longer than 100 characters, it will be truncated in the middle and replaced with ...
|
||||
provider_name: string // The name of the provider that requested moderation
|
||||
model_slug: string
|
||||
}
|
||||
+220
-136
@@ -4,19 +4,25 @@ import { withRetry } from "../retry"
|
||||
import { ApiHandler } from "../"
|
||||
import { ApiHandlerOptions, ModelInfo, vertexDefaultModelId, VertexModelId, vertexModels } from "../../shared/api"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { VertexAI } from "@google-cloud/vertexai"
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-vertex-ai
|
||||
export class VertexHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
private client: AnthropicVertex
|
||||
private clientAnthropic: AnthropicVertex
|
||||
private clientVertex: VertexAI
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
this.client = new AnthropicVertex({
|
||||
this.clientAnthropic = new AnthropicVertex({
|
||||
projectId: this.options.vertexProjectId,
|
||||
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
|
||||
region: this.options.vertexRegion,
|
||||
})
|
||||
this.clientVertex = new VertexAI({
|
||||
project: this.options.vertexProjectId,
|
||||
location: this.options.vertexRegion,
|
||||
})
|
||||
}
|
||||
|
||||
@withRetry()
|
||||
@@ -24,40 +30,66 @@ export class VertexHandler implements ApiHandler {
|
||||
const model = this.getModel()
|
||||
const modelId = model.id
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = budget_tokens !== 0 ? true : false
|
||||
if (modelId.includes("claude")) {
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
let stream
|
||||
switch (modelId) {
|
||||
case "claude-3-7-sonnet@20250219":
|
||||
case "claude-3-5-sonnet-v2@20241022":
|
||||
case "claude-3-5-sonnet@20240620":
|
||||
case "claude-3-5-haiku@20241022":
|
||||
case "claude-3-opus@20240229":
|
||||
case "claude-3-haiku@20240307": {
|
||||
// Find indices of user messages for cache control
|
||||
const userMsgIndices = messages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
let stream
|
||||
switch (modelId) {
|
||||
case "claude-3-7-sonnet@20250219":
|
||||
case "claude-3-5-sonnet-v2@20241022":
|
||||
case "claude-3-5-sonnet@20240620":
|
||||
case "claude-3-5-haiku@20241022":
|
||||
case "claude-3-opus@20240229":
|
||||
case "claude-3-haiku@20240307": {
|
||||
// Find indices of user messages for cache control
|
||||
const userMsgIndices = messages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
stream = await this.client.beta.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
messages: messages.map((message, index) => {
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
stream = await this.clientAnthropic.beta.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
messages: messages.map((message, index) => {
|
||||
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
]
|
||||
: message.content.map((content, contentIndex) =>
|
||||
contentIndex === message.content.length - 1
|
||||
? {
|
||||
...content,
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
}
|
||||
: content,
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
@@ -66,121 +98,173 @@ export class VertexHandler implements ApiHandler {
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
]
|
||||
: message.content.map((content, contentIndex) =>
|
||||
contentIndex === message.content.length - 1
|
||||
? {
|
||||
...content,
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
}
|
||||
: content,
|
||||
),
|
||||
: message.content,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
},
|
||||
]
|
||||
: message.content,
|
||||
}
|
||||
}),
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
stream = await this.client.beta.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
}),
|
||||
stream: true,
|
||||
},
|
||||
],
|
||||
messages: messages.map((message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
},
|
||||
]
|
||||
: message.content,
|
||||
})),
|
||||
stream: true,
|
||||
})
|
||||
break
|
||||
{
|
||||
headers: {},
|
||||
},
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
stream = await this.clientAnthropic.beta.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
messages: messages.map((message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string"
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: message.content,
|
||||
},
|
||||
]
|
||||
: message.content,
|
||||
})),
|
||||
stream: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "message_start":
|
||||
const usage = chunk.message.usage
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
case "message_stop":
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "message_start":
|
||||
const usage = chunk.message.usage
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: usage.input_tokens || 0,
|
||||
outputTokens: usage.output_tokens || 0,
|
||||
cacheWriteTokens: usage.cache_creation_input_tokens || undefined,
|
||||
cacheReadTokens: usage.cache_read_input_tokens || undefined,
|
||||
}
|
||||
break
|
||||
case "message_delta":
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: chunk.usage.output_tokens || 0,
|
||||
}
|
||||
break
|
||||
case "message_stop":
|
||||
break
|
||||
case "content_block_start":
|
||||
switch (chunk.content_block.type) {
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.content_block.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
// Handle redacted thinking blocks - we still mark it as reasoning
|
||||
// but note that the content is encrypted
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
|
||||
case "text":
|
||||
if (chunk.index > 0) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "\n",
|
||||
}
|
||||
}
|
||||
yield {
|
||||
type: "text",
|
||||
text: "\n",
|
||||
text: chunk.content_block.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "thinking_delta":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: chunk.delta.thinking,
|
||||
}
|
||||
break
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_stop":
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// gemini
|
||||
const generativeModel = this.clientVertex.getGenerativeModel({
|
||||
model: this.getModel().id,
|
||||
systemInstruction: {
|
||||
role: "system",
|
||||
parts: [{ text: systemPrompt }],
|
||||
},
|
||||
})
|
||||
const request = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: messages.map((m) => {
|
||||
if (typeof m.content === "string") {
|
||||
return { text: m.content }
|
||||
} else if (Array.isArray(m.content)) {
|
||||
return {
|
||||
text: m.content
|
||||
.map((block) => {
|
||||
if (typeof block === "string") {
|
||||
return block
|
||||
} else if (block.type === "text") {
|
||||
return block.text
|
||||
} else {
|
||||
console.log("Unsupported block type", block)
|
||||
return ""
|
||||
}
|
||||
})
|
||||
.join(" "),
|
||||
}
|
||||
} else {
|
||||
return { text: "" }
|
||||
}
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
const streamingResult = await generativeModel.generateContentStream(request)
|
||||
for await (const chunk of streamingResult.stream) {
|
||||
// If usage data is available, yield it similarly:
|
||||
// yield { type: "usage", inputTokens: 0, outputTokens: 0 }
|
||||
// Otherwise, just yield text:
|
||||
const candidates = chunk.candidates || []
|
||||
for (const candidate of candidates) {
|
||||
for (const part of candidate.content?.parts || []) {
|
||||
if (part.text) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.content_block.text,
|
||||
text: part.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
case "content_block_delta":
|
||||
switch (chunk.delta.type) {
|
||||
case "text_delta":
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.delta.text,
|
||||
}
|
||||
break
|
||||
}
|
||||
break
|
||||
case "content_block_stop":
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import {
|
||||
Content,
|
||||
EnhancedGenerateContentResponse,
|
||||
FunctionCallPart,
|
||||
FunctionDeclaration,
|
||||
FunctionResponsePart,
|
||||
InlineDataPart,
|
||||
Part,
|
||||
SchemaType,
|
||||
TextPart,
|
||||
} from "@google/generative-ai"
|
||||
import { Content, EnhancedGenerateContentResponse, InlineDataPart, Part, TextPart } from "@google/generative-ai"
|
||||
|
||||
export function convertAnthropicContentToGemini(content: string | Anthropic.ContentBlockParam[]): Part[] {
|
||||
if (typeof content === "string") {
|
||||
@@ -29,55 +19,6 @@ export function convertAnthropicContentToGemini(content: string | Anthropic.Cont
|
||||
mimeType: block.source.media_type,
|
||||
},
|
||||
} as InlineDataPart
|
||||
case "tool_use":
|
||||
return {
|
||||
functionCall: {
|
||||
name: block.name,
|
||||
args: block.input,
|
||||
},
|
||||
} as FunctionCallPart
|
||||
case "tool_result":
|
||||
const name = block.tool_use_id.split("-")[0]
|
||||
if (!block.content) {
|
||||
return []
|
||||
}
|
||||
if (typeof block.content === "string") {
|
||||
return {
|
||||
functionResponse: {
|
||||
name,
|
||||
response: {
|
||||
name,
|
||||
content: block.content,
|
||||
},
|
||||
},
|
||||
} as FunctionResponsePart
|
||||
} else {
|
||||
// The only case when tool_result could be array is when the tool failed and we're providing ie user feedback potentially with images
|
||||
const textParts = block.content.filter((part) => part.type === "text")
|
||||
const imageParts = block.content.filter((part) => part.type === "image")
|
||||
const text = textParts.length > 0 ? textParts.map((part) => part.text).join("\n\n") : ""
|
||||
const imageText = imageParts.length > 0 ? "\n\n(See next part for image)" : ""
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
name,
|
||||
response: {
|
||||
name,
|
||||
content: text + imageText,
|
||||
},
|
||||
},
|
||||
} as FunctionResponsePart,
|
||||
...imageParts.map(
|
||||
(part) =>
|
||||
({
|
||||
inlineData: {
|
||||
data: part.source.data,
|
||||
mimeType: part.source.media_type,
|
||||
},
|
||||
}) as InlineDataPart,
|
||||
),
|
||||
]
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported content block type: ${(block as any).type}`)
|
||||
}
|
||||
@@ -91,26 +32,6 @@ export function convertAnthropicMessageToGemini(message: Anthropic.Messages.Mess
|
||||
}
|
||||
}
|
||||
|
||||
export function convertAnthropicToolToGemini(tool: Anthropic.Messages.Tool): FunctionDeclaration {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
parameters: {
|
||||
type: SchemaType.OBJECT,
|
||||
properties: Object.fromEntries(
|
||||
Object.entries(tool.input_schema.properties || {}).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
type: (value as any).type.toUpperCase(),
|
||||
description: (value as any).description || "",
|
||||
},
|
||||
]),
|
||||
),
|
||||
required: (tool.input_schema.required as string[]) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
|
||||
*/
|
||||
@@ -127,22 +48,6 @@ export function convertGeminiResponseToAnthropic(response: EnhancedGenerateConte
|
||||
content.push({ type: "text", text, citations: null })
|
||||
}
|
||||
|
||||
// Add function calls as tool_use blocks
|
||||
const functionCalls = response.functionCalls()
|
||||
if (functionCalls) {
|
||||
functionCalls.forEach((call, index) => {
|
||||
if ("content" in call.args && typeof call.args.content === "string") {
|
||||
call.args.content = unescapeGeminiContent(call.args.content)
|
||||
}
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: `${call.name}-${index}-${Date.now()}`,
|
||||
name: call.name,
|
||||
input: call.args,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Determine stop reason
|
||||
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null
|
||||
const finishReason = response.candidates?.[0]?.finishReason
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Mistral } from "@mistralai/mistralai"
|
||||
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
|
||||
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
|
||||
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
|
||||
@@ -21,25 +20,15 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
|
||||
})
|
||||
} else {
|
||||
if (anthropicMessage.role === "user") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolResultBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // user cannot send tool_use messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
// Filter to only include text and image blocks
|
||||
const textAndImageBlocks = anthropicMessage.content.filter(
|
||||
(part) => part.type === "text" || part.type === "image",
|
||||
)
|
||||
|
||||
if (nonToolMessages.length > 0) {
|
||||
if (textAndImageBlocks.length > 0) {
|
||||
mistralMessages.push({
|
||||
role: "user",
|
||||
content: nonToolMessages.map((part) => {
|
||||
content: textAndImageBlocks.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return {
|
||||
type: "image_url",
|
||||
@@ -53,37 +42,17 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
|
||||
})
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolUseBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
|
||||
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
|
||||
|
||||
let content: string | undefined
|
||||
if (nonToolMessages.length > 0) {
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return "" // impossible as the assistant cannot send images
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n")
|
||||
if (textBlocks.length > 0) {
|
||||
const content = textBlocks.map((part) => part.text).join("\n")
|
||||
|
||||
mistralMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
mistralMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Message } from "ollama"
|
||||
|
||||
export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] {
|
||||
const ollamaMessages: Message[] = []
|
||||
|
||||
for (const anthropicMessage of anthropicMessages) {
|
||||
if (typeof anthropicMessage.content === "string") {
|
||||
ollamaMessages.push({
|
||||
role: anthropicMessage.role,
|
||||
content: anthropicMessage.content,
|
||||
})
|
||||
} else {
|
||||
if (anthropicMessage.role === "user") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolResultBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_result") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
|
||||
// Process tool result messages FIRST since they must follow the tool use messages
|
||||
let toolResultImages: string[] = []
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
|
||||
if (typeof toolMessage.content === "string") {
|
||||
content = toolMessage.content
|
||||
} else {
|
||||
content =
|
||||
toolMessage.content
|
||||
?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
|
||||
return "(see following user message for image)"
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n") ?? ""
|
||||
}
|
||||
ollamaMessages.push({
|
||||
role: "user",
|
||||
images: toolResultImages.length > 0 ? toolResultImages : undefined,
|
||||
content: content,
|
||||
})
|
||||
})
|
||||
|
||||
// Process non-tool messages
|
||||
if (nonToolMessages.length > 0) {
|
||||
ollamaMessages.push({
|
||||
role: "user",
|
||||
content: nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return `data:${part.source.media_type};base64,${part.source.data}`
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n"),
|
||||
})
|
||||
}
|
||||
} else if (anthropicMessage.role === "assistant") {
|
||||
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
|
||||
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
|
||||
toolMessages: Anthropic.ToolUseBlockParam[]
|
||||
}>(
|
||||
(acc, part) => {
|
||||
if (part.type === "tool_use") {
|
||||
acc.toolMessages.push(part)
|
||||
} else if (part.type === "text" || part.type === "image") {
|
||||
acc.nonToolMessages.push(part)
|
||||
} // assistant cannot send tool_result messages
|
||||
return acc
|
||||
},
|
||||
{ nonToolMessages: [], toolMessages: [] },
|
||||
)
|
||||
|
||||
// Process non-tool messages
|
||||
let content: string = ""
|
||||
if (nonToolMessages.length > 0) {
|
||||
content = nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return "" // impossible as the assistant cannot send images
|
||||
}
|
||||
return part.text
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
ollamaMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ollamaMessages
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ModelInfo } from "../../shared/api"
|
||||
import { convertToOpenAiMessages } from "./openai-format"
|
||||
import { convertToR1Format } from "./r1-format"
|
||||
import { ApiStream, ApiStreamChunk } from "./stream"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { OpenRouterErrorResponse } from "../providers/types"
|
||||
|
||||
export async function createOpenRouterStream(
|
||||
client: OpenAI,
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
model: { id: string; info: ModelInfo },
|
||||
o3MiniReasoningEffort?: string,
|
||||
thinkingBudgetTokens?: number,
|
||||
openRouterProviderSorting?: string,
|
||||
) {
|
||||
// Convert Anthropic messages to OpenAI format
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
// prompt caching: https://openrouter.ai/docs/prompt-caching
|
||||
// this is specifically for claude models (some models may 'support prompt caching' automatically without this)
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
case "anthropic/claude-3-haiku":
|
||||
case "anthropic/claude-3-haiku:beta":
|
||||
case "anthropic/claude-3-opus":
|
||||
case "anthropic/claude-3-opus:beta":
|
||||
openAiMessages[0] = {
|
||||
role: "system",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: systemPrompt,
|
||||
// @ts-ignore-next-line
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
}
|
||||
// Add cache_control to the last two user messages
|
||||
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
|
||||
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
|
||||
lastTwoUserMessages.forEach((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
msg.content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
|
||||
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
|
||||
|
||||
if (!lastTextPart) {
|
||||
lastTextPart = { type: "text", text: "..." }
|
||||
msg.content.push(lastTextPart)
|
||||
}
|
||||
// @ts-ignore-next-line
|
||||
lastTextPart["cache_control"] = { type: "ephemeral" }
|
||||
}
|
||||
})
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Not sure how openrouter defaults max tokens when no value is provided, but the anthropic api requires this value and since they offer both 4096 and 8192 variants, we should ensure 8192.
|
||||
// (models usually default to max tokens allowed)
|
||||
let maxTokens: number | undefined
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet":
|
||||
case "anthropic/claude-3.5-sonnet:beta":
|
||||
case "anthropic/claude-3.5-sonnet-20240620":
|
||||
case "anthropic/claude-3.5-sonnet-20240620:beta":
|
||||
case "anthropic/claude-3-5-haiku":
|
||||
case "anthropic/claude-3-5-haiku:beta":
|
||||
case "anthropic/claude-3-5-haiku-20241022":
|
||||
case "anthropic/claude-3-5-haiku-20241022:beta":
|
||||
maxTokens = 8_192
|
||||
break
|
||||
}
|
||||
|
||||
let temperature: number | undefined = 0
|
||||
let topP: number | undefined = undefined
|
||||
if (
|
||||
model.id.startsWith("deepseek/deepseek-r1") ||
|
||||
model.id === "perplexity/sonar-reasoning" ||
|
||||
model.id === "qwen/qwq-32b:free" ||
|
||||
model.id === "qwen/qwq-32b"
|
||||
) {
|
||||
// Recommended values from DeepSeek
|
||||
temperature = 0.7
|
||||
topP = 0.95
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
|
||||
let reasoning: { max_tokens: number } | undefined = undefined
|
||||
switch (model.id) {
|
||||
case "anthropic/claude-3.7-sonnet":
|
||||
case "anthropic/claude-3.7-sonnet:beta":
|
||||
case "anthropic/claude-3.7-sonnet:thinking":
|
||||
case "anthropic/claude-3-7-sonnet":
|
||||
case "anthropic/claude-3-7-sonnet:beta":
|
||||
let budget_tokens = thinkingBudgetTokens || 0
|
||||
const reasoningOn = budget_tokens !== 0 ? true : false
|
||||
if (reasoningOn) {
|
||||
temperature = undefined // extended thinking does not support non-1 temperature
|
||||
reasoning = { max_tokens: budget_tokens }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Removes messages in the middle when close to context window limit. Should not be applied to models that support prompt caching since it would continuously break the cache.
|
||||
let shouldApplyMiddleOutTransform = !model.info.supportsPromptCache
|
||||
// except for deepseek (which we set supportsPromptCache to true for), where because the context window is so small our truncation algo might miss and we should use openrouter's middle-out transform as a fallback to ensure we don't exceed the context window (FIXME: once we have a more robust token estimator we should not rely on this)
|
||||
if (model.id === "deepseek/deepseek-chat") {
|
||||
shouldApplyMiddleOutTransform = true
|
||||
}
|
||||
|
||||
// @ts-ignore-next-line
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
top_p: topP,
|
||||
messages: openAiMessages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
|
||||
include_reasoning: true,
|
||||
...(model.id === "openai/o3-mini" ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
|
||||
})
|
||||
|
||||
return stream
|
||||
}
|
||||
+375
-143
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import cloneDeep from "clone-deep"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import fs from "fs/promises"
|
||||
import getFolderSize from "get-folder-size"
|
||||
import os from "os"
|
||||
@@ -9,12 +9,10 @@ import * as path from "path"
|
||||
import { serializeError } from "serialize-error"
|
||||
import * as vscode from "vscode"
|
||||
import { ApiHandler, buildApiHandler } from "../api"
|
||||
import { OpenAiHandler } from "../api/providers/openai"
|
||||
import { OpenRouterHandler } from "../api/providers/openrouter"
|
||||
import { ApiStream } from "../api/transform/stream"
|
||||
import CheckpointTracker from "../integrations/checkpoints/CheckpointTracker"
|
||||
import { DIFF_VIEW_URI_SCHEME, DiffViewProvider } from "../integrations/editor/DiffViewProvider"
|
||||
import { findToolName, formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
|
||||
import { formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
|
||||
import { extractTextFromFile } from "../integrations/misc/extract-text"
|
||||
import { showSystemNotification } from "../integrations/notifications"
|
||||
import { TerminalManager } from "../integrations/terminal/TerminalManager"
|
||||
@@ -24,7 +22,7 @@ import { listFiles } from "../services/glob/list-files"
|
||||
import { regexSearchFiles } from "../services/ripgrep"
|
||||
import { parseSourceCodeForDefinitionsTopLevel } from "../services/tree-sitter"
|
||||
import { ApiConfiguration } from "../shared/api"
|
||||
import { findLast, findLastIndex } from "../shared/array"
|
||||
import { findLast, findLastIndex, parsePartialArrayString } from "../shared/array"
|
||||
import { AutoApprovalSettings } from "../shared/AutoApprovalSettings"
|
||||
import { BrowserSettings } from "../shared/BrowserSettings"
|
||||
import { ChatSettings } from "../shared/ChatSettings"
|
||||
@@ -37,8 +35,10 @@ import {
|
||||
ClineApiReqCancelReason,
|
||||
ClineApiReqInfo,
|
||||
ClineAsk,
|
||||
ClineAskQuestion,
|
||||
ClineAskUseMcpServer,
|
||||
ClineMessage,
|
||||
ClinePlanModeResponse,
|
||||
ClineSay,
|
||||
ClineSayBrowserAction,
|
||||
ClineSayTool,
|
||||
@@ -48,7 +48,7 @@ import { getApiMetrics } from "../shared/getApiMetrics"
|
||||
import { HistoryItem } from "../shared/HistoryItem"
|
||||
import { ClineAskResponse, ClineCheckpointRestore } from "../shared/WebviewMessage"
|
||||
import { calculateApiCostAnthropic } from "../utils/cost"
|
||||
import { fileExistsAtPath } from "../utils/fs"
|
||||
import { fileExistsAtPath, isDirectory } from "../utils/fs"
|
||||
import { arePathsEqual, getReadablePath } from "../utils/path"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "../utils/string"
|
||||
import { AssistantMessageContent, parseAssistantMessage, ToolParamName, ToolUseName } from "./assistant-message"
|
||||
@@ -57,10 +57,21 @@ import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "./ignore/ClineIgnoreCon
|
||||
import { parseMentions } from "./mentions"
|
||||
import { formatResponse } from "./prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from "./prompts/system"
|
||||
import { getNextTruncationRange, getTruncatedMessages } from "./sliding-window"
|
||||
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
|
||||
import { ContextManager } from "./context-management/ContextManager"
|
||||
import { OpenAiHandler } from "../api/providers/openai"
|
||||
import { ApiStream } from "../api/transform/stream"
|
||||
import { ClineHandler } from "../api/providers/cline"
|
||||
import { ClineProvider } from "./webview/ClineProvider"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay, LanguageKey } from "../shared/Languages"
|
||||
import { telemetryService } from "../services/telemetry/TelemetryService"
|
||||
import { ConversationTelemetryService, TelemetryChatMessage } from "../services/telemetry/ConversationTelemetryService"
|
||||
import pTimeout from "p-timeout"
|
||||
import { GlobalFileNames } from "../global-constants"
|
||||
import {
|
||||
checkIsAnthropicContextWindowError,
|
||||
checkIsOpenRouterContextWindowError,
|
||||
} from "./context-management/context-error-handling"
|
||||
import { AnthropicHandler } from "../api/providers/anthropic"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
|
||||
|
||||
@@ -69,10 +80,12 @@ type UserContent = Array<Anthropic.ContentBlockParam>
|
||||
|
||||
export class Cline {
|
||||
readonly taskId: string
|
||||
readonly apiProvider?: string
|
||||
api: ApiHandler
|
||||
private terminalManager: TerminalManager
|
||||
private urlContentFetcher: UrlContentFetcher
|
||||
browserSession: BrowserSession
|
||||
contextManager: ContextManager
|
||||
private didEditFile: boolean = false
|
||||
customInstructions?: string
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
@@ -129,10 +142,12 @@ export class Cline {
|
||||
console.error("Failed to initialize ClineIgnoreController:", error)
|
||||
})
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.apiProvider = apiConfiguration.apiProvider
|
||||
this.api = buildApiHandler(apiConfiguration)
|
||||
this.terminalManager = new TerminalManager()
|
||||
this.urlContentFetcher = new UrlContentFetcher(provider.context)
|
||||
this.browserSession = new BrowserSession(provider.context, browserSettings)
|
||||
this.contextManager = new ContextManager()
|
||||
this.diffViewProvider = new DiffViewProvider(cwd)
|
||||
this.customInstructions = customInstructions
|
||||
this.autoApprovalSettings = autoApprovalSettings
|
||||
@@ -148,6 +163,14 @@ export class Cline {
|
||||
} else {
|
||||
throw new Error("Either historyItem or task/images must be provided")
|
||||
}
|
||||
|
||||
if (historyItem) {
|
||||
// Open task from history
|
||||
telemetryService.captureTaskRestarted(this.taskId, this.apiProvider)
|
||||
} else {
|
||||
// New task started
|
||||
telemetryService.captureTaskCreated(this.taskId, this.apiProvider)
|
||||
}
|
||||
}
|
||||
|
||||
updateBrowserSettings(browserSettings: BrowserSettings) {
|
||||
@@ -283,10 +306,12 @@ export class Cline {
|
||||
break
|
||||
case "taskAndWorkspace":
|
||||
case "workspace":
|
||||
if (!this.checkpointTracker) {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -396,10 +421,12 @@ export class Cline {
|
||||
}
|
||||
|
||||
// TODO: handle if this is called from outside original workspace, in which case we need to show user error message we cant show diff outside of workspace?
|
||||
if (!this.checkpointTracker) {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -423,21 +450,30 @@ export class Cline {
|
||||
try {
|
||||
if (seeNewChangesSinceLastTaskCompletion) {
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessage = findLast(
|
||||
const lastTaskCompletedMessageCheckpointHash = findLast(
|
||||
this.clineMessages.slice(0, messageIndex),
|
||||
(m) => m.say === "completion_result",
|
||||
) // ask is only used to relinquish control, its the last say we care about
|
||||
)?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
|
||||
// if undefined, then we get diff from beginning of git
|
||||
// if (!lastTaskCompletedMessage) {
|
||||
// console.error("No previous task completion message found")
|
||||
// return
|
||||
// }
|
||||
// This value *should* always exist
|
||||
const firstCheckpointMessageCheckpointHash = this.clineMessages.find(
|
||||
(m) => m.say === "checkpoint_created",
|
||||
)?.lastCheckpointHash
|
||||
|
||||
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
|
||||
|
||||
if (!previousCheckpointHash) {
|
||||
vscode.window.showErrorMessage("Unexpected error: No checkpoint hash found")
|
||||
relinquishButton()
|
||||
return
|
||||
}
|
||||
|
||||
// Get changed files between current state and commit
|
||||
changedFiles = await this.checkpointTracker?.getDiffSet(
|
||||
lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started
|
||||
hash,
|
||||
)
|
||||
changedFiles = await this.checkpointTracker?.getDiffSet(previousCheckpointHash, hash)
|
||||
if (!changedFiles?.length) {
|
||||
vscode.window.showInformationMessage("No changes found")
|
||||
relinquishButton()
|
||||
@@ -500,10 +536,12 @@ export class Cline {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!this.checkpointTracker) {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
this.checkpointTracker = await CheckpointTracker.create(
|
||||
this.taskId,
|
||||
this.providerRef.deref()?.context.globalStorageUri.fsPath,
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -515,12 +553,26 @@ export class Cline {
|
||||
const lastTaskCompletedMessage = findLast(this.clineMessages.slice(0, messageIndex), (m) => m.say === "completion_result")
|
||||
|
||||
try {
|
||||
// Get changed files between current state and commit
|
||||
const changedFiles = await this.checkpointTracker?.getDiffSet(
|
||||
lastTaskCompletedMessage?.lastCheckpointHash, // if undefined, then we get diff from beginning of git history, AKA when the task was started
|
||||
hash,
|
||||
)
|
||||
const changedFilesCount = changedFiles?.length || 0
|
||||
// Get last task completed
|
||||
const lastTaskCompletedMessageCheckpointHash = lastTaskCompletedMessage?.lastCheckpointHash // ask is only used to relinquish control, its the last say we care about
|
||||
// if undefined, then we get diff from beginning of git
|
||||
// if (!lastTaskCompletedMessage) {
|
||||
// console.error("No previous task completion message found")
|
||||
// return
|
||||
// }
|
||||
// This value *should* always exist
|
||||
const firstCheckpointMessageCheckpointHash = this.clineMessages.find(
|
||||
(m) => m.say === "checkpoint_created",
|
||||
)?.lastCheckpointHash
|
||||
|
||||
const previousCheckpointHash = lastTaskCompletedMessageCheckpointHash || firstCheckpointMessageCheckpointHash // either use the diff between the first checkpoint and the task completion, or the diff between the latest two task completions
|
||||
|
||||
if (!previousCheckpointHash) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get count of changed files between current state and commit
|
||||
const changedFilesCount = (await this.checkpointTracker?.getDiffCount(previousCheckpointHash, hash)) || 0
|
||||
if (changedFilesCount > 0) {
|
||||
return true
|
||||
}
|
||||
@@ -788,7 +840,8 @@ export class Cline {
|
||||
}
|
||||
|
||||
private async resumeTaskFromHistory() {
|
||||
// TODO: right now we let users init checkpoints for old tasks, assuming they're continuing them from the same workspace (which we never tied to tasks, so no way for us to know if it's opened in the right workspace)
|
||||
// UPDATE: we don't need this anymore since most tasks are now created with checkpoints enabled
|
||||
// right now we let users init checkpoints for old tasks, assuming they're continuing them from the same workspace (which we never tied to tasks, so no way for us to know if it's opened in the right workspace)
|
||||
// const doesShadowGitExist = await CheckpointTracker.doesShadowGitExist(this.taskId, this.providerRef.deref())
|
||||
// if (!doesShadowGitExist) {
|
||||
// this.checkpointTrackerErrorMessage = "Checkpoints are only available for new tasks"
|
||||
@@ -860,42 +913,7 @@ export class Cline {
|
||||
|
||||
// need to make sure that the api conversation history can be resumed by the api, even if it goes out of sync with cline messages
|
||||
|
||||
let existingApiConversationHistory: Anthropic.Messages.MessageParam[] = await this.getSavedApiConversationHistory()
|
||||
|
||||
// v2.0 xml tags refactor caveat: since we don't use tools anymore, we need to replace all tool use blocks with a text block since the API disallows conversations with tool uses and no tool schema
|
||||
const conversationWithoutToolBlocks = existingApiConversationHistory.map((message) => {
|
||||
if (Array.isArray(message.content)) {
|
||||
const newContent = message.content.map((block) => {
|
||||
if (block.type === "tool_use") {
|
||||
// it's important we convert to the new tool schema format so the model doesn't get confused about how to invoke tools
|
||||
const inputAsXml = Object.entries(block.input as Record<string, string>)
|
||||
.map(([key, value]) => `<${key}>\n${value}\n</${key}>`)
|
||||
.join("\n")
|
||||
return {
|
||||
type: "text",
|
||||
text: `<${block.name}>\n${inputAsXml}\n</${block.name}>`,
|
||||
} as Anthropic.Messages.TextBlockParam
|
||||
} else if (block.type === "tool_result") {
|
||||
// Convert block.content to text block array, removing images
|
||||
const contentAsTextBlocks = Array.isArray(block.content)
|
||||
? block.content.filter((item) => item.type === "text")
|
||||
: [{ type: "text", text: block.content }]
|
||||
const textContent = contentAsTextBlocks.map((item) => item.text).join("\n\n")
|
||||
const toolName = findToolName(block.tool_use_id, existingApiConversationHistory)
|
||||
return {
|
||||
type: "text",
|
||||
text: `[${toolName} Result]\n\n${textContent}`,
|
||||
} as Anthropic.Messages.TextBlockParam
|
||||
}
|
||||
return block
|
||||
})
|
||||
return { ...message, content: newContent }
|
||||
}
|
||||
return message
|
||||
})
|
||||
existingApiConversationHistory = conversationWithoutToolBlocks
|
||||
|
||||
// FIXME: remove tool use blocks altogether
|
||||
const existingApiConversationHistory: Anthropic.Messages.MessageParam[] = await this.getSavedApiConversationHistory()
|
||||
|
||||
// if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response
|
||||
// if there's no tool use and only a text block, then we can just add a user message
|
||||
@@ -1197,7 +1215,7 @@ export class Cline {
|
||||
// for their associated messages to be sent to the webview, maintaining
|
||||
// the correct order of messages (although the webview is smart about
|
||||
// grouping command_output messages despite any gaps anyways)
|
||||
await delay(50)
|
||||
await setTimeoutPromise(50)
|
||||
|
||||
result = result.trim()
|
||||
|
||||
@@ -1286,13 +1304,33 @@ export class Cline {
|
||||
const clineRulesFilePath = path.resolve(cwd, GlobalFileNames.clineRules)
|
||||
let clineRulesFileInstructions: string | undefined
|
||||
if (await fileExistsAtPath(clineRulesFilePath)) {
|
||||
try {
|
||||
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (ruleFileContent) {
|
||||
clineRulesFileInstructions = `# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
|
||||
if (await isDirectory(clineRulesFilePath)) {
|
||||
try {
|
||||
// Read all files in the .clinerules/ directory.
|
||||
const ruleFiles = await fs
|
||||
.readdir(clineRulesFilePath, { withFileTypes: true, recursive: true })
|
||||
.then((files) => files.filter((file) => file.isFile()))
|
||||
.then((files) => files.map((file) => path.resolve(file.parentPath, file.name)))
|
||||
const ruleFileContent = await Promise.all(
|
||||
ruleFiles.map(async (file) => {
|
||||
const ruleFilePath = path.resolve(clineRulesFilePath, file)
|
||||
const ruleFilePathRelative = path.relative(cwd, ruleFilePath)
|
||||
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
|
||||
}),
|
||||
).then((contents) => contents.join("\n\n"))
|
||||
clineRulesFileInstructions = `# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules directory at ${clineRulesFilePath}`)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const ruleFileContent = (await fs.readFile(clineRulesFilePath, "utf8")).trim()
|
||||
if (ruleFileContent) {
|
||||
clineRulesFileInstructions = `# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${ruleFileContent}`
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
|
||||
}
|
||||
} catch {
|
||||
console.error(`Failed to read .clinerules file at ${clineRulesFilePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1317,58 +1355,38 @@ export class Cline {
|
||||
)
|
||||
}
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = this.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)
|
||||
let contextWindow = this.api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (this.api instanceof OpenAiHandler && this.api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
this.conversationHistoryDeletedRange = getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range
|
||||
// await this.overwriteApiConversationHistory(truncatedMessages)
|
||||
}
|
||||
// Capture system prompt for telemetry,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
const systemMessage: TelemetryChatMessage = {
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
ts: Date.now(), // we dont uniquely identify system messages, so we use the timestamp as the id
|
||||
}
|
||||
|
||||
// no need for timeout here, as there's no timestamp to compare to
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(this.taskId, systemMessage, {
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = getTruncatedMessages(
|
||||
const contextManagementMetadata = this.contextManager.getNewContextMessagesAndMetadata(
|
||||
this.apiConversationHistory,
|
||||
this.clineMessages,
|
||||
this.api,
|
||||
this.conversationHistoryDeletedRange,
|
||||
previousApiReqIndex,
|
||||
)
|
||||
|
||||
let stream = this.api.createMessage(systemPrompt, truncatedConversationHistory)
|
||||
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
|
||||
this.conversationHistoryDeletedRange = contextManagementMetadata.conversationHistoryDeletedRange
|
||||
await this.saveClineMessages() // saves task history item which we use to keep track of conversation history deleted range
|
||||
}
|
||||
|
||||
let stream = this.api.createMessage(systemPrompt, contextManagementMetadata.truncatedConversationHistory)
|
||||
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
@@ -1379,21 +1397,60 @@ export class Cline {
|
||||
yield firstChunk.value
|
||||
this.isWaitingForFirstChunk = false
|
||||
} catch (error) {
|
||||
const isOpenRouter = this.api instanceof OpenRouterHandler
|
||||
if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
const isOpenRouter = this.api instanceof OpenRouterHandler || this.api instanceof ClineHandler
|
||||
const isAnthropic = this.api instanceof AnthropicHandler
|
||||
const isOpenRouterContextWindowError = checkIsOpenRouterContextWindowError(error) && isOpenRouter
|
||||
const isAnthropicContextWindowError = checkIsAnthropicContextWindowError(error) && isAnthropic
|
||||
|
||||
if (isAnthropic && isAnthropicContextWindowError && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
"quarter", // Force aggressive truncation
|
||||
)
|
||||
await this.saveClineMessages()
|
||||
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
} else if (isOpenRouter && !this.didAutomaticallyRetryFailedApiRequest) {
|
||||
if (isOpenRouterContextWindowError) {
|
||||
this.conversationHistoryDeletedRange = this.contextManager.getNextTruncationRange(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
"quarter", // Force aggressive truncation
|
||||
)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
|
||||
console.log("first chunk failed, waiting 1 second before retrying")
|
||||
await delay(1000)
|
||||
await setTimeoutPromise(1000)
|
||||
this.didAutomaticallyRetryFailedApiRequest = true
|
||||
} else {
|
||||
// request failed after retrying automatically once, ask user if they want to retry again
|
||||
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
|
||||
|
||||
if (isOpenRouterContextWindowError || isAnthropicContextWindowError) {
|
||||
const truncatedConversationHistory = this.contextManager.getTruncatedMessages(
|
||||
this.apiConversationHistory,
|
||||
this.conversationHistoryDeletedRange,
|
||||
)
|
||||
|
||||
// If the conversation has more than 3 messages, we can truncate again. If not, then the conversation is bricked.
|
||||
// ToDo: Allow the user to change their input if this is the case.
|
||||
if (truncatedConversationHistory.length > 3) {
|
||||
error = new Error("Context window exceeded. Click retry to truncate the conversation and try again.")
|
||||
this.didAutomaticallyRetryFailedApiRequest = false
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = this.formatErrorWithStatusCode(error)
|
||||
|
||||
const { response } = await this.ask("api_req_failed", errorMessage)
|
||||
|
||||
if (response !== "yesButtonClicked") {
|
||||
// this will never happen since if noButtonClicked, we will clear current task, aborting this instance
|
||||
throw new Error("API request failed")
|
||||
}
|
||||
|
||||
await this.say("api_req_retried")
|
||||
}
|
||||
// delegate generator output from the recursive call
|
||||
@@ -1717,7 +1774,7 @@ export class Cline {
|
||||
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file.\n\n` +
|
||||
`The file was reverted to its original state:\n\n` +
|
||||
`<file_content path="${relPath.toPosix()}">\n${this.diffViewProvider.originalContent}\n</file_content>\n\n` +
|
||||
`Try again with a more precise SEARCH block.\n(If you keep running into this error, you may use the write_to_file tool as a workaround.)`,
|
||||
`Try again with fewer/more precise SEARCH blocks.\n(If you run into this error two times in a row, you may use the write_to_file tool as a fallback.)`,
|
||||
),
|
||||
)
|
||||
await this.diffViewProvider.revertChanges()
|
||||
@@ -1807,7 +1864,7 @@ export class Cline {
|
||||
await this.diffViewProvider.open(relPath)
|
||||
}
|
||||
await this.diffViewProvider.update(newContent, true)
|
||||
await delay(300) // wait for diff view to update
|
||||
await setTimeoutPromise(300) // wait for diff view to update
|
||||
this.diffViewProvider.scrollToFirstDiff()
|
||||
// showOmissionWarning(this.diffViewProvider.originalContent || "", newContent)
|
||||
|
||||
@@ -1826,16 +1883,16 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
|
||||
|
||||
// we need an artificial delay to let the diagnostics catch up to the changes
|
||||
await delay(3_500)
|
||||
await setTimeoutPromise(3_500)
|
||||
} else {
|
||||
// If auto-approval is enabled but this tool wasn't auto-approved, send notification
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
`Cline wants to ${fileExists ? "edit" : "create"} ${path.basename(relPath)}`,
|
||||
)
|
||||
this.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
// const didApprove = await askApproval("tool", completeMessage)
|
||||
|
||||
// Need a more customized tool response for file edits to highlight the fact that the file was not updated (particularly important for deepseek)
|
||||
let didApprove = true
|
||||
@@ -1853,17 +1910,18 @@ export class Cline {
|
||||
}
|
||||
this.didRejectTool = true
|
||||
didApprove = false
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
|
||||
} else {
|
||||
// User hit the approve button, and may have provided feedback
|
||||
if (text || images?.length) {
|
||||
pushAdditionalToolFeedback(text, images)
|
||||
await this.say("user_feedback", text, images)
|
||||
}
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
|
||||
}
|
||||
|
||||
if (!didApprove) {
|
||||
await this.diffViewProvider.revertChanges()
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1971,6 +2029,7 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false) // need to be sending partialValue bool, since undefined has its own purpose in that the message is treated neither as a partial or completion of a partial, but as a single complete message
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
|
||||
} else {
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
`Cline wants to read ${path.basename(absolutePath)}`,
|
||||
@@ -1978,8 +2037,10 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
const didApprove = await askApproval("tool", completeMessage)
|
||||
if (!didApprove) {
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
|
||||
break
|
||||
}
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
|
||||
}
|
||||
// now execute the tool like normal
|
||||
const content = await extractTextFromFile(absolutePath)
|
||||
@@ -2042,6 +2103,7 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
|
||||
} else {
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
`Cline wants to view directory ${path.basename(absolutePath)}/`,
|
||||
@@ -2049,8 +2111,10 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
const didApprove = await askApproval("tool", completeMessage)
|
||||
if (!didApprove) {
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
|
||||
break
|
||||
}
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
|
||||
}
|
||||
pushToolResult(result)
|
||||
|
||||
@@ -2106,6 +2170,7 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
|
||||
} else {
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
`Cline wants to view source code definitions in ${path.basename(absolutePath)}/`,
|
||||
@@ -2113,8 +2178,10 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
const didApprove = await askApproval("tool", completeMessage)
|
||||
if (!didApprove) {
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
|
||||
break
|
||||
}
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
|
||||
}
|
||||
pushToolResult(result)
|
||||
|
||||
@@ -2182,6 +2249,7 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await this.say("tool", completeMessage, undefined, false)
|
||||
this.consecutiveAutoApprovedRequestsCount++
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, true, true)
|
||||
} else {
|
||||
showNotificationForApprovalIfAutoApprovalEnabled(
|
||||
`Cline wants to search files in ${path.basename(absolutePath)}/`,
|
||||
@@ -2189,8 +2257,10 @@ export class Cline {
|
||||
this.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
const didApprove = await askApproval("tool", completeMessage)
|
||||
if (!didApprove) {
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, false)
|
||||
break
|
||||
}
|
||||
telemetryService.captureToolUsage(this.taskId, block.name, false, true)
|
||||
}
|
||||
pushToolResult(results)
|
||||
|
||||
@@ -2678,9 +2748,14 @@ export class Cline {
|
||||
}
|
||||
case "ask_followup_question": {
|
||||
const question: string | undefined = block.params.question
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
const sharedMessage = {
|
||||
question: removeClosingTag("question", question),
|
||||
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
|
||||
} satisfies ClineAskQuestion
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {})
|
||||
await this.ask("followup", JSON.stringify(sharedMessage), block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!question) {
|
||||
@@ -2698,8 +2773,25 @@ export class Cline {
|
||||
})
|
||||
}
|
||||
|
||||
const { text, images } = await this.ask("followup", question, false)
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
const { text, images } = await this.ask("followup", JSON.stringify(sharedMessage), false)
|
||||
|
||||
// Check if options contains the text response
|
||||
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
|
||||
// Valid option selected, don't show user message in UI
|
||||
// Update last followup message with selected option
|
||||
const lastFollowupMessage = findLast(this.clineMessages, (m) => m.ask === "followup")
|
||||
if (lastFollowupMessage) {
|
||||
lastFollowupMessage.text = JSON.stringify({
|
||||
...sharedMessage,
|
||||
selected: text,
|
||||
} satisfies ClineAskQuestion)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
} else {
|
||||
// Option not selected, send user feedback
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
|
||||
pushToolResult(formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images))
|
||||
|
||||
break
|
||||
@@ -2712,11 +2804,14 @@ export class Cline {
|
||||
}
|
||||
case "plan_mode_response": {
|
||||
const response: string | undefined = block.params.response
|
||||
const optionsRaw: string | undefined = block.params.options
|
||||
const sharedMessage = {
|
||||
response: removeClosingTag("response", response),
|
||||
options: parsePartialArrayString(removeClosingTag("options", optionsRaw)),
|
||||
} satisfies ClinePlanModeResponse
|
||||
try {
|
||||
if (block.partial) {
|
||||
await this.ask("plan_mode_response", removeClosingTag("response", response), block.partial).catch(
|
||||
() => {},
|
||||
)
|
||||
await this.ask("plan_mode_response", JSON.stringify(sharedMessage), block.partial).catch(() => {})
|
||||
break
|
||||
} else {
|
||||
if (!response) {
|
||||
@@ -2735,7 +2830,7 @@ export class Cline {
|
||||
// }
|
||||
|
||||
this.isAwaitingPlanResponse = true
|
||||
let { text, images } = await this.ask("plan_mode_response", response, false)
|
||||
let { text, images } = await this.ask("plan_mode_response", JSON.stringify(sharedMessage), false)
|
||||
this.isAwaitingPlanResponse = false
|
||||
|
||||
// webview invoke sendMessage will send this marker in order to put webview into the proper state (responding to an ask) and as a flag to extension that the user switched to ACT mode.
|
||||
@@ -2743,6 +2838,25 @@ export class Cline {
|
||||
text = ""
|
||||
}
|
||||
|
||||
// Check if options contains the text response
|
||||
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
|
||||
// Valid option selected, don't show user message in UI
|
||||
// Update last followup message with selected option
|
||||
const lastPlanMessage = findLast(this.clineMessages, (m) => m.ask === "plan_mode_response")
|
||||
if (lastPlanMessage) {
|
||||
lastPlanMessage.text = JSON.stringify({
|
||||
...sharedMessage,
|
||||
selected: text,
|
||||
} satisfies ClinePlanModeResponse)
|
||||
await this.saveClineMessages()
|
||||
}
|
||||
} else {
|
||||
// Option not selected, send user feedback
|
||||
if (text || images?.length) {
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.didRespondToPlanAskBySwitchingMode) {
|
||||
pushToolResult(
|
||||
formatResponse.toolResult(
|
||||
@@ -2758,10 +2872,6 @@ export class Cline {
|
||||
pushToolResult(formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images))
|
||||
}
|
||||
|
||||
if (text || images?.length) {
|
||||
await this.say("user_feedback", text ?? "", images)
|
||||
}
|
||||
|
||||
//
|
||||
break
|
||||
}
|
||||
@@ -2866,6 +2976,7 @@ export class Cline {
|
||||
await this.say("completion_result", result, undefined, false)
|
||||
await this.saveCheckpoint(true)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(this.taskId)
|
||||
} else {
|
||||
// we already sent a command message, meaning the complete completion message has also been sent
|
||||
await this.saveCheckpoint(true)
|
||||
@@ -2888,6 +2999,7 @@ export class Cline {
|
||||
await this.say("completion_result", result, undefined, false)
|
||||
await this.saveCheckpoint(true)
|
||||
await addNewChangesFlagToLastCompletionResultMessage()
|
||||
telemetryService.captureTaskCompleted(this.taskId)
|
||||
}
|
||||
|
||||
// we already sent completion_result says, an empty string asks relinquishes control over button and field
|
||||
@@ -2984,7 +3096,7 @@ export class Cline {
|
||||
"mistake_limit_reached",
|
||||
this.api.getModel().id.includes("claude")
|
||||
? `This may indicate a failure in his thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 3.5 Sonnet for its advanced agentic coding capabilities.",
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 3.7 Sonnet for its advanced agentic coding capabilities.",
|
||||
)
|
||||
if (response === "messageResponse") {
|
||||
userContent.push(
|
||||
@@ -3039,10 +3151,16 @@ export class Cline {
|
||||
// use this opportunity to initialize the checkpoint tracker (can be expensive to initialize in the constructor)
|
||||
// FIXME: right now we're letting users init checkpoints for old tasks, but this could be a problem if opening a task in the wrong workspace
|
||||
// isNewTask &&
|
||||
if (!this.checkpointTracker) {
|
||||
if (!this.checkpointTracker && !this.checkpointTrackerErrorMessage) {
|
||||
try {
|
||||
this.checkpointTracker = await CheckpointTracker.create(this.taskId, this.providerRef.deref())
|
||||
this.checkpointTrackerErrorMessage = undefined
|
||||
this.checkpointTracker = await pTimeout(
|
||||
CheckpointTracker.create(this.taskId, this.providerRef.deref()?.context.globalStorageUri.fsPath),
|
||||
{
|
||||
milliseconds: 15_000,
|
||||
message:
|
||||
"Checkpoints taking too long to initialize. Consider re-opening Cline in a project that uses git, or disabling checkpoints.",
|
||||
},
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Failed to initialize checkpoint tracker:", errorMessage)
|
||||
@@ -3070,6 +3188,41 @@ export class Cline {
|
||||
content: userContent,
|
||||
})
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "user")
|
||||
|
||||
// Capture message data for telemetry,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Get the corresponding timestamp from clineMessages
|
||||
// The last message in clineMessages should be the one we just added
|
||||
|
||||
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
|
||||
const ts = lastClineMessage.ts
|
||||
|
||||
// Send individual message to telemetry
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
// Add the timestamp to the message object for telemetry
|
||||
{
|
||||
...lastMessage,
|
||||
ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
},
|
||||
)
|
||||
|
||||
// Send entire conversation history to cleanup endpoint
|
||||
// This ensures deleted messages are properly handled in telemetry
|
||||
this.providerRef.deref()?.conversationTelemetryService.cleanupTask(this.taskId, this.clineMessages)
|
||||
}
|
||||
|
||||
// since we sent off a placeholder api_req_started message to update the webview while waiting to actually start the API request (to load potential details for example), we need to update the text of that message
|
||||
const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started")
|
||||
this.clineMessages[lastApiReqIndex].text = JSON.stringify({
|
||||
@@ -3145,6 +3298,38 @@ export class Cline {
|
||||
updateApiReqMsg(cancelReason, streamingFailedMessage)
|
||||
await this.saveClineMessages()
|
||||
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
|
||||
|
||||
// Capture message data for telemetry after assistant response
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Find the corresponding timestamp from clineMessages
|
||||
// For assistant messages, we need to find the most recent "text" message
|
||||
const lastTextMessage = findLast(this.clineMessages, (m) => m.say === "text")
|
||||
|
||||
// Add the timestamp to the message object for telemetry
|
||||
if (!lastTextMessage) {
|
||||
console.error("No text message found in clineMessages")
|
||||
} else {
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
{
|
||||
...lastMessage,
|
||||
ts: lastTextMessage.ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
|
||||
this.didFinishAbortingStream = true
|
||||
}
|
||||
@@ -3166,6 +3351,7 @@ export class Cline {
|
||||
let assistantMessage = ""
|
||||
let reasoningMessage = ""
|
||||
this.isStreaming = true
|
||||
let didReceiveUsageChunk = false
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (!chunk) {
|
||||
@@ -3173,6 +3359,7 @@ export class Cline {
|
||||
}
|
||||
switch (chunk.type) {
|
||||
case "usage":
|
||||
didReceiveUsageChunk = true
|
||||
inputTokens += chunk.inputTokens
|
||||
outputTokens += chunk.outputTokens
|
||||
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
|
||||
@@ -3242,6 +3429,23 @@ export class Cline {
|
||||
this.isStreaming = false
|
||||
}
|
||||
|
||||
// OpenRouter/Cline may not return token usage as part of the stream (since it may abort early), so we fetch after the stream is finished
|
||||
// (updateApiReq below will update the api_req_started message with the usage details. we do this async so it updates the api_req_started message in the background)
|
||||
if (!didReceiveUsageChunk) {
|
||||
this.api.getApiStreamUsage?.().then(async (apiStreamUsage) => {
|
||||
if (apiStreamUsage) {
|
||||
inputTokens += apiStreamUsage.inputTokens
|
||||
outputTokens += apiStreamUsage.outputTokens
|
||||
cacheWriteTokens += apiStreamUsage.cacheWriteTokens ?? 0
|
||||
cacheReadTokens += apiStreamUsage.cacheReadTokens ?? 0
|
||||
totalCost = apiStreamUsage.totalCost
|
||||
}
|
||||
updateApiReqMsg()
|
||||
await this.saveClineMessages()
|
||||
await this.providerRef.deref()?.postStateToWebview()
|
||||
})
|
||||
}
|
||||
|
||||
// need to call here in case the stream was aborted
|
||||
if (this.abort) {
|
||||
throw new Error("Cline instance aborted")
|
||||
@@ -3268,11 +3472,39 @@ export class Cline {
|
||||
// need to save assistant responses to file before proceeding to tool use since user can exit at any moment and we wouldn't be able to save the assistant's response
|
||||
let didEndLoop = false
|
||||
if (assistantMessage.length > 0) {
|
||||
telemetryService.captureConversationTurnEvent(this.taskId, this.apiProvider, this.api.getModel().id, "assistant")
|
||||
|
||||
await this.addToApiConversationHistory({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: assistantMessage }],
|
||||
})
|
||||
|
||||
// Capture message data for telemetry after assistant response,
|
||||
// ONLY if user is opted in, in advanced settings
|
||||
if (this.providerRef.deref()?.conversationTelemetryService.isOptedInToConversationTelemetry()) {
|
||||
// Get the last message from apiConversationHistory
|
||||
const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
|
||||
|
||||
// Find the corresponding timestamp from clineMessages
|
||||
const lastClineMessage = this.clineMessages[this.clineMessages.length - 1]
|
||||
|
||||
if (lastClineMessage) {
|
||||
this.providerRef.deref()?.conversationTelemetryService.captureMessage(
|
||||
this.taskId,
|
||||
{
|
||||
...lastMessage,
|
||||
ts: lastClineMessage.ts,
|
||||
},
|
||||
{
|
||||
apiProvider: this.apiProvider,
|
||||
model: this.api.getModel().id,
|
||||
tokensIn: inputTokens,
|
||||
tokensOut: outputTokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// in case the content blocks finished
|
||||
// it may be the api stream finished after the last parsed content block was executed, so we are able to detect out of bounds and set userMessageContentReady to true (note you should not call presentAssistantMessage since if the last block is completed it will be presented again)
|
||||
@@ -3396,7 +3628,7 @@ export class Cline {
|
||||
|
||||
if (busyTerminals.length > 0 && this.didEditFile) {
|
||||
// || this.didEditFile
|
||||
await delay(300) // delay after saving file to let terminals catch up
|
||||
await setTimeoutPromise(300) // delay after saving file to let terminals catch up
|
||||
}
|
||||
|
||||
// let terminalWasBusy = false
|
||||
@@ -3510,7 +3742,7 @@ export class Cline {
|
||||
details +=
|
||||
"\nIn this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_response tool to engage in a conversational back and forth with the user. Do not use the plan_mode_response tool until you've gathered all the information you need e.g. with read_file or ask_followup_question."
|
||||
details +=
|
||||
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan.)'
|
||||
'\n(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)'
|
||||
} else {
|
||||
details += "\nACT MODE"
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export const toolParamNames = [
|
||||
"arguments",
|
||||
"uri",
|
||||
"question",
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
] as const
|
||||
@@ -58,64 +59,3 @@ export interface ToolUse {
|
||||
params: Partial<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export interface ExecuteCommandToolUse extends ToolUse {
|
||||
name: "execute_command"
|
||||
// Pick<Record<ToolParamName, string>, "command"> makes "command" required, but Partial<> makes it optional
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "command" | "requires_approval">>
|
||||
}
|
||||
|
||||
export interface ReadFileToolUse extends ToolUse {
|
||||
name: "read_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path">>
|
||||
}
|
||||
|
||||
export interface WriteToFileToolUse extends ToolUse {
|
||||
name: "write_to_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content">>
|
||||
}
|
||||
|
||||
export interface ReplaceInFileToolUse extends ToolUse {
|
||||
name: "replace_in_file"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "diff">>
|
||||
}
|
||||
|
||||
export interface SearchFilesToolUse extends ToolUse {
|
||||
name: "search_files"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>
|
||||
}
|
||||
|
||||
export interface ListFilesToolUse extends ToolUse {
|
||||
name: "list_files"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path" | "recursive">>
|
||||
}
|
||||
|
||||
export interface ListCodeDefinitionNamesToolUse extends ToolUse {
|
||||
name: "list_code_definition_names"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "path">>
|
||||
}
|
||||
|
||||
export interface BrowserActionToolUse extends ToolUse {
|
||||
name: "browser_action"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "action" | "url" | "coordinate" | "text">>
|
||||
}
|
||||
|
||||
export interface UseMcpToolToolUse extends ToolUse {
|
||||
name: "use_mcp_tool"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "tool_name" | "arguments">>
|
||||
}
|
||||
|
||||
export interface AccessMcpResourceToolUse extends ToolUse {
|
||||
name: "access_mcp_resource"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "server_name" | "uri">>
|
||||
}
|
||||
|
||||
export interface AskFollowupQuestionToolUse extends ToolUse {
|
||||
name: "ask_followup_question"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "question">>
|
||||
}
|
||||
|
||||
export interface AttemptCompletionToolUse extends ToolUse {
|
||||
name: "attempt_completion"
|
||||
params: Partial<Pick<Record<ToolParamName, string>, "result" | "command">>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ContextManager } from "./ContextManager"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { expect } from "chai"
|
||||
|
||||
describe("ContextManager", () => {
|
||||
function createMessages(count: number): Anthropic.Messages.MessageParam[] {
|
||||
const messages: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: "Initial task message",
|
||||
})
|
||||
|
||||
let role: "user" | "assistant" = "assistant"
|
||||
for (let i = 1; i < count; i++) {
|
||||
messages.push({
|
||||
role,
|
||||
content: `Message ${i}`,
|
||||
})
|
||||
role = role === "user" ? "assistant" : "user"
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
describe("getNextTruncationRange", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("first truncation with half keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
expect(result).to.deep.equal([1, 4])
|
||||
})
|
||||
|
||||
it("first truncation with quarter keep", () => {
|
||||
const messages = createMessages(11)
|
||||
const result = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
expect(result).to.deep.equal([1, 6])
|
||||
})
|
||||
|
||||
it("sequential truncation with half keep", () => {
|
||||
const messages = createMessages(21)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages)
|
||||
expect(firstRange).to.deep.equal([1, 10])
|
||||
|
||||
// Pass the previous range for sequential truncation
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange)
|
||||
expect(secondRange).to.deep.equal([1, 14])
|
||||
})
|
||||
|
||||
it("sequential truncation with quarter keep", () => {
|
||||
const messages = createMessages(41)
|
||||
const firstRange = contextManager.getNextTruncationRange(messages, undefined, "quarter")
|
||||
|
||||
const secondRange = contextManager.getNextTruncationRange(messages, firstRange, "quarter")
|
||||
|
||||
expect(secondRange[0]).to.equal(1)
|
||||
expect(secondRange[1]).to.be.greaterThan(firstRange[1])
|
||||
})
|
||||
|
||||
it("ensures the last message in range is a user message", () => {
|
||||
const messages = createMessages(14)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
// Check if the message at the end of range is a user message
|
||||
const lastRemovedMessage = messages[result[1]]
|
||||
expect(lastRemovedMessage.role).to.equal("user")
|
||||
|
||||
// Check if the next message after the range is an assistant message
|
||||
const nextMessage = messages[result[1] + 1]
|
||||
expect(nextMessage.role).to.equal("assistant")
|
||||
})
|
||||
|
||||
it("handles small message arrays", () => {
|
||||
const messages = createMessages(3)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
expect(result).to.deep.equal([1, 0])
|
||||
})
|
||||
|
||||
it("preserves the message structure when truncating", () => {
|
||||
const messages = createMessages(20)
|
||||
const result = contextManager.getNextTruncationRange(messages)
|
||||
|
||||
// Get messages after removing the range
|
||||
const effectiveMessages = [...messages.slice(0, result[0]), ...messages.slice(result[1] + 1)]
|
||||
|
||||
// Check first message and alternating pattern
|
||||
expect(effectiveMessages[0].role).to.equal("user")
|
||||
for (let i = 1; i < effectiveMessages.length; i++) {
|
||||
const expectedRole = i % 2 === 1 ? "assistant" : "user"
|
||||
expect(effectiveMessages[i].role).to.equal(expectedRole)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTruncatedMessages", () => {
|
||||
let contextManager: ContextManager
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = new ContextManager()
|
||||
})
|
||||
|
||||
it("returns original messages when no range is provided", () => {
|
||||
const messages = createMessages(3)
|
||||
|
||||
const result = contextManager.getTruncatedMessages(messages, undefined)
|
||||
expect(result).to.deep.equal(messages)
|
||||
})
|
||||
|
||||
it("correctly removes messages in the specified range", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 3]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[4])
|
||||
})
|
||||
|
||||
it("works with a range that starts at the first message after task", () => {
|
||||
const messages = createMessages(4)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(2)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
})
|
||||
|
||||
it("correctly handles removing a range while preserving alternation pattern", () => {
|
||||
const messages = createMessages(5)
|
||||
|
||||
const range: [number, number] = [1, 2]
|
||||
const result = contextManager.getTruncatedMessages(messages, range)
|
||||
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0]).to.deep.equal(messages[0])
|
||||
expect(result[1]).to.deep.equal(messages[3])
|
||||
expect(result[2]).to.deep.equal(messages[4])
|
||||
|
||||
expect(result[0].role).to.equal("user")
|
||||
expect(result[1].role).to.equal("assistant")
|
||||
expect(result[2].role).to.equal("user")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
|
||||
export class ContextManager {
|
||||
getNewContextMessagesAndMetadata(
|
||||
apiConversationHistory: Anthropic.Messages.MessageParam[],
|
||||
clineMessages: ClineMessage[],
|
||||
api: ApiHandler,
|
||||
conversationHistoryDeletedRange: [number, number] | undefined,
|
||||
previousApiReqIndex: number,
|
||||
) {
|
||||
let updatedConversationHistoryDeletedRange = false
|
||||
|
||||
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
|
||||
if (previousApiReqIndex >= 0) {
|
||||
const previousRequest = clineMessages[previousApiReqIndex]
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
|
||||
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
|
||||
// FIXME: truncating the conversation in a way that is optimal for prompt caching AND takes into account multi-context window complexity is something we need to improve
|
||||
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
|
||||
|
||||
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
|
||||
conversationHistoryDeletedRange = this.getNextTruncationRange(
|
||||
apiConversationHistory,
|
||||
conversationHistoryDeletedRange,
|
||||
keep,
|
||||
)
|
||||
|
||||
updatedConversationHistoryDeletedRange = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// conversationHistoryDeletedRange is updated only when we're close to hitting the context window, so we don't continuously break the prompt cache
|
||||
const truncatedConversationHistory = this.getTruncatedMessages(apiConversationHistory, conversationHistoryDeletedRange)
|
||||
|
||||
return {
|
||||
conversationHistoryDeletedRange: conversationHistoryDeletedRange,
|
||||
updatedConversationHistoryDeletedRange: updatedConversationHistoryDeletedRange,
|
||||
truncatedConversationHistory: truncatedConversationHistory,
|
||||
}
|
||||
}
|
||||
|
||||
public getNextTruncationRange(
|
||||
apiMessages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined,
|
||||
keep: "half" | "quarter",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of remaining user-assistant pairs
|
||||
// We first calculate half of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor((apiMessages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of remaining user-assistant pairs
|
||||
// We calculate 3/4ths of the messages then divide by 2 to get the number of pairs.
|
||||
// After flooring, we multiply by 2 to get the number of messages.
|
||||
// Note that this will also always be an even number.
|
||||
messagesToRemove = Math.floor(((apiMessages.length - startOfRest) * 3) / 4 / 2) * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (apiMessages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
public getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function checkIsOpenRouterContextWindowError(error: any): boolean {
|
||||
return error.code === 400 && error.message?.includes("context length")
|
||||
}
|
||||
|
||||
export function checkIsAnthropicContextWindowError(response: any): boolean {
|
||||
return (
|
||||
response?.error?.error?.type === "invalid_request_error" &&
|
||||
response?.error?.error?.message?.includes("prompt is too long")
|
||||
)
|
||||
}
|
||||
@@ -72,7 +72,10 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher
|
||||
}
|
||||
}
|
||||
|
||||
for (const mention of mentions) {
|
||||
// Filter out duplicate mentions while preserving order
|
||||
const uniqueMentions = Array.from(new Set(mentions))
|
||||
|
||||
for (const mention of uniqueMentions) {
|
||||
if (mention.startsWith("http")) {
|
||||
let result: string
|
||||
if (launchBrowserError) {
|
||||
|
||||
@@ -216,9 +216,13 @@ Usage:
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
Parameters:
|
||||
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
|
||||
Usage:
|
||||
<ask_followup_question>
|
||||
<question>Your question here</question>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</ask_followup_question>
|
||||
|
||||
## attempt_completion
|
||||
@@ -239,9 +243,13 @@ Your final result description here
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
|
||||
Parameters:
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible choice or path forward in the planning process. This can help guide the discussion and make it easier for the user to provide input on key decisions. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. Do NOT present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.
|
||||
Usage:
|
||||
<plan_mode_response>
|
||||
<response>Your response here</response>
|
||||
<options>
|
||||
Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</options>
|
||||
</plan_mode_response>
|
||||
|
||||
# Tool Use Examples
|
||||
@@ -813,7 +821,7 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
|
||||
## Important Considerations
|
||||
|
||||
- Using write_to_file requires providing the file’s complete final content.
|
||||
- Using write_to_file requires providing the file's complete final content.
|
||||
- If you only need to make small changes to an existing file, consider using replace_in_file instead to avoid unnecessarily rewriting the entire file.
|
||||
- While write_to_file should not be your default choice, don't hesitate to use it when the situation truly calls for it.
|
||||
|
||||
@@ -826,12 +834,12 @@ You have access to two tools for working with files: **write_to_file** and **rep
|
||||
## When to Use
|
||||
|
||||
- Small, localized changes like updating a few lines, function implementations, changing variable names, modifying a section of text, etc.
|
||||
- Targeted improvements where only specific portions of the file’s content needs to be altered.
|
||||
- Targeted improvements where only specific portions of the file's content needs to be altered.
|
||||
- Especially useful for long files where much of the file will remain unchanged.
|
||||
|
||||
## Advantages
|
||||
|
||||
- More efficient for minor edits, since you don’t need to supply the entire file content.
|
||||
- More efficient for minor edits, since you don't need to supply the entire file content.
|
||||
- Reduces the chance of errors that can occur when overwriting large files.
|
||||
|
||||
# Choosing the Appropriate Tool
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/*
|
||||
We can't implement a dynamically updating sliding window as it would break prompt cache
|
||||
every time. To maintain the benefits of caching, we need to keep conversation history
|
||||
static. This operation should be performed as infrequently as possible. If a user reaches
|
||||
a 200k context, we can assume that the first half is likely irrelevant to their current task.
|
||||
Therefore, this function should only be called when absolutely necessary to fit within
|
||||
context limits, not as a continuous process.
|
||||
*/
|
||||
// export function truncateHalfConversation(
|
||||
// messages: Anthropic.Messages.MessageParam[],
|
||||
// ): Anthropic.Messages.MessageParam[] {
|
||||
// // API expects messages to be in user-assistant order, and tool use messages must be followed by tool results. We need to maintain this structure while truncating.
|
||||
|
||||
// // Always keep the first Task message (this includes the project's file structure in environment_details)
|
||||
// const truncatedMessages = [messages[0]]
|
||||
|
||||
// // Remove half of user-assistant pairs
|
||||
// const messagesToRemove = Math.floor(messages.length / 4) * 2 // has to be even number
|
||||
|
||||
// const remainingMessages = messages.slice(messagesToRemove + 1) // has to start with assistant message since tool result cannot follow assistant message with no tool use
|
||||
// truncatedMessages.push(...remainingMessages)
|
||||
|
||||
// return truncatedMessages
|
||||
// }
|
||||
|
||||
/*
|
||||
getNextTruncationRange: Calculates the next range of messages to be "deleted"
|
||||
- Takes the full messages array and optional current deleted range
|
||||
- Always preserves the first message (task message)
|
||||
- Removes 1/2 of remaining messages (rounded down to even number) after current deleted range
|
||||
- Returns [startIndex, endIndex] representing inclusive range to delete
|
||||
|
||||
getTruncatedMessages: Constructs the truncated array using the deleted range
|
||||
- Takes full messages array and optional deleted range
|
||||
- Returns new array with messages in deleted range removed
|
||||
- Preserves order and structure of remaining messages
|
||||
|
||||
The range is represented as [startIndex, endIndex] where both indices are inclusive
|
||||
The functions maintain the original array integrity while allowing progressive truncation
|
||||
through the deletedRange parameter
|
||||
|
||||
Usage example:
|
||||
const messages = [user1, assistant1, user2, assistant2, user3, assistant3];
|
||||
let deletedRange = getNextTruncationRange(messages); // [1,2] (assistant1,user2)
|
||||
let truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant2, user3, assistant3]
|
||||
|
||||
deletedRange = getNextTruncationRange(messages, deletedRange); // [2,3] (assistant2,user3)
|
||||
truncated = getTruncatedMessages(messages, deletedRange);
|
||||
// [user1, assistant3]
|
||||
*/
|
||||
|
||||
export function getNextTruncationRange(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
currentDeletedRange: [number, number] | undefined = undefined,
|
||||
keep: "half" | "quarter" = "half",
|
||||
): [number, number] {
|
||||
// Since we always keep the first message, currentDeletedRange[0] will always be 1 (for now until we have a smarter truncation algorithm)
|
||||
const rangeStartIndex = 1
|
||||
const startOfRest = currentDeletedRange ? currentDeletedRange[1] + 1 : 1
|
||||
|
||||
let messagesToRemove: number
|
||||
if (keep === "half") {
|
||||
// Remove half of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 4) * 2 // Keep even number
|
||||
} else {
|
||||
// Remove 3/4 of user-assistant pairs
|
||||
messagesToRemove = Math.floor((messages.length - startOfRest) / 8) * 3 * 2
|
||||
}
|
||||
|
||||
let rangeEndIndex = startOfRest + messagesToRemove - 1
|
||||
|
||||
// Make sure the last message being removed is a user message, so that the next message after the initial task message is an assistant message. This preservers the user-assistant-user-assistant structure.
|
||||
// NOTE: anthropic format messages are always user-assistant-user-assistant, while openai format messages can have multiple user messages in a row (we use anthropic format throughout cline)
|
||||
if (messages[rangeEndIndex].role !== "user") {
|
||||
rangeEndIndex -= 1
|
||||
}
|
||||
|
||||
// this is an inclusive range that will be removed from the conversation history
|
||||
return [rangeStartIndex, rangeEndIndex]
|
||||
}
|
||||
|
||||
export function getTruncatedMessages(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
deletedRange: [number, number] | undefined,
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
if (!deletedRange) {
|
||||
return messages
|
||||
}
|
||||
|
||||
const [start, end] = deletedRange
|
||||
// the range is inclusive - both start and end indices and everything in between will be removed from the final result.
|
||||
// NOTE: if you try to console log these, don't forget that logging a reference to an array may not provide the same result as logging a slice() snapshot of that array at that exact moment. The following DOES in fact include the latest assistant message.
|
||||
return [...messages.slice(0, start), ...messages.slice(end + 1)]
|
||||
}
|
||||
+563
-271
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Registers development-only commands for task manipulation.
|
||||
* These are only activated in development mode.
|
||||
*/
|
||||
export function registerTaskCommands(context: vscode.ExtensionContext, provider: ClineProvider): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand("cline.dev.createTestTasks", async () => {
|
||||
const count = await vscode.window.showInputBox({
|
||||
title: "Test Tasks",
|
||||
prompt: "How many test tasks to create?",
|
||||
value: "10",
|
||||
})
|
||||
|
||||
if (!count) {
|
||||
return
|
||||
}
|
||||
|
||||
const tasksCount = parseInt(count)
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `Creating ${tasksCount} test tasks...`,
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
for (let i = 0; i < tasksCount; i++) {
|
||||
// Generate a timestamp to ensure unique IDs
|
||||
const timestamp = Date.now() + i
|
||||
const taskId = `${timestamp}`
|
||||
const taskDir = path.join(tasksDir, taskId)
|
||||
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
|
||||
// Generate a task prompt
|
||||
const taskName = getRandomTaskName(i)
|
||||
|
||||
// Create realistic message sequence
|
||||
const messages = createRealisticMessageSequence(timestamp, taskName, i)
|
||||
|
||||
// Create API conversation history file
|
||||
await fs.writeFile(
|
||||
path.join(taskDir, "api_conversation_history.json"),
|
||||
JSON.stringify(
|
||||
[
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `<task>\n${taskName}\n</task>` }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `I'll help you ${taskName.toLowerCase()}. Let me break this down into steps.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
// Create UI messages file with realistic message sequence
|
||||
await fs.writeFile(path.join(taskDir, "ui_messages.json"), JSON.stringify(messages, null, 2))
|
||||
|
||||
// Create history item to be shown in the HistoryView
|
||||
const historyItem: HistoryItem = {
|
||||
id: taskId,
|
||||
ts: timestamp,
|
||||
task: taskName,
|
||||
tokensIn: Math.floor(100 + Math.random() * 900), // Random token count from 100-1000
|
||||
tokensOut: Math.floor(200 + Math.random() * 1800), // Random token count from 200-2000
|
||||
cacheWrites: i % 3 === 0 ? Math.floor(50 + Math.random() * 150) : undefined, // Only add cache writes to every 3rd task
|
||||
cacheReads: i % 3 === 0 ? Math.floor(20 + Math.random() * 80) : undefined, // Only add cache reads to every 3rd task
|
||||
totalCost: Number((0.0001 + Math.random() * 0.01).toFixed(5)), // Random cost from $0.0001 to $0.0101
|
||||
size: 1024 * 1024, // 1MB
|
||||
}
|
||||
|
||||
// Update task history in global state
|
||||
await provider.updateTaskHistory(historyItem)
|
||||
|
||||
progress.report({ increment: 100 / tasksCount })
|
||||
}
|
||||
|
||||
// Update the UI to show the new tasks
|
||||
await provider.postStateToWebview()
|
||||
|
||||
vscode.window.showInformationMessage(`Created ${tasksCount} test tasks`)
|
||||
},
|
||||
)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a realistic sequence of messages that would occur in a typical task
|
||||
*/
|
||||
function createRealisticMessageSequence(baseTimestamp: number, taskPrompt: string, taskIndex: number): ClineMessage[] {
|
||||
// Use an incrementing timestamp to ensure messages appear in sequence
|
||||
let timestamp = baseTimestamp
|
||||
const getNextTimestamp = () => {
|
||||
timestamp += 1000 // Add 1 second between messages
|
||||
return timestamp
|
||||
}
|
||||
|
||||
// Variables to make different test tasks look unique
|
||||
const fileName = getRandomFileName(taskIndex)
|
||||
const commitHash = `commit${taskIndex}${Math.floor(Math.random() * 1000000).toString(16)}`
|
||||
|
||||
// Create a realistic message sequence
|
||||
const messages: ClineMessage[] = [
|
||||
// Initial task message - uses "say" with "text" which is the format used in Cline.ts
|
||||
{
|
||||
ts: baseTimestamp,
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: taskPrompt,
|
||||
},
|
||||
|
||||
// API request started
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "api_req_started",
|
||||
text: JSON.stringify({
|
||||
request: `<task>\n${taskPrompt}\n</task>`,
|
||||
tokensIn: Math.floor(100 + Math.random() * 200),
|
||||
tokensOut: Math.floor(300 + Math.random() * 500),
|
||||
}),
|
||||
},
|
||||
|
||||
// Reasoning message
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "reasoning",
|
||||
text: `I'll approach this task by breaking it down into manageable steps. First, I'll analyze the requirements, then create a plan, and finally implement the solution systematically.`,
|
||||
},
|
||||
|
||||
// Text response
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "text",
|
||||
text: `I'll help you with this task. Let me start by creating the necessary files and implementing the core functionality.`,
|
||||
},
|
||||
]
|
||||
|
||||
// Add task-specific messages based on index modulo to create variety
|
||||
const messageType = taskIndex % 5
|
||||
|
||||
if (messageType === 0 || messageType === 2) {
|
||||
// Tool use - file operations
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
tool: "newFileCreated",
|
||||
path: fileName,
|
||||
content: `// Sample code for ${taskPrompt}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (messageType === 1 || messageType === 3) {
|
||||
// Command execution
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
text: `ls -la`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "command_output",
|
||||
text: `total 24\ndrwxr-xr-x 3 user staff 96 Mar 10 12:34 .\ndrwxr-xr-x 8 user staff 256 Mar 10 12:30 ..\n-rw-r--r-- 1 user staff 158 Mar 10 12:34 ${fileName}`,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (messageType === 2 || messageType === 4) {
|
||||
// Browser actions
|
||||
messages.push(
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "ask",
|
||||
ask: "browser_action_launch",
|
||||
text: `https://example.com`,
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action_result",
|
||||
text: JSON.stringify({
|
||||
logs: "Page loaded successfully",
|
||||
screenshot:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
}),
|
||||
},
|
||||
{
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "browser_action",
|
||||
text: JSON.stringify({
|
||||
action: "close",
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Add checkpoint
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "checkpoint_created",
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
// Add completion result (all tasks end with this)
|
||||
messages.push({
|
||||
ts: getNextTimestamp(),
|
||||
type: "say",
|
||||
say: "completion_result",
|
||||
text: `I've completed the task to ${taskPrompt.toLowerCase()}. The implementation includes all the required functionality and meets the specifications. ${"x".repeat(1024 * 1024)}`, // 1MB file
|
||||
lastCheckpointHash: commitHash,
|
||||
})
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random task name for test data
|
||||
*/
|
||||
function getRandomTaskName(index: number): string {
|
||||
const tasks = [
|
||||
"Create a simple todo application",
|
||||
"Build a weather forecast widget",
|
||||
"Implement a markdown parser",
|
||||
"Design a responsive landing page",
|
||||
"Develop a currency converter",
|
||||
"Create a file upload component",
|
||||
"Build a data visualization dashboard",
|
||||
"Implement a search functionality",
|
||||
"Create a user authentication system",
|
||||
"Design a dark mode toggle",
|
||||
"Build a countdown timer",
|
||||
"Create a drag and drop interface",
|
||||
"Implement form validation",
|
||||
"Design a multi-step wizard",
|
||||
"Create a notification system",
|
||||
]
|
||||
|
||||
return tasks[index % tasks.length] + ` (Test ${index + 1})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a random file name for test data
|
||||
*/
|
||||
function getRandomFileName(index: number): string {
|
||||
const files = [
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"script.js",
|
||||
"app.jsx",
|
||||
"main.ts",
|
||||
"utils.py",
|
||||
"config.json",
|
||||
"server.js",
|
||||
"data.csv",
|
||||
"README.md",
|
||||
]
|
||||
|
||||
return files[index % files.length]
|
||||
}
|
||||
+175
-6
@@ -1,6 +1,6 @@
|
||||
// The module 'vscode' contains the VS Code extensibility API
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
@@ -32,6 +32,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const sidebarProvider = new ClineProvider(context, outputChannel)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
|
||||
webviewOptions: { retainContextWhenHidden: true },
|
||||
@@ -88,7 +90,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
tabProvider.resolveWebviewView(panel)
|
||||
|
||||
// Lock the editor group so clicking on files doesn't open them over the panel
|
||||
await delay(100)
|
||||
await setTimeoutPromise(100)
|
||||
await vscode.commands.executeCommand("workbench.action.lockEditorGroup")
|
||||
}
|
||||
|
||||
@@ -115,14 +117,20 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.accountLoginClicked", () => {
|
||||
vscode.commands.registerCommand("cline.accountButtonClicked", () => {
|
||||
sidebarProvider.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "accountLoginClicked",
|
||||
action: "accountButtonClicked",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.openDocumentation", () => {
|
||||
vscode.env.openExternal(vscode.Uri.parse("https://docs.cline.bot/"))
|
||||
}),
|
||||
)
|
||||
|
||||
/*
|
||||
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
|
||||
|
||||
@@ -162,10 +170,12 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
case "/auth": {
|
||||
const token = query.get("token")
|
||||
const state = query.get("state")
|
||||
const apiKey = query.get("apiKey")
|
||||
|
||||
console.log("Auth callback received:", {
|
||||
token: token,
|
||||
state: state,
|
||||
apiKey: apiKey,
|
||||
})
|
||||
|
||||
// Validate state parameter
|
||||
@@ -174,8 +184,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
return
|
||||
}
|
||||
|
||||
if (token) {
|
||||
await visibleProvider.handleAuthCallback(token)
|
||||
if (token && apiKey) {
|
||||
await visibleProvider.handleAuthCallback(token, apiKey)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -185,6 +195,165 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))
|
||||
|
||||
// Register size testing commands in development mode
|
||||
if (IS_DEV && IS_DEV === "true") {
|
||||
// Use dynamic import to avoid loading the module in production
|
||||
import("./dev/commands/tasks")
|
||||
.then((module) => {
|
||||
const devTaskCommands = module.registerTaskCommands(context, sidebarProvider)
|
||||
context.subscriptions.push(...devTaskCommands)
|
||||
Logger.log("Cline dev task commands registered")
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.log("Failed to register dev task commands: " + error)
|
||||
})
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addToChat", async (range?: vscode.Range, diagnostics?: vscode.Diagnostic[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use provided range if available, otherwise use current selection
|
||||
// (vscode command passes an argument in the first param by default, so we need to ensure it's a Range object)
|
||||
const textRange = range instanceof vscode.Range ? range : editor.selection
|
||||
const selectedText = editor.document.getText(textRange)
|
||||
|
||||
if (!selectedText) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the file path and language ID
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedCodeToChat(
|
||||
selectedText,
|
||||
filePath,
|
||||
languageId,
|
||||
Array.isArray(diagnostics) ? diagnostics : undefined,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.addTerminalOutputToChat", async () => {
|
||||
const terminal = vscode.window.activeTerminal
|
||||
if (!terminal) {
|
||||
return
|
||||
}
|
||||
|
||||
// Save current clipboard content
|
||||
const tempCopyBuffer = await vscode.env.clipboard.readText()
|
||||
|
||||
try {
|
||||
// Copy the *existing* terminal selection (without selecting all)
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
|
||||
|
||||
// Get copied content
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
|
||||
if (!terminalContents) {
|
||||
// No terminal content was copied (either nothing selected or some error)
|
||||
return
|
||||
}
|
||||
|
||||
// [Optional] Any additional logic to process multi-line content can remain here
|
||||
// For example:
|
||||
/*
|
||||
const lines = terminalContents.split("\n")
|
||||
const lastLine = lines.pop()?.trim()
|
||||
if (lastLine) {
|
||||
let i = lines.length - 1
|
||||
while (i >= 0 && !lines[i].trim().startsWith(lastLine)) {
|
||||
i--
|
||||
}
|
||||
terminalContents = lines.slice(Math.max(i, 0)).join("\n")
|
||||
}
|
||||
*/
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
} catch (error) {
|
||||
// Ensure clipboard is restored even if an error occurs
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
console.error("Error getting terminal contents:", error)
|
||||
vscode.window.showErrorMessage("Failed to get terminal contents")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
// Register code action provider
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCodeActionsProvider(
|
||||
"*",
|
||||
new (class implements vscode.CodeActionProvider {
|
||||
public static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]
|
||||
|
||||
provideCodeActions(
|
||||
document: vscode.TextDocument,
|
||||
range: vscode.Range,
|
||||
context: vscode.CodeActionContext,
|
||||
): vscode.CodeAction[] {
|
||||
// Expand range to include surrounding 3 lines
|
||||
const expandedRange = new vscode.Range(
|
||||
Math.max(0, range.start.line - 3),
|
||||
0,
|
||||
Math.min(document.lineCount - 1, range.end.line + 3),
|
||||
document.lineAt(Math.min(document.lineCount - 1, range.end.line + 3)).text.length,
|
||||
)
|
||||
|
||||
const addAction = new vscode.CodeAction("Add to Cline", vscode.CodeActionKind.QuickFix)
|
||||
addAction.command = {
|
||||
command: "cline.addToChat",
|
||||
title: "Add to Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
const fixAction = new vscode.CodeAction("Fix with Cline", vscode.CodeActionKind.QuickFix)
|
||||
fixAction.command = {
|
||||
command: "cline.fixWithCline",
|
||||
title: "Fix with Cline",
|
||||
arguments: [expandedRange, context.diagnostics],
|
||||
}
|
||||
|
||||
// Only show actions when there are errors
|
||||
if (context.diagnostics.length > 0) {
|
||||
return [addAction, fixAction]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
})(),
|
||||
{
|
||||
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Register the command handler
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.fixWithCline", async (range: vscode.Range, diagnostics: any[]) => {
|
||||
const editor = vscode.window.activeTextEditor
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedText = editor.document.getText(range)
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider with diagnostics
|
||||
await sidebarProvider.fixWithCline(selectedText, filePath, languageId, diagnostics)
|
||||
}),
|
||||
)
|
||||
|
||||
return createClineAPI(outputChannel, sidebarProvider)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// NOTE: These are here temporarily until we find a better home for them
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
}
|
||||
@@ -75,8 +75,10 @@ describe("Checkpoint Commit Operations", () => {
|
||||
expect(sortedDiffs[1].relativePath).to.equal("src/test2.txt")
|
||||
|
||||
// Verify file contents
|
||||
expect(sortedDiffs[0].before).to.equal("file1 initial\nfile2 initial\n")
|
||||
expect(sortedDiffs[0].after).to.equal("file1 modified\nfile2 modified\n")
|
||||
expect(sortedDiffs[0].before).to.equal("file1 initial\n")
|
||||
expect(sortedDiffs[0].after).to.equal("file1 modified\n")
|
||||
expect(sortedDiffs[1].before).to.equal("file2 initial\n")
|
||||
expect(sortedDiffs[1].after).to.equal("file2 modified\n")
|
||||
})
|
||||
|
||||
it("should create commit when files are deleted", async () => {
|
||||
|
||||
@@ -30,16 +30,6 @@ import { GIT_DISABLED_SUFFIX } from "./CheckpointGitOperations"
|
||||
* and organized checkpoint history.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface representing the result of a file exclusion check
|
||||
*/
|
||||
interface ExclusionResult {
|
||||
/** Whether the file should be excluded */
|
||||
excluded: boolean
|
||||
/** Optional reason for exclusion */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default list of file and directory patterns to exclude from checkpoints.
|
||||
* Combines built-in patterns with workspace-specific LFS patterns.
|
||||
@@ -129,7 +119,7 @@ function getMediaFilePatterns(): string[] {
|
||||
"*.webp",
|
||||
"*.tiff",
|
||||
"*.tif",
|
||||
"*.svg",
|
||||
// "*.svg",
|
||||
"*.raw",
|
||||
"*.heic",
|
||||
"*.avif",
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
|
||||
import fs from "fs/promises"
|
||||
import { globby } from "globby"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import * as vscode from "vscode"
|
||||
import { getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
|
||||
interface StorageProvider {
|
||||
context: {
|
||||
globalStorageUri: { fsPath: string }
|
||||
}
|
||||
}
|
||||
import { getLfsPatterns, writeExcludesFile } from "./CheckpointExclusions"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
|
||||
interface CheckpointAddResult {
|
||||
success: boolean
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,25 +19,20 @@ interface CheckpointAddResult {
|
||||
* - Git repository initialization and configuration
|
||||
* - Git settings management (user, LFS, etc.)
|
||||
* - Worktree configuration and management
|
||||
* - Task-specific branch management (creation, switching, deletion)
|
||||
* - Handling of both legacy and branch-per-task checkpoint structures
|
||||
* - Managing nested git repositories during checkpoint operations
|
||||
* - File staging and checkpoint creation
|
||||
* - Shadow git repository maintenance and cleanup
|
||||
*/
|
||||
export class GitOperations {
|
||||
private cwd: string
|
||||
private isLegacyCheckpoint: boolean
|
||||
|
||||
/**
|
||||
* Creates a new GitOperations instance.
|
||||
*
|
||||
* @param cwd - The current working directory for git operations
|
||||
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
|
||||
*/
|
||||
constructor(cwd: string, isLegacyCheckpoint: boolean) {
|
||||
constructor(cwd: string) {
|
||||
this.cwd = cwd
|
||||
this.isLegacyCheckpoint = isLegacyCheckpoint
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,12 +44,9 @@ export class GitOperations {
|
||||
* - Creates/verifies shadow git repository
|
||||
* - Configures git settings (user, LFS, etc.)
|
||||
* - Sets up worktree to point to workspace
|
||||
* - Creates initial empty commit
|
||||
* - Handles both legacy and branch-per-task checkpoint structures
|
||||
*
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @param cwd - The current working directory for git operations
|
||||
* @param isLegacyCheckpoint - Whether this is operating in legacy checkpoint mode
|
||||
* @returns Promise<string> Path to the initialized .git directory
|
||||
* @throws Error if:
|
||||
* - Worktree verification fails for existing repository
|
||||
@@ -70,8 +54,8 @@ export class GitOperations {
|
||||
* - Unable to create initial commit
|
||||
* - LFS pattern setup fails
|
||||
*/
|
||||
public static async initShadowGit(gitPath: string, cwd: string, isLegacyCheckpoint: boolean): Promise<string> {
|
||||
console.info(`Initializing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git`)
|
||||
public async initShadowGit(gitPath: string, cwd: string, taskId: string): Promise<string> {
|
||||
console.info(`Initializing shadow git`)
|
||||
|
||||
// If repo exists, just verify worktree
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
@@ -80,33 +64,41 @@ export class GitOperations {
|
||||
if (worktree.value !== cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree.value)
|
||||
}
|
||||
console.warn(`Using existing ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git at ${gitPath}`)
|
||||
console.warn(`Using existing shadow git at ${gitPath}`)
|
||||
|
||||
// shadow git repo already exists, but update the excludes just in case
|
||||
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
|
||||
|
||||
return gitPath
|
||||
}
|
||||
|
||||
// Initialize new repo
|
||||
const startTime = performance.now()
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
console.warn(`Creating new ${isLegacyCheckpoint ? "legacy" : "branch-per-task"} shadow git in ${checkpointsDir}`)
|
||||
console.warn(`Creating new shadow git in ${checkpointsDir}`)
|
||||
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
// Configure repo
|
||||
// Configure repo with git settings
|
||||
await git.addConfig("core.worktree", cwd)
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "checkpoint@cline.bot")
|
||||
await git.addConfig("core.quotePath", "false")
|
||||
await git.addConfig("core.precomposeunicode", "true")
|
||||
|
||||
// Set up LFS patterns
|
||||
const lfsPatterns = await getLfsPatterns(cwd)
|
||||
await writeExcludesFile(gitPath, lfsPatterns)
|
||||
|
||||
await this.addCheckpointFiles(git)
|
||||
|
||||
// Initial commit only on first repo creation
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
console.warn(`${isLegacyCheckpoint ? "Legacy" : "New"} shadow git initialization completed`)
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
console.warn(`Shadow git initialization completed`)
|
||||
|
||||
return gitPath
|
||||
}
|
||||
@@ -131,202 +123,6 @@ export class GitOperations {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a shadow Git repository exists for the given task and workspace.
|
||||
* Checks both legacy checkpoint paths (tasks/{taskId}/checkpoints/.git) and
|
||||
* branch-per-task paths (checkpoints/{workspaceHash}/.git).
|
||||
*
|
||||
* @param taskId - The ID of the task whose shadow git to check
|
||||
* @param provider - The ClineProvider instance for accessing VS Code functionality
|
||||
* @returns Promise<boolean> True if either a legacy or branch-per-task shadow git exists, false otherwise
|
||||
*/
|
||||
public static async doesShadowGitExist(taskId: string, provider?: StorageProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check legacy checkpoint path to see if this is a legacy task
|
||||
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
if (await fileExistsAtPath(legacyGitPath)) {
|
||||
console.info("Found legacy shadow git")
|
||||
return true
|
||||
}
|
||||
|
||||
// Check branch-per-task path for newer tasks
|
||||
const workingDir = await getWorkingDirectory()
|
||||
const cwdHash = hashWorkingDir(workingDir)
|
||||
const gitPath = path.join(globalStoragePath, "checkpoints", cwdHash, ".git")
|
||||
const exists = await fileExistsAtPath(gitPath)
|
||||
if (exists) {
|
||||
console.info("Found branch-per-task shadow git")
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a branch in the git repository, handling cases where the branch is currently checked out.
|
||||
* If the branch to be deleted is currently checked out, the method will:
|
||||
* 1. Save the current worktree configuration
|
||||
* 2. Temporarily unset the worktree to prevent workspace modifications
|
||||
* 3. Force switch to master/main branch
|
||||
* 4. Delete the target branch
|
||||
* 5. Restore the worktree configuration
|
||||
*
|
||||
* @param git - SimpleGit instance to use for operations
|
||||
* @param branchName - Name of the branch to delete
|
||||
* @param checkpointsDir - Directory containing the git repository
|
||||
* @throws Error if:
|
||||
* - Branch deletion fails
|
||||
* - Unable to switch to master/main branch after 3 retries
|
||||
* - Git operations fail during the process
|
||||
*/
|
||||
public static async deleteBranchForGit(git: SimpleGit, branchName: string, checkpointsDir: string): Promise<void> {
|
||||
// Check if branch exists
|
||||
const branches = await git.branchLocal()
|
||||
if (!branches.all.includes(branchName)) {
|
||||
console.error(`Task branch ${branchName} does not exist, nothing to delete`)
|
||||
return // Branch doesn't exist, nothing to delete
|
||||
}
|
||||
|
||||
// First, if we're on the branch to be deleted, switch to master/main
|
||||
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
console.info(`Current branch: ${currentBranch}, target branch to delete: ${branchName}`)
|
||||
|
||||
if (currentBranch === branchName) {
|
||||
console.debug("Currently on branch to be deleted, switching to master/main first")
|
||||
// Save the current worktree config
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
console.debug(`Saved current worktree config: ${worktree.value}`)
|
||||
|
||||
try {
|
||||
// Temporarily unset worktree to prevent workspace modifications
|
||||
console.debug("Temporarily unsetting worktree config")
|
||||
await git.raw(["config", "--unset", "core.worktree"])
|
||||
|
||||
// Force discard all changes
|
||||
console.debug("Discarding all changes")
|
||||
await git.reset(["--hard"])
|
||||
await git.clean("f", ["-d"]) // Clean mode 'f' for force, -d for directories
|
||||
|
||||
// Determine default branch (master or main)
|
||||
const defaultBranch = branches.all.includes("main") ? "main" : "master"
|
||||
console.debug(`Using ${defaultBranch} as default branch`)
|
||||
|
||||
// Switch to default branch and delete branch
|
||||
console.debug(`Attempting to force switch to ${defaultBranch} branch`)
|
||||
await git.checkout([defaultBranch, "--force"])
|
||||
|
||||
// Verify the switch completed
|
||||
let retries = 3
|
||||
while (retries > 0) {
|
||||
const newBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
console.debug(`Verifying branch switch - current branch: ${newBranch}, attempts left: ${retries}`)
|
||||
if (newBranch === defaultBranch) {
|
||||
console.debug(`Successfully switched to ${defaultBranch} branch`)
|
||||
break
|
||||
}
|
||||
retries--
|
||||
if (retries === 0) {
|
||||
throw new Error(`Failed to switch to ${defaultBranch} branch`)
|
||||
}
|
||||
}
|
||||
|
||||
console.info(`Deleting branch: ${branchName}`)
|
||||
await git.raw(["branch", "-D", branchName])
|
||||
console.debug(`Successfully deleted branch: ${branchName}`)
|
||||
} finally {
|
||||
// Restore the worktree config
|
||||
if (worktree.value) {
|
||||
console.debug(`Restoring worktree config to: ${worktree.value}`)
|
||||
await git.addConfig("core.worktree", worktree.value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If we're not on the branch, we can safely delete it
|
||||
console.info(`Directly deleting branch ${branchName} since we're not on it`)
|
||||
await git.raw(["branch", "-D", branchName])
|
||||
console.debug(`Successfully deleted branch: ${branchName}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static method to delete a task's branch using stored workspace path.
|
||||
* Handles both branch-per-task and legacy checkpoint formats:
|
||||
* 1. First attempts to delete branch-per-task checkpoint if it exists
|
||||
* 2. Falls back to deleting legacy checkpoint directory if found
|
||||
*
|
||||
* @param taskId - The ID of the task whose branch should be deleted
|
||||
* @param historyItem - The history item containing the shadow git config
|
||||
* @param globalStoragePath - Path to VS Code's global storage
|
||||
* @throws Error if:
|
||||
* - Global storage path is invalid
|
||||
* - Branch deletion fails
|
||||
* - Legacy checkpoint directory deletion fails
|
||||
*/
|
||||
public static async deleteTaskBranchStatic(
|
||||
taskId: string,
|
||||
historyItem: HistoryItem,
|
||||
globalStoragePath: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.debug("Starting static task branch deletion process...")
|
||||
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
|
||||
// First try to handle branch-per-task checkpoint
|
||||
let workingDir: string
|
||||
if (historyItem.shadowGitConfigWorkTree) {
|
||||
workingDir = historyItem.shadowGitConfigWorkTree
|
||||
} else {
|
||||
// Try to determine working directory from current state
|
||||
workingDir = await getWorkingDirectory()
|
||||
}
|
||||
|
||||
const cwdHash = hashWorkingDir(workingDir)
|
||||
const checkpointsDir = path.join(globalStoragePath, "checkpoints", cwdHash)
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
console.debug(`Found branch-per-task git repository at ${gitPath}`)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const branchName = `task-${taskId}`
|
||||
|
||||
// Check if the branch exists
|
||||
const branches = await git.branchLocal()
|
||||
if (branches.all.includes(branchName)) {
|
||||
console.info(`Found branch ${branchName} to delete`)
|
||||
await GitOperations.deleteBranchForGit(git, branchName, checkpointsDir)
|
||||
return
|
||||
}
|
||||
console.warn(`Branch ${branchName} not found in branch-per-task repository`)
|
||||
}
|
||||
|
||||
// Only check legacy checkpoint if we didn't find/delete a branch-per-task branch
|
||||
const legacyCheckpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
|
||||
const legacyGitPath = path.join(legacyCheckpointsDir, ".git")
|
||||
|
||||
if (await fileExistsAtPath(legacyGitPath)) {
|
||||
console.info("Found legacy checkpoint, deleting directory")
|
||||
try {
|
||||
await fs.rm(legacyCheckpointsDir, { recursive: true, force: true })
|
||||
console.debug("Successfully deleted legacy checkpoint directory")
|
||||
return
|
||||
} catch (error) {
|
||||
console.error("Failed to delete legacy checkpoint directory:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
console.info("No checkpoints found to delete")
|
||||
} catch (error) {
|
||||
console.error("Failed to delete task branch:", error)
|
||||
throw new Error(`Failed to delete task branch: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's
|
||||
* requirement of using submodules for nested repos.
|
||||
@@ -338,29 +134,17 @@ export class GitOperations {
|
||||
* @param disable - If true, adds suffix to disable nested git repos. If false, removes suffix to re-enable them.
|
||||
* @throws Error if renaming any .git directory fails
|
||||
*/
|
||||
public async renameNestedGitRepos(disable: boolean): Promise<void> {
|
||||
// Find all .git directories that are not at the root level using VS Code API
|
||||
const gitFiles = await vscode.workspace.findFiles(
|
||||
new vscode.RelativePattern(this.cwd, "**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX)),
|
||||
new vscode.RelativePattern(this.cwd, ".git/**"), // Exclude root .git
|
||||
)
|
||||
public async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
|
||||
// Filter to only include directories
|
||||
const gitPaths: string[] = []
|
||||
for (const file of gitFiles) {
|
||||
const relativePath = path.relative(this.cwd, file.fsPath)
|
||||
try {
|
||||
const stats = await fs.stat(path.join(this.cwd, relativePath))
|
||||
if (stats.isDirectory()) {
|
||||
gitPaths.push(relativePath)
|
||||
}
|
||||
} catch {
|
||||
// Skip if stat fails
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// For each nested .git directory, rename it based on the disable flag
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
@@ -372,52 +156,13 @@ export class GitOperations {
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.info(`${disable ? "Disabled" : "Enabled"} nested git repo ${gitPath}`)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to or creates a task-specific branch in the shadow Git repository.
|
||||
* For legacy checkpoints, this is a no-op since they use separate repositories.
|
||||
* For branch-per-task checkpoints, this ensures we're on the correct task branch before operations.
|
||||
*
|
||||
* The method performs the following:
|
||||
* 1. Gets the shadow git path and initializes simple-git
|
||||
* 2. Constructs the branch name using the task ID
|
||||
* 3. Checks if the branch exists:
|
||||
* - If not, creates a new branch
|
||||
* - If yes, switches to the existing branch
|
||||
* 4. Verifies the branch switch completed successfully
|
||||
*
|
||||
* Branch naming convention:
|
||||
* task-{taskId}
|
||||
*
|
||||
* @param taskId - The ID of the task whose branch to switch to
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @returns Promise<void>
|
||||
* @throws Error if branch operations fail or git commands error
|
||||
*/
|
||||
public async switchToTaskBranch(taskId: string, gitPath: string): Promise<void> {
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const branchName = `task-${taskId}`
|
||||
|
||||
// Create new task-specific branch, or switch to one if it already exists.
|
||||
const branches = await git.branchLocal()
|
||||
if (!branches.all.includes(branchName)) {
|
||||
console.info(`Creating new task branch: ${branchName}`)
|
||||
await git.checkoutLocalBranch(branchName)
|
||||
} else {
|
||||
console.info(`Switching to existing task branch: ${branchName}`)
|
||||
await git.checkout(branchName)
|
||||
}
|
||||
|
||||
const currentBranch = await git.revparse(["--abbrev-ref", "HEAD"])
|
||||
console.info(`Current Checkpoint branch after switch: ${currentBranch}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds files to the shadow git repository while handling nested git repos.
|
||||
* Uses git commands to list files and stages them for commit.
|
||||
@@ -431,7 +176,6 @@ export class GitOperations {
|
||||
* 5. Re-enables nested git repos
|
||||
*
|
||||
* @param git - SimpleGit instance configured for the shadow git repo
|
||||
* @param gitPath - Path to the .git directory
|
||||
* @returns Promise<CheckpointAddResult> Object containing success status, message, and file count
|
||||
* @throws Error if:
|
||||
* - File operations fail
|
||||
@@ -439,33 +183,18 @@ export class GitOperations {
|
||||
* - LFS pattern updates fail
|
||||
* - Nested git repo handling fails
|
||||
*/
|
||||
public async addCheckpointFiles(git: SimpleGit, gitPath: string): Promise<CheckpointAddResult> {
|
||||
public async addCheckpointFiles(git: SimpleGit): Promise<CheckpointAddResult> {
|
||||
const startTime = performance.now()
|
||||
try {
|
||||
// Update exclude patterns before each commit
|
||||
await writeExcludesFile(gitPath, await getLfsPatterns(this.cwd))
|
||||
await this.renameNestedGitRepos(true)
|
||||
//console.info("Starting checkpoint add operation...")
|
||||
|
||||
// Get list of all files git would track (respects .gitignore)
|
||||
await git.addConfig("core.quotePath", "false")
|
||||
await git.addConfig("core.precomposeunicode", "true")
|
||||
const gitFiles = (await git.raw(["ls-files", "--others", "--exclude-standard", "--cached"]))
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
|
||||
// Add filtered files
|
||||
if (gitFiles.length === 0) {
|
||||
console.info("No files to add to checkpoint")
|
||||
return { success: true, fileCount: 0 }
|
||||
}
|
||||
console.info("Starting checkpoint add operation...")
|
||||
|
||||
try {
|
||||
console.info(`Adding ${gitFiles.length} files to checkpoint`)
|
||||
await git.addConfig("core.quotePath", "false")
|
||||
await git.addConfig("core.precomposeunicode", "true")
|
||||
await git.add(gitFiles)
|
||||
console.info("Checkpoint add operation completed successfully")
|
||||
return { success: true, fileCount: gitFiles.length }
|
||||
await git.add(".")
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
console.debug(`Checkpoint add operation completed in ${durationMs}ms`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Checkpoint add operation failed:", error)
|
||||
throw error
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
|
||||
/**
|
||||
* Cleans up legacy checkpoints from task folders.
|
||||
* This is a one-time operation that runs when the extension is updated to use the new checkpoint system.
|
||||
*
|
||||
* @param globalStoragePath - Path to the extension's global storage
|
||||
* @param outputChannel - VSCode output channel for logging
|
||||
*/
|
||||
export async function cleanupLegacyCheckpoints(globalStoragePath: string, outputChannel: vscode.OutputChannel): Promise<void> {
|
||||
try {
|
||||
outputChannel.appendLine("Checking for legacy checkpoints...")
|
||||
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
// Check if tasks directory exists
|
||||
if (!(await fileExistsAtPath(tasksDir))) {
|
||||
return // No tasks directory, nothing to clean up
|
||||
}
|
||||
|
||||
// Get all task folders
|
||||
const taskFolders = await fs.readdir(tasksDir)
|
||||
if (taskFolders.length === 0) {
|
||||
return // No task folders, nothing to clean up
|
||||
}
|
||||
|
||||
// Get stats for each folder to sort by creation time
|
||||
const folderStats = await Promise.all(
|
||||
taskFolders.map(async (folder) => {
|
||||
const folderPath = path.join(tasksDir, folder)
|
||||
const stats = await fs.stat(folderPath)
|
||||
return { folder, path: folderPath, stats }
|
||||
}),
|
||||
)
|
||||
|
||||
// Sort by creation time, newest first
|
||||
folderStats.sort((a, b) => b.stats.birthtimeMs - a.stats.birthtimeMs)
|
||||
|
||||
// Check if the most recent task folder has a checkpoints directory
|
||||
if (folderStats.length > 0) {
|
||||
const mostRecentFolder = folderStats[0]
|
||||
const checkpointsDir = path.join(mostRecentFolder.path, "checkpoints")
|
||||
|
||||
if (await fileExistsAtPath(checkpointsDir)) {
|
||||
outputChannel.appendLine("Found legacy checkpoints directory, cleaning up...")
|
||||
|
||||
// Legacy checkpoints found, delete checkpoints directories in all task folders
|
||||
for (const folder of folderStats) {
|
||||
const folderCheckpointsDir = path.join(folder.path, "checkpoints")
|
||||
if (await fileExistsAtPath(folderCheckpointsDir)) {
|
||||
outputChannel.appendLine(`Deleting legacy checkpoints in ${folder.folder}`)
|
||||
try {
|
||||
await fs.rm(folderCheckpointsDir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
// Ignore error if directory removal fails
|
||||
outputChannel.appendLine(`Warning: Failed to delete checkpoints in ${folder.folder}, continuing...`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputChannel.appendLine("Legacy checkpoints cleanup completed")
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
outputChannel.appendLine(`Error cleaning up legacy checkpoints: ${error}`)
|
||||
console.error("Error cleaning up legacy checkpoints:", error)
|
||||
}
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { GitOperations } from "./CheckpointGitOperations"
|
||||
import { getShadowGitPath, hashWorkingDir, getWorkingDirectory, detectLegacyCheckpoint } from "./CheckpointUtils"
|
||||
|
||||
/**
|
||||
* CheckpointTracker Module
|
||||
*
|
||||
* Core implementation of Cline's Checkpoints system that provides version control
|
||||
* capabilities without interfering with the user's main Git repository. Key features:
|
||||
*
|
||||
* Shadow Git Repository:
|
||||
* - Creates and manages an isolated Git repository for tracking checkpoints
|
||||
* - Handles nested Git repositories by temporarily disabling them
|
||||
* - Configures Git settings automatically (identity, LFS, etc.)
|
||||
*
|
||||
* File Management:
|
||||
* - Integrates with CheckpointExclusions for file filtering
|
||||
* - Handles workspace validation and path resolution
|
||||
* - Manages Git worktree configuration
|
||||
*
|
||||
* Checkpoint Operations:
|
||||
* - Creates checkpoints (commits) of the current state
|
||||
* - Provides diff capabilities between checkpoints
|
||||
* - Supports resetting to previous checkpoints
|
||||
*
|
||||
* Safety Features:
|
||||
* - Prevents usage in sensitive directories (home, desktop, etc.)
|
||||
* - Validates workspace configuration
|
||||
* - Handles cleanup and resource disposal
|
||||
*
|
||||
* Checkpoint Architecture:
|
||||
* - Uses a branch-per-task model to consolidate shadow git repositories
|
||||
* - Each task gets its own branch within a single shadow git per workspace
|
||||
* - Maintains backward compatibility with legacy checkpoint structure
|
||||
* - Automatically cleans up by deleting task branches when tasks are removed
|
||||
*/
|
||||
|
||||
class CheckpointTracker {
|
||||
private globalStoragePath: string
|
||||
private taskId: string
|
||||
private cwd: string
|
||||
private cwdHash: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
private lastCheckpointHash?: string
|
||||
private isLegacyCheckpoint: boolean = false
|
||||
private gitOperations: GitOperations
|
||||
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
|
||||
* The constructor is private - use the static create() method to instantiate.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task being tracked
|
||||
* @param cwd - The current working directory to track files in
|
||||
* @param cwdHash - Hash of the working directory path for shadow git organization
|
||||
*/
|
||||
private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) {
|
||||
this.globalStoragePath = globalStoragePath
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
this.cwdHash = cwdHash
|
||||
this.gitOperations = new GitOperations(cwd, false) // Initialize with non-legacy mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance for tracking changes in a task.
|
||||
* Handles initialization of the shadow git repository and branch setup.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task to track
|
||||
* @param globalStoragePath - the globalStorage path
|
||||
* @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled
|
||||
* @throws Error if:
|
||||
* - globalStoragePath is not supplied
|
||||
* - Git is not installed
|
||||
* - Working directory is invalid or in a protected location
|
||||
* - Shadow git initialization fails
|
||||
*
|
||||
* Key operations:
|
||||
* - Validates git installation and settings
|
||||
* - Creates/initializes shadow git repository
|
||||
* - Detects and handles legacy checkpoint structure
|
||||
* - Sets up task-specific branch for new checkpoints
|
||||
*
|
||||
* Configuration:
|
||||
* - Respects 'cline.enableCheckpoints' VS Code setting
|
||||
* - Uses branch-per-task architecture for new checkpoints
|
||||
* - Maintains backwards compatibility with legacy structure
|
||||
*/
|
||||
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage path is required to create a checkpoint tracker")
|
||||
}
|
||||
try {
|
||||
console.info(`Creating new CheckpointTracker for task ${taskId}`)
|
||||
|
||||
// Check if checkpoints are disabled in VS Code settings
|
||||
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
|
||||
if (!enableCheckpoints) {
|
||||
return undefined // Don't create tracker when disabled
|
||||
}
|
||||
|
||||
// Check if git is installed by attempting to get version
|
||||
try {
|
||||
await simpleGit().version()
|
||||
} catch (error) {
|
||||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
const workingDir = await getWorkingDirectory()
|
||||
const cwdHash = hashWorkingDir(workingDir)
|
||||
console.debug(`Repository ID (cwdHash): ${cwdHash}`)
|
||||
|
||||
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
|
||||
|
||||
// Check if this is a legacy task
|
||||
newTracker.isLegacyCheckpoint = await detectLegacyCheckpoint(newTracker.globalStoragePath, newTracker.taskId)
|
||||
if (newTracker.isLegacyCheckpoint) {
|
||||
console.debug("Using legacy checkpoint path structure")
|
||||
const gitPath = await getShadowGitPath(
|
||||
newTracker.globalStoragePath,
|
||||
newTracker.taskId,
|
||||
newTracker.cwdHash,
|
||||
newTracker.isLegacyCheckpoint,
|
||||
)
|
||||
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
|
||||
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
|
||||
return newTracker
|
||||
}
|
||||
|
||||
// Branch-per-task structure
|
||||
const gitPath = await getShadowGitPath(
|
||||
newTracker.globalStoragePath,
|
||||
newTracker.taskId,
|
||||
newTracker.cwdHash,
|
||||
newTracker.isLegacyCheckpoint,
|
||||
)
|
||||
await GitOperations.initShadowGit(gitPath, workingDir, newTracker.isLegacyCheckpoint)
|
||||
await newTracker.gitOperations.switchToTaskBranch(newTracker.taskId, gitPath)
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new checkpoint commit in the shadow git repository.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Creates commit with checkpoint files in shadow git repo
|
||||
* - Handles both legacy and branch-per-task checkpoint structures
|
||||
* - For new tasks, switches to task-specific branch first
|
||||
* - Caches the created commit hash
|
||||
*
|
||||
* Commit structure:
|
||||
* - Legacy: Simple "checkpoint" message
|
||||
* - Branch-per-task: "checkpoint-{cwdHash}-{taskId}"
|
||||
* - Always allows empty commits
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - For new checkpoints, requires task branch setup
|
||||
* - Uses addCheckpointFiles to stage changes
|
||||
*
|
||||
* @returns Promise<string | undefined> The created commit hash, or undefined if:
|
||||
* - Shadow git access fails
|
||||
* - Branch switch fails
|
||||
* - Staging files fails
|
||||
* - Commit creation fails
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Switch branches
|
||||
* - Stage or commit files
|
||||
*/
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
console.info(`Using shadow git at: ${gitPath}`)
|
||||
|
||||
// Disable nested git repos before any operations
|
||||
await this.gitOperations.renameNestedGitRepos(true)
|
||||
|
||||
try {
|
||||
if (!this.isLegacyCheckpoint) {
|
||||
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
|
||||
}
|
||||
await this.gitOperations.addCheckpointFiles(git, gitPath)
|
||||
|
||||
const commitMessage = this.isLegacyCheckpoint ? "checkpoint" : "checkpoint-" + this.cwdHash + "-" + this.taskId
|
||||
|
||||
console.info(
|
||||
`Creating ${this.isLegacyCheckpoint ? "legacy" : "new"} checkpoint commit with message: ${commitMessage}`,
|
||||
)
|
||||
const result = await git.commit(commitMessage, {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
console.warn(`Checkpoint commit created.`)
|
||||
return commitHash
|
||||
} finally {
|
||||
// Always re-enable nested git repos
|
||||
await this.gitOperations.renameNestedGitRepos(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", {
|
||||
taskId: this.taskId,
|
||||
error,
|
||||
isLegacyCheckpoint: this.isLegacyCheckpoint,
|
||||
})
|
||||
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the worktree path from the shadow git configuration.
|
||||
* The worktree path indicates where the shadow git repository is tracking files,
|
||||
* which should match the current workspace directory.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
|
||||
* - Returns cached value if available
|
||||
* - Reads git config if no cached value exists
|
||||
* - Handles both legacy and new checkpoint structures
|
||||
*
|
||||
* Configuration read:
|
||||
* - Uses simple-git to read core.worktree config
|
||||
* - Operates on shadow git at path from getShadowGitPath()
|
||||
*
|
||||
* @returns Promise<string | undefined> The configured worktree path, or undefined if:
|
||||
* - Shadow git repository doesn't exist
|
||||
* - Config read fails
|
||||
* - No worktree is configured
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Read git configuration
|
||||
*/
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
|
||||
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the shadow git repository's HEAD to a specific checkpoint commit.
|
||||
* This will discard all changes after the target commit and restore the
|
||||
* working directory to that checkpoint's state.
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - For new checkpoints, requires task branch setup
|
||||
* - Must be called with a valid commit hash from this task's history
|
||||
*
|
||||
* @param commitHash - The hash of the checkpoint commit to reset to
|
||||
* @returns Promise<void> Resolves when reset is complete
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Switch to task branch
|
||||
* - Reset to target commit
|
||||
*/
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
console.info(`Resetting to checkpoint: ${commitHash}`)
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
console.debug(`Using shadow git at: ${gitPath}`)
|
||||
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array describing changed files between one commit and either:
|
||||
* - another commit, or
|
||||
* - the current working directory (including uncommitted changes).
|
||||
*
|
||||
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
|
||||
* If you want truly untracked files to appear, `git add` them first.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash?: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
before: string
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash, this.isLegacyCheckpoint)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
if (!this.isLegacyCheckpoint) {
|
||||
await this.gitOperations.switchToTaskBranch(this.taskId, gitPath)
|
||||
}
|
||||
|
||||
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
// If lhsHash is missing, use the initial commit of the repo
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
|
||||
baseHash = rootCommit.trim()
|
||||
console.debug(`Using root commit as base: ${baseHash}`)
|
||||
}
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git, gitPath)
|
||||
|
||||
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
|
||||
console.info(`Found ${diffSummary.files.length} changed files`)
|
||||
|
||||
// For each changed file, gather before/after content
|
||||
const result = []
|
||||
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
|
||||
const files = diffSummary.files.map((f) => f.file)
|
||||
const batchSize = 50
|
||||
|
||||
// Get list of files that exist in base commit
|
||||
const existingFiles = await this.getExistingFiles(git, baseHash, files)
|
||||
|
||||
// Process files in batches
|
||||
for (let i = 0; i < files.length; i += batchSize) {
|
||||
const batch = files.slice(i, i + batchSize)
|
||||
|
||||
// Split batch into existing and new files
|
||||
const existingBatch = batch.filter((file) => existingFiles.has(file))
|
||||
const newBatch = batch.filter((file) => !existingFiles.has(file))
|
||||
|
||||
// Get before contents for existing files
|
||||
let beforeContents: string[] = new Array(batch.length).fill("")
|
||||
if (existingBatch.length > 0) {
|
||||
await git.addConfig("core.quotePath", "false")
|
||||
await git.addConfig("core.precomposeunicode", "true")
|
||||
const args = ["show", "--format="]
|
||||
existingBatch.forEach((file) => {
|
||||
args.push(`${baseHash}:${file}`)
|
||||
})
|
||||
const beforeResult = await git.raw(args)
|
||||
const existingContents = beforeResult.split("\n\0\n")
|
||||
// Map contents back to original batch positions
|
||||
existingBatch.forEach((file, index) => {
|
||||
const batchIndex = batch.indexOf(file)
|
||||
if (batchIndex !== -1) {
|
||||
beforeContents[batchIndex] = existingContents[index] || ""
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Get after contents
|
||||
let afterContents: string[] = []
|
||||
if (rhsHash) {
|
||||
// Split after files into existing and new in target commit
|
||||
const afterExistingFiles = await this.getExistingFiles(git, rhsHash, batch)
|
||||
const afterExistingBatch = batch.filter((file) => afterExistingFiles.has(file))
|
||||
|
||||
if (afterExistingBatch.length > 0) {
|
||||
const args = ["show", "--format="]
|
||||
afterExistingBatch.forEach((file) => {
|
||||
args.push(`${rhsHash}:${file}`)
|
||||
})
|
||||
const afterResult = await git.raw(args)
|
||||
const existingContents = afterResult.split("\n\0\n")
|
||||
afterContents = new Array(batch.length).fill("")
|
||||
afterExistingBatch.forEach((file, index) => {
|
||||
const batchIndex = batch.indexOf(file)
|
||||
if (batchIndex !== -1) {
|
||||
afterContents[batchIndex] = existingContents[index] || ""
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Read from disk for working directory changes
|
||||
afterContents = await Promise.all(
|
||||
batch.map(async (filePath) => {
|
||||
try {
|
||||
return await fs.readFile(path.join(cwdPath, filePath), "utf8")
|
||||
} catch (_) {
|
||||
return ""
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Add results for this batch
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
const filePath = batch[j]
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
result.push({
|
||||
relativePath: filePath,
|
||||
absolutePath,
|
||||
before: beforeContents[j] || "",
|
||||
after: afterContents[j] || "",
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all checkpoint data for a given task.
|
||||
* Handles both legacy checkpoints and branch-per-task checkpoints.
|
||||
*
|
||||
* @param taskId - The ID of the task whose checkpoints should be deleted
|
||||
* @param historyItem - The history item containing the shadow git config for this task
|
||||
* @param globalStoragePath - the globalStorage path
|
||||
* @throws Error if deletion fails
|
||||
*/
|
||||
public static async deleteCheckpoints(taskId: string, historyItem: HistoryItem, globalStoragePath: string): Promise<void> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
await GitOperations.deleteTaskBranchStatic(taskId, historyItem, globalStoragePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get a set of files that exist in a given commit
|
||||
*/
|
||||
private async getExistingFiles(git: SimpleGit, commitHash: string, files: string[]): Promise<Set<string>> {
|
||||
try {
|
||||
const result = await git.raw(["ls-tree", "-r", "--name-only", commitHash])
|
||||
const existingFiles = new Set<string>(result.split("\n"))
|
||||
return existingFiles
|
||||
} catch (error) {
|
||||
console.error("Error getting existing files:", error)
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CheckpointTracker
|
||||
@@ -0,0 +1,420 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { globby } from "globby"
|
||||
|
||||
class CheckpointTracker {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private taskId: string
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private cwd: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
lastCheckpointHash?: string
|
||||
|
||||
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
}
|
||||
|
||||
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
|
||||
try {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required to create a checkpoint tracker")
|
||||
}
|
||||
|
||||
// Check if checkpoints are disabled in VS Code settings
|
||||
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
|
||||
if (!enableCheckpoints) {
|
||||
return undefined // Don't create tracker when disabled
|
||||
}
|
||||
|
||||
// Check if git is installed by attempting to get version
|
||||
try {
|
||||
await simpleGit().version()
|
||||
} catch (error) {
|
||||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
const cwd = await CheckpointTracker.getWorkingDirectory()
|
||||
const newTracker = new CheckpointTracker(provider, taskId, cwd)
|
||||
await newTracker.initShadowGit()
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static async getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
|
||||
private async getShadowGitPath(): Promise<string> {
|
||||
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
|
||||
await fs.mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
|
||||
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
return await fileExistsAtPath(gitPath)
|
||||
}
|
||||
|
||||
public async initShadowGit(): Promise<string> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
// Make sure it's the same cwd as the configured worktree
|
||||
const worktree = await this.getShadowGitConfigWorkTree()
|
||||
if (worktree !== this.cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
|
||||
}
|
||||
|
||||
return gitPath
|
||||
} else {
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
|
||||
|
||||
// Disable commit signing for shadow repo
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
|
||||
// Get LFS patterns from workspace if they exist
|
||||
let lfsPatterns: string[] = []
|
||||
try {
|
||||
const attributesPath = path.join(this.cwd, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
lfsPatterns = attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
|
||||
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
|
||||
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
|
||||
// TODO: let user customize these
|
||||
const excludesPath = path.join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
excludesPath,
|
||||
[
|
||||
".git/", // ignore the user's .git
|
||||
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
"env/",
|
||||
"venv/",
|
||||
"target/dependency/",
|
||||
"build/dependencies/",
|
||||
"dist/",
|
||||
"out/",
|
||||
"bundle/",
|
||||
"vendor/",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
"deps/",
|
||||
"pkg/",
|
||||
"Pods/",
|
||||
// Media files
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
// "*.svg",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.avi",
|
||||
"*.mov",
|
||||
"*.wmv",
|
||||
"*.webm",
|
||||
"*.webp",
|
||||
"*.m4a",
|
||||
"*.flac",
|
||||
// Build and dependency directories
|
||||
"build/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".vs/",
|
||||
"coverage/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
// Cache and temporary files
|
||||
"*.cache",
|
||||
"*.tmp",
|
||||
"*.temp",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
".pytest_cache/",
|
||||
".eslintcache",
|
||||
// Environment and config files
|
||||
".env*",
|
||||
"*.local",
|
||||
"*.development",
|
||||
"*.production",
|
||||
// Large data files
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
// Database files
|
||||
"*.sqlite",
|
||||
"*.db",
|
||||
"*.sql",
|
||||
// Log files
|
||||
"*.logs",
|
||||
"*.error",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
...lfsPatterns,
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// Set up git identity (git throws an error if user.name or user.email is not set)
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "noreply@example.com")
|
||||
|
||||
await this.addAllFiles(git)
|
||||
// Initial commit (--allow-empty ensures it works even with no files)
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
return gitPath
|
||||
}
|
||||
}
|
||||
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
await this.addAllFiles(git)
|
||||
const result = await git.commit("checkpoint", {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// Clean working directory and force reset
|
||||
// This ensures that the operation will succeed regardless of:
|
||||
// - Untracked files in the workspace
|
||||
// - Staged changes
|
||||
// - Unstaged changes
|
||||
// - Partial commits
|
||||
// - Merge conflicts
|
||||
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array describing changed files between one commit and either:
|
||||
* - another commit, or
|
||||
* - the current working directory (including uncommitted changes).
|
||||
*
|
||||
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
|
||||
* If you want truly untracked files to appear, `git add` them first.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash?: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
before: string
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// If lhsHash is missing, use the initial commit of the repo
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
|
||||
baseHash = rootCommit.trim()
|
||||
}
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.addAllFiles(git)
|
||||
|
||||
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
|
||||
|
||||
// For each changed file, gather before/after content
|
||||
const result = []
|
||||
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
|
||||
|
||||
for (const file of diffSummary.files) {
|
||||
const filePath = file.file
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${baseHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
|
||||
let afterContent = ""
|
||||
if (rhsHash) {
|
||||
// if user provided a newer commit, use git.show at that commit
|
||||
try {
|
||||
afterContent = await git.show([`${rhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
}
|
||||
} else {
|
||||
// otherwise, read from disk (includes uncommitted changes)
|
||||
try {
|
||||
afterContent = await fs.readFile(absolutePath, "utf8")
|
||||
} catch (_) {
|
||||
// file might be deleted => remains empty
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
relativePath: filePath,
|
||||
absolutePath,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async addAllFiles(git: SimpleGit) {
|
||||
await this.renameNestedGitRepos(true)
|
||||
try {
|
||||
await git.add(".")
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to git:", error)
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
|
||||
private async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
|
||||
const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
|
||||
export default CheckpointTracker
|
||||
@@ -1,31 +1,94 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import simpleGit from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { globby } from "globby"
|
||||
import { telemetryService } from "../../services/telemetry/TelemetryService"
|
||||
import { GitOperations } from "./CheckpointGitOperations"
|
||||
import { getShadowGitPath, getWorkingDirectory, hashWorkingDir } from "./CheckpointUtils"
|
||||
|
||||
/**
|
||||
* CheckpointTracker Module
|
||||
*
|
||||
* Core implementation of Cline's Checkpoints system that provides version control
|
||||
* capabilities without interfering with the user's main Git repository. Key features:
|
||||
*
|
||||
* Shadow Git Repository:
|
||||
* - Creates and manages an isolated Git repository for tracking checkpoints
|
||||
* - Handles nested Git repositories by temporarily disabling them
|
||||
* - Configures Git settings automatically (identity, LFS, etc.)
|
||||
*
|
||||
* File Management:
|
||||
* - Integrates with CheckpointExclusions for file filtering
|
||||
* - Handles workspace validation and path resolution
|
||||
* - Manages Git worktree configuration
|
||||
*
|
||||
* Checkpoint Operations:
|
||||
* - Creates checkpoints (commits) of the current state
|
||||
* - Provides diff capabilities between checkpoints
|
||||
* - Supports resetting to previous checkpoints
|
||||
*
|
||||
* Safety Features:
|
||||
* - Prevents usage in sensitive directories (home, desktop, etc.)
|
||||
* - Validates workspace configuration
|
||||
* - Handles cleanup and resource disposal
|
||||
*
|
||||
* Checkpoint Architecture:
|
||||
* - Unique shadow git repository for each workspace
|
||||
* - Workspaces are identified by name, and hashed to a unique number
|
||||
* - All commits for a workspace are stored in one shadow git, under a single branch
|
||||
*/
|
||||
|
||||
class CheckpointTracker {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private globalStoragePath: string
|
||||
private taskId: string
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private cwd: string
|
||||
private cwdHash: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
lastCheckpointHash?: string
|
||||
private gitOperations: GitOperations
|
||||
|
||||
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
|
||||
* The constructor is private - use the static create() method to instantiate.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task being tracked
|
||||
* @param cwd - The current working directory to track files in
|
||||
* @param cwdHash - Hash of the working directory path for shadow git organization
|
||||
*/
|
||||
private constructor(globalStoragePath: string, taskId: string, cwd: string, cwdHash: string) {
|
||||
this.globalStoragePath = globalStoragePath
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
this.cwdHash = cwdHash
|
||||
this.gitOperations = new GitOperations(cwd)
|
||||
}
|
||||
|
||||
public static async create(taskId: string, provider?: ClineProvider): Promise<CheckpointTracker | undefined> {
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance for tracking changes in a task.
|
||||
* Handles initialization of the shadow git repository.
|
||||
*
|
||||
* @param taskId - Unique identifier for the task to track
|
||||
* @param globalStoragePath - the globalStorage path
|
||||
* @returns Promise resolving to new CheckpointTracker instance, or undefined if checkpoints are disabled
|
||||
* @throws Error if:
|
||||
* - globalStoragePath is not supplied
|
||||
* - Git is not installed
|
||||
* - Working directory is invalid or in a protected location
|
||||
* - Shadow git initialization fails
|
||||
*
|
||||
* Key operations:
|
||||
* - Validates git installation and settings
|
||||
* - Creates/initializes shadow git repository
|
||||
*
|
||||
* Configuration:
|
||||
* - Respects 'cline.enableCheckpoints' VS Code setting
|
||||
*/
|
||||
public static async create(taskId: string, globalStoragePath: string | undefined): Promise<CheckpointTracker | undefined> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage path is required to create a checkpoint tracker")
|
||||
}
|
||||
try {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required to create a checkpoint tracker")
|
||||
}
|
||||
console.info(`Creating new CheckpointTracker for task ${taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
// Check if checkpoints are disabled in VS Code settings
|
||||
const enableCheckpoints = vscode.workspace.getConfiguration("cline").get<boolean>("enableCheckpoints") ?? true
|
||||
@@ -40,9 +103,18 @@ class CheckpointTracker {
|
||||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
const cwd = await CheckpointTracker.getWorkingDirectory()
|
||||
const newTracker = new CheckpointTracker(provider, taskId, cwd)
|
||||
await newTracker.initShadowGit()
|
||||
const workingDir = await getWorkingDirectory()
|
||||
const cwdHash = hashWorkingDir(workingDir)
|
||||
console.debug(`Repository ID (cwdHash): ${cwdHash}`)
|
||||
|
||||
const newTracker = new CheckpointTracker(globalStoragePath, taskId, workingDir, cwdHash)
|
||||
|
||||
const gitPath = await getShadowGitPath(newTracker.globalStoragePath, newTracker.taskId, newTracker.cwdHash)
|
||||
await newTracker.gitOperations.initShadowGit(gitPath, workingDir, taskId)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(taskId, "shadow_git_initialized", durationMs)
|
||||
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
@@ -50,203 +122,96 @@ class CheckpointTracker {
|
||||
}
|
||||
}
|
||||
|
||||
private static async getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
/**
|
||||
* Creates a new checkpoint commit in the shadow git repository.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Creates commit with checkpoint files in shadow git repo
|
||||
* - Caches the created commit hash
|
||||
*
|
||||
* Commit structure:
|
||||
* - Commit message: "checkpoint-{cwdHash}-{taskId}"
|
||||
* - Always allows empty commits
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - Uses addCheckpointFiles to stage changes using 'git add .'
|
||||
* - Relies on git's native exclusion handling via the exclude file
|
||||
*
|
||||
* @returns Promise<string | undefined> The created commit hash, or undefined if:
|
||||
* - Shadow git access fails
|
||||
* - Staging files fails
|
||||
* - Commit creation fails
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Stage or commit files
|
||||
*/
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
console.info(`Creating new checkpoint commit for task ${this.taskId}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
|
||||
private async getShadowGitPath(): Promise<string> {
|
||||
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
|
||||
await fs.mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
|
||||
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
return await fileExistsAtPath(gitPath)
|
||||
}
|
||||
|
||||
public async initShadowGit(): Promise<string> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
// Make sure it's the same cwd as the configured worktree
|
||||
const worktree = await this.getShadowGitConfigWorkTree()
|
||||
if (worktree !== this.cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
|
||||
}
|
||||
|
||||
return gitPath
|
||||
} else {
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
|
||||
|
||||
// Disable commit signing for shadow repo
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
|
||||
// Get LFS patterns from workspace if they exist
|
||||
let lfsPatterns: string[] = []
|
||||
try {
|
||||
const attributesPath = path.join(this.cwd, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
lfsPatterns = attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
|
||||
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
|
||||
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
|
||||
// TODO: let user customize these
|
||||
const excludesPath = path.join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
excludesPath,
|
||||
[
|
||||
".git/", // ignore the user's .git
|
||||
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
"env/",
|
||||
"venv/",
|
||||
"target/dependency/",
|
||||
"build/dependencies/",
|
||||
"dist/",
|
||||
"out/",
|
||||
"bundle/",
|
||||
"vendor/",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
"deps/",
|
||||
"pkg/",
|
||||
"Pods/",
|
||||
// Media files
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
// "*.svg",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.avi",
|
||||
"*.mov",
|
||||
"*.wmv",
|
||||
"*.webm",
|
||||
"*.webp",
|
||||
"*.m4a",
|
||||
"*.flac",
|
||||
// Build and dependency directories
|
||||
"build/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".vs/",
|
||||
"coverage/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
// Cache and temporary files
|
||||
"*.cache",
|
||||
"*.tmp",
|
||||
"*.temp",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
".pytest_cache/",
|
||||
".eslintcache",
|
||||
// Environment and config files
|
||||
".env*",
|
||||
"*.local",
|
||||
"*.development",
|
||||
"*.production",
|
||||
// Large data files
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
// Database files
|
||||
"*.sqlite",
|
||||
"*.db",
|
||||
"*.sql",
|
||||
// Log files
|
||||
"*.logs",
|
||||
"*.error",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
...lfsPatterns,
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// Set up git identity (git throws an error if user.name or user.email is not set)
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "noreply@example.com")
|
||||
|
||||
await this.addAllFiles(git)
|
||||
// Initial commit (--allow-empty ensures it works even with no files)
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
return gitPath
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
console.info(`Using shadow git at: ${gitPath}`)
|
||||
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const commitMessage = "checkpoint-" + this.cwdHash + "-" + this.taskId
|
||||
|
||||
console.info(`Creating checkpoint commit with message: ${commitMessage}`)
|
||||
const result = await git.commit(commitMessage, {
|
||||
"--allow-empty": null,
|
||||
"--no-verify": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
console.warn(`Checkpoint commit created.`)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
|
||||
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", {
|
||||
taskId: this.taskId,
|
||||
error,
|
||||
})
|
||||
throw new Error(`Failed to create checkpoint: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the worktree path from the shadow git configuration.
|
||||
* The worktree path indicates where the shadow git repository is tracking files,
|
||||
* which should match the current workspace directory.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Caches result in lastRetrievedShadowGitConfigWorkTree to avoid repeated reads
|
||||
* - Returns cached value if available
|
||||
* - Reads git config if no cached value exists
|
||||
*
|
||||
* Configuration read:
|
||||
* - Uses simple-git to read core.worktree config
|
||||
* - Operates on shadow git at path from getShadowGitPath()
|
||||
*
|
||||
* @returns Promise<string | undefined> The configured worktree path, or undefined if:
|
||||
* - Shadow git repository doesn't exist
|
||||
* - Config read fails
|
||||
* - No worktree is configured
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Read git configuration
|
||||
*/
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
this.lastRetrievedShadowGitConfigWorkTree = await this.gitOperations.getShadowGitConfigWorkTree(gitPath)
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
@@ -254,36 +219,34 @@ class CheckpointTracker {
|
||||
}
|
||||
}
|
||||
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
await this.addAllFiles(git)
|
||||
const result = await git.commit("checkpoint", {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the shadow git repository's HEAD to a specific checkpoint commit.
|
||||
* This will discard all changes after the target commit and restore the
|
||||
* working directory to that checkpoint's state.
|
||||
*
|
||||
* Dependencies:
|
||||
* - Requires initialized shadow git (getShadowGitPath)
|
||||
* - Must be called with a valid commit hash from this task's history
|
||||
*
|
||||
* @param commitHash - The hash of the checkpoint commit to reset to
|
||||
* @returns Promise<void> Resolves when reset is complete
|
||||
* @throws Error if unable to:
|
||||
* - Access shadow git path
|
||||
* - Initialize simple-git
|
||||
* - Reset to target commit
|
||||
*/
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
console.info(`Resetting to checkpoint: ${commitHash}`)
|
||||
const startTime = performance.now()
|
||||
|
||||
// Clean working directory and force reset
|
||||
// This ensures that the operation will succeed regardless of:
|
||||
// - Untracked files in the workspace
|
||||
// - Staged changes
|
||||
// - Unstaged changes
|
||||
// - Partial commits
|
||||
// - Merge conflicts
|
||||
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
console.debug(`Using shadow git at: ${gitPath}`)
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "restored", durationMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,7 +263,7 @@ class CheckpointTracker {
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash?: string,
|
||||
lhsHash: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
@@ -310,46 +273,40 @@ class CheckpointTracker {
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const startTime = performance.now()
|
||||
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// If lhsHash is missing, use the initial commit of the repo
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
|
||||
baseHash = rootCommit.trim()
|
||||
}
|
||||
console.info(`Getting diff between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.addAllFiles(git)
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
console.info(`Diff range: ${diffRange}`)
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
// For each changed file, gather before/after content
|
||||
const result = []
|
||||
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
|
||||
|
||||
for (const file of diffSummary.files) {
|
||||
const filePath = file.file
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
const absolutePath = path.join(this.cwd, filePath)
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${baseHash}:${filePath}`])
|
||||
beforeContent = await git.show([`${lhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
|
||||
let afterContent = ""
|
||||
if (rhsHash) {
|
||||
// if user provided a newer commit, use git.show at that commit
|
||||
try {
|
||||
afterContent = await git.show([`${rhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
}
|
||||
} else {
|
||||
// otherwise, read from disk (includes uncommitted changes)
|
||||
try {
|
||||
afterContent = await fs.readFile(absolutePath, "utf8")
|
||||
} catch (_) {
|
||||
@@ -365,56 +322,39 @@ class CheckpointTracker {
|
||||
})
|
||||
}
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async addAllFiles(git: SimpleGit) {
|
||||
await this.renameNestedGitRepos(true)
|
||||
try {
|
||||
await git.add(".")
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to git:", error)
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Returns the number of files changed between two commits.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns The number of files changed between the commits
|
||||
*/
|
||||
public async getDiffCount(lhsHash: string, rhsHash?: string): Promise<number> {
|
||||
const startTime = performance.now()
|
||||
|
||||
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
|
||||
private async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
console.info(`Getting diff count between commits: ${lhsHash || "initial"} -> ${rhsHash || "working directory"}`)
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
this.disposables = []
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "diff_generated", durationMs)
|
||||
|
||||
return diffSummary.files.length
|
||||
}
|
||||
}
|
||||
|
||||
const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
|
||||
export default CheckpointTracker
|
||||
|
||||
@@ -2,42 +2,11 @@ import { mkdir } from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import os from "os"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
|
||||
/**
|
||||
* Gets the path to the legacy shadow Git repository in globalStorage.
|
||||
* Legacy checkpoints stored each task's checkpoints in a separate git repository
|
||||
* under the tasks/{taskId}/checkpoints directory.
|
||||
*
|
||||
* Legacy path structure:
|
||||
* globalStorage/
|
||||
* tasks/
|
||||
* {taskId}/
|
||||
* checkpoints/
|
||||
* .git/
|
||||
*
|
||||
* @param globalStoragePath - The VS Code global storage path
|
||||
* @param taskId - The ID of the task
|
||||
* @returns Promise<string> The absolute path to the legacy shadow git directory
|
||||
* @throws Error if global storage path is invalid
|
||||
*/
|
||||
export async function getLegacyShadowGitPath(globalStoragePath: string, taskId: string): Promise<string> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", taskId, "checkpoints")
|
||||
await mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
console.info(`Legacy shadow git path: ${gitPath}`)
|
||||
return gitPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the shadow Git repository in globalStorage.
|
||||
* For legacy checkpoints, delegates to getLegacyShadowGitPath().
|
||||
* For new checkpoints, uses the consolidated branch-per-task structure.
|
||||
*
|
||||
* Branch-per-task path structure:
|
||||
* Checkpoints path structure:
|
||||
* globalStorage/
|
||||
* checkpoints/
|
||||
* {cwdHash}/
|
||||
@@ -46,19 +15,10 @@ export async function getLegacyShadowGitPath(globalStoragePath: string, taskId:
|
||||
* @param globalStoragePath - The VS Code global storage path
|
||||
* @param taskId - The ID of the task
|
||||
* @param cwdHash - Hash of the working directory path
|
||||
* @param isLegacyCheckpoint - Whether this is a legacy checkpoint
|
||||
* @returns Promise<string> The absolute path to the shadow git directory
|
||||
* @throws Error if global storage path is invalid
|
||||
*/
|
||||
export async function getShadowGitPath(
|
||||
globalStoragePath: string,
|
||||
taskId: string,
|
||||
cwdHash: string,
|
||||
isLegacyCheckpoint: boolean,
|
||||
): Promise<string> {
|
||||
if (isLegacyCheckpoint) {
|
||||
return getLegacyShadowGitPath(globalStoragePath, taskId)
|
||||
}
|
||||
export async function getShadowGitPath(globalStoragePath: string, taskId: string, cwdHash: string): Promise<string> {
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
@@ -124,36 +84,3 @@ export function hashWorkingDir(workingDir: string): string {
|
||||
const numericHash = bigHash.toString().slice(0, 13)
|
||||
return numericHash
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if a task uses the legacy checkpoint structure.
|
||||
* Legacy checkpoints stored each task's checkpoints in a separate git repository
|
||||
* under the tasks/{taskId}/checkpoints directory. New checkpoints use a single
|
||||
* repository with branches per task.
|
||||
*
|
||||
* @param globalStoragePath - The VS Code global storage path
|
||||
* @param taskId - The ID of the task to check
|
||||
* @returns Promise<boolean> True if task uses legacy checkpoint structure, false otherwise
|
||||
*
|
||||
* Legacy path structure:
|
||||
* globalStorage/
|
||||
* tasks/
|
||||
* {taskId}/
|
||||
* checkpoints/
|
||||
* .git/
|
||||
*
|
||||
* Branch-per-task structure:
|
||||
* globalStorage/
|
||||
* checkpoints/
|
||||
* {cwdHash}/
|
||||
* .git/
|
||||
*/
|
||||
export async function detectLegacyCheckpoint(globalStoragePath: string | undefined, taskId: string): Promise<boolean> {
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const legacyGitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
const isLegacy = await fileExistsAtPath(legacyGitPath)
|
||||
console.info(`Legacy checkpoint detection result: ${isLegacy}`)
|
||||
return isLegacy
|
||||
}
|
||||
|
||||
@@ -158,7 +158,6 @@ export class DiffViewProvider {
|
||||
await updatedDocument.save()
|
||||
}
|
||||
|
||||
// await delay(100)
|
||||
// get text after save in case there is any auto-formatting done by the editor
|
||||
const postSaveContent = updatedDocument.getText()
|
||||
|
||||
|
||||
@@ -47,10 +47,7 @@ export async function downloadTask(dateTs: number, conversationHistory: Anthropi
|
||||
}
|
||||
}
|
||||
|
||||
export function formatContentBlockToMarkdown(
|
||||
block: Anthropic.ContentBlockParam,
|
||||
// messages: Anthropic.MessageParam[]
|
||||
): string {
|
||||
export function formatContentBlockToMarkdown(block: Anthropic.ContentBlockParam): string {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
return block.text
|
||||
@@ -69,32 +66,16 @@ export function formatContentBlockToMarkdown(
|
||||
}
|
||||
return `[Tool Use: ${block.name}]\n${input}`
|
||||
case "tool_result":
|
||||
// For now we're not doing tool name lookup since we don't use tools anymore
|
||||
// const toolName = findToolName(block.tool_use_id, messages)
|
||||
const toolName = "Tool"
|
||||
if (typeof block.content === "string") {
|
||||
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content}`
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content}`
|
||||
} else if (Array.isArray(block.content)) {
|
||||
return `[${toolName}${block.is_error ? " (Error)" : ""}]\n${block.content
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]\n${block.content
|
||||
.map((contentBlock) => formatContentBlockToMarkdown(contentBlock))
|
||||
.join("\n")}`
|
||||
} else {
|
||||
return `[${toolName}${block.is_error ? " (Error)" : ""}]`
|
||||
return `[Tool${block.is_error ? " (Error)" : ""}]`
|
||||
}
|
||||
default:
|
||||
return "[Unexpected content type]"
|
||||
}
|
||||
}
|
||||
|
||||
export function findToolName(toolCallId: string, messages: Anthropic.MessageParam[]): string {
|
||||
for (const message of messages) {
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "tool_use" && block.id === toolCallId) {
|
||||
return block.name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Unknown Tool"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import pdf from "pdf-parse/lib/pdf-parse"
|
||||
import mammoth from "mammoth"
|
||||
import fs from "fs/promises"
|
||||
import { isBinaryFile } from "isbinaryfile"
|
||||
import { getFileSizeInKB } from "../../utils/fs"
|
||||
|
||||
export async function extractTextFromFile(filePath: string): Promise<string> {
|
||||
try {
|
||||
@@ -22,6 +23,11 @@ export async function extractTextFromFile(filePath: string): Promise<string> {
|
||||
default:
|
||||
const isBinary = await isBinaryFile(filePath).catch(() => false)
|
||||
if (!isBinary) {
|
||||
// If file is over 300KB, throw an error
|
||||
const fileSizeInKB = await getFileSizeInKB(filePath)
|
||||
if (fileSizeInKB > 300) {
|
||||
throw new Error(`File is too large to read into context.`)
|
||||
}
|
||||
return await fs.readFile(filePath, "utf8")
|
||||
} else {
|
||||
throw new Error(`Cannot read text for file type: ${fileExtension}`)
|
||||
|
||||
@@ -63,7 +63,6 @@ export async function fetchOpenGraphData(url: string): Promise<OpenGraphData> {
|
||||
type: data.ogType,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching Open Graph data for ${url}:`, error)
|
||||
// Return basic information based on the URL
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
@@ -100,8 +99,7 @@ export async function isImageUrl(url: string): Promise<boolean> {
|
||||
const contentType = response.headers["content-type"]
|
||||
return contentType && contentType.startsWith("image/")
|
||||
} catch (error) {
|
||||
console.error(`Error checking if URL is an image: ${url}`, error)
|
||||
// If we can't determine, fall back to checking the file extension
|
||||
return /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url)
|
||||
return /\.(jpg|jpeg|png|gif|webp|bmp|svg|tiff|tif|avif)$/i.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { describe, it, beforeEach, afterEach } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { TerminalProcess } from "./TerminalProcess"
|
||||
import * as vscode from "vscode"
|
||||
import { TerminalRegistry } from "./TerminalRegistry"
|
||||
import { EventEmitter } from "events"
|
||||
|
||||
declare module "vscode" {
|
||||
// https://github.com/microsoft/vscode/blob/f0417069c62e20f3667506f4b7e53ca0004b4e3e/src/vscode-dts/vscode.d.ts#L7442
|
||||
interface Terminal {
|
||||
shellIntegration?: {
|
||||
cwd?: vscode.Uri
|
||||
executeCommand?: (command: string) => {
|
||||
read: () => AsyncIterable<string>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a mock stream for simulating terminal output - this is only used for tests
|
||||
// that need controlled output which can't be guaranteed with real terminals
|
||||
function createMockStream(lines: string[] = ["test-command", "line1", "line2", "line3"]) {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const line of lines) {
|
||||
yield line + "\n"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("TerminalProcess (Integration Tests)", () => {
|
||||
let process: TerminalProcess
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let createdTerminals: vscode.Terminal[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox({ useFakeTimers: true })
|
||||
process = new TerminalProcess()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore sandbox, which restores timers and all Sinon fakes
|
||||
sandbox.restore()
|
||||
// Remove any event listeners left on the TerminalProcess
|
||||
process.removeAllListeners()
|
||||
// Dispose all terminals created during the test
|
||||
createdTerminals.forEach((t) => t.dispose())
|
||||
createdTerminals = []
|
||||
})
|
||||
|
||||
describe("Real terminal tests", () => {
|
||||
// This test works with or without shell integration
|
||||
it("should create and run a command in a real terminal", async () => {
|
||||
// Create a real VS Code terminal for testing
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Spy on emit to verify behavior
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
// Run a simple command
|
||||
await process.run(terminal, "echo test")
|
||||
|
||||
// Verify that the continue event was emitted
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
})
|
||||
|
||||
it("should execute and capture events from a simple command", async () => {
|
||||
// Create a real VS Code terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Spy on emit to verify line events
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
// Run a command that produces predictable output
|
||||
await process.run(terminal, "echo 'Line 1' && echo 'Line 2'")
|
||||
|
||||
// Check that the events were emitted
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
})
|
||||
|
||||
it("should execute a command that lists files", async () => {
|
||||
// Create a real VS Code terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Spy on emit to verify behavior
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
// Run a command that lists files
|
||||
await process.run(terminal, "ls -la")
|
||||
|
||||
// Verify that the continue event was emitted
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
})
|
||||
|
||||
it("should handle a longer running command", async () => {
|
||||
// Create a real terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Spy on emit to verify behavior
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
// Un-fake timers temporarily for this test since we need real timing
|
||||
sandbox.clock.restore()
|
||||
|
||||
// Run a command that sleeps for a short period
|
||||
await process.run(terminal, "sleep 0.5 && echo 'Done sleeping'")
|
||||
|
||||
// Verify that the continue and completed events were emitted
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
|
||||
// Restore fake timers for other tests
|
||||
sandbox.useFakeTimers()
|
||||
})
|
||||
|
||||
it("should execute a command with arguments", async () => {
|
||||
// Create a real VS Code terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Spy on emit to verify line events
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
// Run a command that produces predictable output
|
||||
await process.run(terminal, "echo 'Line 1' 'Line 2'")
|
||||
|
||||
// Check that the events were emitted
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
})
|
||||
|
||||
it("should execute a command with quotes", async () => {
|
||||
// Create a real VS Code terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Spy on emit to verify line events
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
// Run a command that produces predictable output
|
||||
await process.run(terminal, "echo \"Line 1\" && echo 'Line 2'")
|
||||
|
||||
// Check that the events were emitted
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
// Test that specifically checks for no shell integration
|
||||
it("should handle terminals without shell integration", async () => {
|
||||
// Create a real terminal without explicitly providing shell integration
|
||||
const terminal = vscode.window.createTerminal({ name: "Test Terminal" })
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Stub the shellIntegration getter to return undefined for this test
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => undefined)
|
||||
|
||||
// Stub the sendText method to verify it's called
|
||||
const sendTextStub = sandbox.stub(terminal, "sendText")
|
||||
|
||||
// Spy on the emit function to verify events
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
// Run the command
|
||||
await process.run(terminal, "test-command")
|
||||
|
||||
// Check that the correct methods were called and events emitted
|
||||
sendTextStub.calledWith("test-command", true).should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
|
||||
// This event should be emitted for terminals without shell integration
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("no_shell_integration").should.be.true()
|
||||
})
|
||||
|
||||
// The following tests require shell integration and controlled terminal output
|
||||
describe("Shell integration tests", () => {
|
||||
// We'll mock the terminal run process and TerminalProcess for these tests
|
||||
it("should emit completed and continue events when command finishes", async function () {
|
||||
// Create a terminal to ensure proper interface, but we'll use mocking under the hood
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Create a mock implementation of executeCommand
|
||||
const mockExecuteCommand = sandbox.stub().returns({
|
||||
read: () => createMockStream(["echo test", "test output"]),
|
||||
})
|
||||
|
||||
// Create a fake shell integration object
|
||||
const mockShellIntegration = {
|
||||
executeCommand: mockExecuteCommand,
|
||||
}
|
||||
|
||||
// Stub terminal.shellIntegration to return our mock
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => mockShellIntegration)
|
||||
|
||||
// Spy on emit to verify behavior
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
// Run the command
|
||||
await process.run(terminal, "echo test")
|
||||
|
||||
// Verify the executeCommand was called with the right command
|
||||
mockExecuteCommand.calledWith("echo test").should.be.true()
|
||||
|
||||
// Check that the events were emitted
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("continue").should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests with controlled output
|
||||
describe("Controlled output tests", () => {
|
||||
it("should emit line events for each line of output", async function () {
|
||||
// Create a terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Mock the shell integration with controlled output
|
||||
const mockExecuteCommand = sandbox.stub().returns({
|
||||
read: () => createMockStream(["test-command", "line1", "line2", "line3"]),
|
||||
})
|
||||
|
||||
// Create a mock shell integration object and stub the getter
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => ({
|
||||
executeCommand: mockExecuteCommand,
|
||||
}))
|
||||
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
await process.run(terminal, "test-command")
|
||||
|
||||
// Check that line events were emitted for each line
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "line1").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "line2").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "line3").should.be.true()
|
||||
})
|
||||
|
||||
it("should properly handle process hot state (e.g. compiling)", async function () {
|
||||
// Create a terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Mock the shell integration
|
||||
const mockExecuteCommand = sandbox.stub().returns({
|
||||
read: () => createMockStream(["compiling..."]),
|
||||
})
|
||||
|
||||
// Create a mock shell integration object and stub the getter
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => ({
|
||||
executeCommand: mockExecuteCommand,
|
||||
}))
|
||||
|
||||
// Spy on global setTimeout
|
||||
const setTimeoutSpy = sandbox.spy(global, "setTimeout")
|
||||
|
||||
await process.run(terminal, "build command")
|
||||
|
||||
// Move time forward enough to schedule
|
||||
sandbox.clock.tick(100)
|
||||
|
||||
// Expect a 15-second (>= 10000ms) hot timeout, since it saw "compiling"
|
||||
const foundCompilingTimeout = setTimeoutSpy.args.filter((args) => args[1] && args[1] >= 10000)
|
||||
foundCompilingTimeout.length.should.be.greaterThan(0)
|
||||
})
|
||||
|
||||
it("should handle standard commands with normal hot timeout", async function () {
|
||||
// Create a terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Mock the shell integration
|
||||
const mockExecuteCommand = sandbox.stub().returns({
|
||||
read: () => createMockStream(["some normal output"]),
|
||||
})
|
||||
|
||||
// Create a mock shell integration object and stub the getter
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => ({
|
||||
executeCommand: mockExecuteCommand,
|
||||
}))
|
||||
|
||||
const setTimeoutSpy = sandbox.spy(global, "setTimeout")
|
||||
|
||||
await process.run(terminal, "standard command")
|
||||
sandbox.clock.tick(100)
|
||||
|
||||
// Expect a short hot timeout (<= 5000)
|
||||
const foundNormalTimeout = setTimeoutSpy.args.filter((args) => args[1] && args[1] <= 5000)
|
||||
foundNormalTimeout.length.should.be.greaterThan(0)
|
||||
|
||||
// Also check that "completed" eventually emits
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
await process.run(terminal, "another command")
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("completed").should.be.true()
|
||||
})
|
||||
|
||||
it("should correctly filter command echoes based on current implementation", async function () {
|
||||
// Create a terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Mock the shell integration
|
||||
const mockExecuteCommand = sandbox.stub().returns({
|
||||
read: () =>
|
||||
createMockStream([
|
||||
"test-command", // This should be filtered (command contains this exactly)
|
||||
"test command", // This should NOT be filtered (doesn't match exactly)
|
||||
"other output",
|
||||
]),
|
||||
})
|
||||
|
||||
// Create a mock shell integration object and stub the getter
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => ({
|
||||
executeCommand: mockExecuteCommand,
|
||||
}))
|
||||
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
await process.run(terminal, "test-command")
|
||||
|
||||
// Check that "test-command" was filtered out but "test command" was not
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "test command").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "other output").should.be.true()
|
||||
// This should never be called because it should be filtered
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "test-command").should.be.false()
|
||||
})
|
||||
|
||||
it("should handle npm run commands", async function () {
|
||||
// Create a terminal
|
||||
const terminal = TerminalRegistry.createTerminal().terminal
|
||||
createdTerminals.push(terminal)
|
||||
|
||||
// Mock the shell integration
|
||||
const mockExecuteCommand = sandbox.stub().returns({
|
||||
read: () => createMockStream(["npm run build", "> project@1.0.0 build", "> tsc", "files built successfully"]),
|
||||
})
|
||||
|
||||
// Create a mock shell integration object and stub the getter
|
||||
sandbox.stub(terminal, "shellIntegration").get(() => ({
|
||||
executeCommand: mockExecuteCommand,
|
||||
}))
|
||||
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
await process.run(terminal, "npm run build")
|
||||
|
||||
// The "npm run build" line should be filtered, but the rest should be emitted
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "> project@1.0.0 build").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "> tsc").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "files built successfully").should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
// The following tests are shared with the unit tests to ensure consistent behavior
|
||||
it("should emit line for remaining buffer when emitRemainingBufferIfListening is called", () => {
|
||||
// Access private properties via type assertion
|
||||
const processAny = process as any
|
||||
processAny.buffer = "test buffer content"
|
||||
processAny.isListening = true
|
||||
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
processAny.emitRemainingBufferIfListening()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "test buffer content").should.be.true()
|
||||
processAny.buffer.should.equal("")
|
||||
})
|
||||
|
||||
it("should remove prompt characters from the last line of output", () => {
|
||||
const processAny = process as any
|
||||
|
||||
processAny.removeLastLineArtifacts("line 1\nline 2 %").should.equal("line 1\nline 2")
|
||||
processAny.removeLastLineArtifacts("line 1\nline 2 $").should.equal("line 1\nline 2")
|
||||
processAny.removeLastLineArtifacts("line 1\nline 2 #").should.equal("line 1\nline 2")
|
||||
processAny.removeLastLineArtifacts("line 1\nline 2 >").should.equal("line 1\nline 2")
|
||||
})
|
||||
|
||||
it("should process buffer and emit lines when newline characters are found", () => {
|
||||
const processAny = process as any
|
||||
const emitSpy = sandbox.spy(process, "emit")
|
||||
|
||||
processAny.emitIfEol("line 1\nline 2\nline 3")
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 1").should.be.true()
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 2").should.be.true()
|
||||
processAny.buffer.should.equal("line 3")
|
||||
|
||||
processAny.emitIfEol(" continued\n")
|
||||
;(emitSpy as sinon.SinonSpy).calledWith("line", "line 3 continued").should.be.true()
|
||||
processAny.buffer.should.equal("")
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from "events"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { stripAnsi } from "./ansiUtils"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export interface TerminalProcessEvents {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export function ansiRegex({ onlyFirst = false } = {}) {
|
||||
// Valid string terminator sequences are BEL, ESC\, and 0x9c
|
||||
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"
|
||||
const pattern = [
|
||||
`[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`,
|
||||
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join("|")
|
||||
|
||||
return new RegExp(pattern, onlyFirst ? undefined : "g")
|
||||
}
|
||||
|
||||
export function stripAnsi(string: string): string {
|
||||
return string.replace(ansiRegex(), "")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
|
||||
|
||||
export class ClineAccountService {
|
||||
private readonly baseUrl = "https://api.cline.bot/v1"
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's Cline Account key from the apiConfiguration
|
||||
*/
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { apiConfiguration } = await provider.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to make authenticated requests to the Cline API
|
||||
* @param endpoint The API endpoint to call (without the base URL)
|
||||
* @param config Additional axios request configuration
|
||||
* @returns The API response data
|
||||
* @throws Error if the API key is not found or the request fails
|
||||
*/
|
||||
private async authenticatedRequest<T>(endpoint: string, config: AxiosRequestConfig = {}): Promise<T> {
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
|
||||
if (!clineApiKey) {
|
||||
throw new Error("Cline API key not found")
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
const requestConfig: AxiosRequestConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
Authorization: `Bearer ${clineApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
...config.headers,
|
||||
},
|
||||
}
|
||||
|
||||
const response: AxiosResponse<T> = await axios.get(url, requestConfig)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error(`Invalid response from ${endpoint} API`)
|
||||
}
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's current credit balance
|
||||
*/
|
||||
async fetchBalance(): Promise<BalanceResponse | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsBalance",
|
||||
userCreditsBalance: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch balance:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's usage transactions
|
||||
*/
|
||||
async fetchUsageTransactions(): Promise<UsageTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsUsage",
|
||||
userCreditsUsage: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch usage transactions:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's payment transactions
|
||||
*/
|
||||
async fetchPaymentTransactions(): Promise<PaymentTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsPayments",
|
||||
userCreditsPayments: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch payment transactions:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { initializeApp } from "firebase/app"
|
||||
import { Auth, User, getAuth, onAuthStateChanged, signInWithCustomToken, signOut } from "firebase/auth"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { firebaseConfig } from "./config"
|
||||
|
||||
export interface UserInfo {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
|
||||
export class FirebaseAuthManager {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private auth: Auth
|
||||
private disposables: vscode.Disposable[] = []
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
console.log("Initializing FirebaseAuthManager", { provider })
|
||||
this.providerRef = new WeakRef(provider)
|
||||
const app = initializeApp(firebaseConfig)
|
||||
this.auth = getAuth(app)
|
||||
console.log("Firebase app initialized", { appConfig: firebaseConfig })
|
||||
|
||||
// Auth state listener
|
||||
onAuthStateChanged(this.auth, this.handleAuthStateChange.bind(this))
|
||||
console.log("Auth state change listener added")
|
||||
|
||||
// Try to restore session
|
||||
this.restoreSession()
|
||||
}
|
||||
|
||||
private async restoreSession() {
|
||||
console.log("Attempting to restore session")
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
console.log("Provider reference lost during session restore")
|
||||
return
|
||||
}
|
||||
|
||||
const storedToken = await provider.getSecret("authToken")
|
||||
if (storedToken) {
|
||||
console.log("Found stored auth token, attempting to restore session")
|
||||
try {
|
||||
await this.signInWithCustomToken(storedToken)
|
||||
console.log("Session restored successfully")
|
||||
} catch (error) {
|
||||
console.error("Failed to restore session, clearing token:", error)
|
||||
await provider.setAuthToken(undefined)
|
||||
await provider.setUserInfo(undefined)
|
||||
}
|
||||
} else {
|
||||
console.log("No stored auth token found")
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthStateChange(user: User | null) {
|
||||
console.log("Auth state changed", { user })
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
console.log("Provider reference lost")
|
||||
return
|
||||
}
|
||||
|
||||
if (user) {
|
||||
console.log("User signed in", { userId: user.uid })
|
||||
const idToken = await user.getIdToken()
|
||||
await provider.setAuthToken(idToken)
|
||||
// Store public user info in state
|
||||
await provider.setUserInfo({
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
photoURL: user.photoURL,
|
||||
})
|
||||
console.log("User info set in provider", { user })
|
||||
} else {
|
||||
console.log("User signed out")
|
||||
await provider.setAuthToken(undefined)
|
||||
await provider.setUserInfo(undefined)
|
||||
}
|
||||
await provider.postStateToWebview()
|
||||
console.log("Webview state updated")
|
||||
}
|
||||
|
||||
async signInWithCustomToken(token: string) {
|
||||
console.log("Signing in with custom token", { token })
|
||||
await signInWithCustomToken(this.auth, token)
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
console.log("Signing out")
|
||||
await signOut(this.auth)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
console.log("Disposables disposed", { count: this.disposables.length })
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppetee
|
||||
// @ts-ignore
|
||||
import PCR from "puppeteer-chromium-resolver"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { BrowserActionResult } from "../../shared/ExtensionMessage"
|
||||
import { BrowserSettings } from "../../shared/BrowserSettings"
|
||||
@@ -295,7 +295,7 @@ export class BrowserSession {
|
||||
}
|
||||
|
||||
lastHTMLSize = currentHTMLSize
|
||||
await delay(checkDurationMsecs)
|
||||
await setTimeoutPromise(checkDurationMsecs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ export class BrowserSession {
|
||||
this.currentMousePosition = coordinate
|
||||
|
||||
// Small delay to check if click triggered any network activity
|
||||
await delay(100)
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
if (hasNetworkActivity) {
|
||||
// If we detected network activity, wait for navigation/loading
|
||||
@@ -346,7 +346,7 @@ export class BrowserSession {
|
||||
behavior: "auto",
|
||||
})
|
||||
})
|
||||
await delay(300)
|
||||
await setTimeoutPromise(300)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ export class BrowserSession {
|
||||
behavior: "auto",
|
||||
})
|
||||
})
|
||||
await delay(300)
|
||||
await setTimeoutPromise(300)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from "path"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
export async function listFiles(dirPath: string, recursive: boolean, limit: number): Promise<[string[], boolean]> {
|
||||
// First resolve the path normally - path.resolve doesn't care about glob special characters
|
||||
const absolutePath = path.resolve(dirPath)
|
||||
// Do not allow listing files in root or home directory, which cline tends to want to do when the user's prompt is vague.
|
||||
const root = process.platform === "win32" ? path.parse(absolutePath).root : "/"
|
||||
@@ -36,7 +37,7 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
|
||||
".*", // '!**/.*' excludes hidden directories, while '!**/.*/**' excludes only their contents. This way we are at least aware of the existence of hidden directories.
|
||||
].map((dir) => `**/${dir}/**`)
|
||||
|
||||
const options = {
|
||||
const options: Options = {
|
||||
cwd: dirPath,
|
||||
dot: true, // do not ignore hidden files/directories
|
||||
absolute: true,
|
||||
@@ -44,9 +45,11 @@ export async function listFiles(dirPath: string, recursive: boolean, limit: numb
|
||||
gitignore: recursive, // globby ignores any files that are gitignored
|
||||
ignore: recursive ? dirsToIgnore : undefined, // just in case there is no gitignore, we ignore sensible defaults
|
||||
onlyFiles: false, // true by default, false means it will list directories on their own too
|
||||
suppressErrors: true,
|
||||
}
|
||||
|
||||
// * globs all files in one dir, ** globs files in nested directories
|
||||
// For non-recursive listing, we still use a simple pattern
|
||||
const filePaths = recursive ? await globbyLevelByLevel(limit, options) : (await globby("*", options)).slice(0, limit)
|
||||
|
||||
return [filePaths, filePaths.length >= limit]
|
||||
@@ -79,7 +82,11 @@ async function globbyLevelByLevel(limit: number, options?: Options) {
|
||||
}
|
||||
results.add(file)
|
||||
if (file.endsWith("/")) {
|
||||
queue.push(`${file}*`)
|
||||
// Escape parentheses in the path to prevent glob pattern interpretation
|
||||
// This is crucial for NextJS folder naming conventions which use parentheses like (auth), (dashboard)
|
||||
// Without escaping, glob treats parentheses as special pattern grouping characters
|
||||
const escapedFile = file.replace(/\(/g, "\\(").replace(/\)/g, "\\)")
|
||||
queue.push(`${escapedFile}*`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+58
-32
@@ -8,14 +8,15 @@ import {
|
||||
ReadResourceResultSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import delay from "delay"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { ClineProvider, GlobalFileNames } from "../../core/webview/ClineProvider"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpMode,
|
||||
McpResource,
|
||||
McpResourceResponse,
|
||||
@@ -23,10 +24,12 @@ import {
|
||||
McpServer,
|
||||
McpTool,
|
||||
McpToolCallResponse,
|
||||
MIN_MCP_TIMEOUT_SECONDS,
|
||||
} from "../../shared/mcp"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
|
||||
import { secondsToMs } from "../../utils/time"
|
||||
import { GlobalFileNames } from "../../global-constants"
|
||||
export type McpConnection = {
|
||||
server: McpServer
|
||||
client: Client
|
||||
@@ -35,13 +38,13 @@ export type McpConnection = {
|
||||
|
||||
const AutoApproveSchema = z.array(z.string()).default([])
|
||||
|
||||
// StdioServerParameters
|
||||
const StdioConfigSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string()).optional(),
|
||||
autoApprove: AutoApproveSchema.optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
timeout: z.number().min(MIN_MCP_TIMEOUT_SECONDS).optional().default(DEFAULT_MCP_TIMEOUT_SECONDS),
|
||||
})
|
||||
|
||||
const McpSettingsSchema = z.object({
|
||||
@@ -242,28 +245,6 @@ export class McpHub {
|
||||
}
|
||||
transport.start = async () => {} // No-op now, .connect() won't fail
|
||||
|
||||
// // Set up notification handlers
|
||||
// client.setNotificationHandler(
|
||||
// // @ts-ignore-next-line
|
||||
// { method: "notifications/tools/list_changed" },
|
||||
// async () => {
|
||||
// console.log(`Tools changed for server: ${name}`)
|
||||
// connection.server.tools = await this.fetchTools(name)
|
||||
// await this.notifyWebviewOfServerChanges()
|
||||
// },
|
||||
// )
|
||||
|
||||
// client.setNotificationHandler(
|
||||
// // @ts-ignore-next-line
|
||||
// { method: "notifications/resources/list_changed" },
|
||||
// async () => {
|
||||
// console.log(`Resources changed for server: ${name}`)
|
||||
// connection.server.resources = await this.fetchResources(name)
|
||||
// connection.server.resourceTemplates = await this.fetchResourceTemplates(name)
|
||||
// await this.notifyWebviewOfServerChanges()
|
||||
// },
|
||||
// )
|
||||
|
||||
// Connect
|
||||
await client.connect(transport)
|
||||
connection.server.status = "connected"
|
||||
@@ -343,10 +324,6 @@ export class McpHub {
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
try {
|
||||
// connection.client.removeNotificationHandler("notifications/tools/list_changed")
|
||||
// connection.client.removeNotificationHandler("notifications/resources/list_changed")
|
||||
// connection.client.removeNotificationHandler("notifications/stderr")
|
||||
// connection.client.removeNotificationHandler("notifications/stderr")
|
||||
await connection.transport.close()
|
||||
await connection.client.close()
|
||||
} catch (error) {
|
||||
@@ -438,7 +415,7 @@ export class McpHub {
|
||||
connection.server.status = "connecting"
|
||||
connection.server.error = ""
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
await delay(500) // artificial delay to show user that server is restarting
|
||||
await setTimeoutPromise(500) // artificial delay to show user that server is restarting
|
||||
try {
|
||||
await this.deleteConnection(serverName)
|
||||
// Try to connect again using existing config
|
||||
@@ -563,6 +540,7 @@ export class McpHub {
|
||||
if (connection.server.disabled) {
|
||||
throw new Error(`Server "${serverName}" is disabled`)
|
||||
}
|
||||
|
||||
return await connection.client.request(
|
||||
{
|
||||
method: "resources/read",
|
||||
@@ -586,6 +564,16 @@ export class McpHub {
|
||||
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
|
||||
}
|
||||
|
||||
let timeout = secondsToMs(DEFAULT_MCP_TIMEOUT_SECONDS) // sdk expects ms
|
||||
|
||||
try {
|
||||
const config = JSON.parse(connection.server.config)
|
||||
const parsedConfig = StdioConfigSchema.parse(config)
|
||||
timeout = secondsToMs(parsedConfig.timeout)
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse timeout configuration for server ${serverName}: ${error}`)
|
||||
}
|
||||
|
||||
return await connection.client.request(
|
||||
{
|
||||
method: "tools/call",
|
||||
@@ -595,6 +583,9 @@ export class McpHub {
|
||||
},
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{
|
||||
timeout,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -620,7 +611,6 @@ export class McpHub {
|
||||
autoApprove.splice(toolIndex, 1)
|
||||
}
|
||||
|
||||
// Write updated config back to file
|
||||
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
|
||||
|
||||
// Update the tools list to reflect the change
|
||||
@@ -663,6 +653,42 @@ export class McpHub {
|
||||
}
|
||||
}
|
||||
|
||||
public async updateServerTimeout(serverName: string, timeout: number): Promise<void> {
|
||||
try {
|
||||
// Validate timeout against schema
|
||||
const setConfigResult = StdioConfigSchema.shape.timeout.safeParse(timeout)
|
||||
if (!setConfigResult.success) {
|
||||
throw new Error(`Invalid timeout value: ${timeout}. Must be at minimum ${MIN_MCP_TIMEOUT_SECONDS} seconds.`)
|
||||
}
|
||||
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
|
||||
if (!config.mcpServers?.[serverName]) {
|
||||
throw new Error(`Server "${serverName}" not found in settings`)
|
||||
}
|
||||
|
||||
config.mcpServers[serverName] = {
|
||||
...config.mcpServers[serverName],
|
||||
timeout,
|
||||
}
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
|
||||
|
||||
await this.updateServerConnections(config.mcpServers)
|
||||
} catch (error) {
|
||||
console.error("Failed to update server timeout:", error)
|
||||
if (error instanceof Error) {
|
||||
console.error("Error details:", error.message, error.stack)
|
||||
}
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.removeAllFileWatchers()
|
||||
for (const connection of this.connections) {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { context, SpanKind, trace } from "@opentelemetry/api"
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
|
||||
import { Resource } from "@opentelemetry/resources"
|
||||
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
|
||||
export type TelemetryChatMessage = {
|
||||
role: "user" | "assistant" | "system"
|
||||
ts: number
|
||||
content: Anthropic.Messages.MessageParam["content"]
|
||||
}
|
||||
|
||||
interface ConversationMetadata {
|
||||
apiProvider?: string
|
||||
model?: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
}
|
||||
|
||||
const { IS_DEV } = process.env
|
||||
|
||||
/**
|
||||
Cline Telemetry (currently only available in DEV builds)
|
||||
|
||||
Advanced Setting to opt-in to LLM observability, allowing you to share message data, code, and more extensive telemetry to help improve prompts used in Cline, train our models, and understand failure states more accurately.
|
||||
|
||||
"cline.conversationTelemetry": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "Share message data, code, and more extensive telemetry. This data may be used to improve prompts used in Cline, train models, and understand failure states more accurately. [Learn more](https://docs.cline.bot/more-info/llm-observability)"
|
||||
}
|
||||
*/
|
||||
|
||||
export class ConversationTelemetryService {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private distinctId: string = vscode.env.machineId
|
||||
private apiEndpoint: string = "https://api.cline.bot/v1/traces"
|
||||
private tracerProvider: NodeTracerProvider | undefined
|
||||
private tracer: any
|
||||
private messageIndices: Map<string, number> = new Map()
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
}
|
||||
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.providerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
const { apiConfiguration } = await provider.getStateToPostToWebview()
|
||||
return apiConfiguration?.clineApiKey
|
||||
}
|
||||
|
||||
public isOptedInToConversationTelemetry(): boolean {
|
||||
// First check global telemetry level - telemetry should only be enabled when level is "all"
|
||||
const telemetryLevel = vscode.workspace.getConfiguration("telemetry").get<string>("telemetryLevel", "all")
|
||||
const isGlobalTelemetryEnabled = telemetryLevel === "all"
|
||||
|
||||
// User has to manually opt in to conversation telemetry in Advanced Settings
|
||||
const isConversationTelemetryEnabled =
|
||||
vscode.workspace.getConfiguration("cline").get<boolean>("conversationTelemetry") ?? false
|
||||
|
||||
// Currently only enabled in dev environment
|
||||
const isDevEnvironment = !!IS_DEV
|
||||
|
||||
return isDevEnvironment && isGlobalTelemetryEnabled && isConversationTelemetryEnabled
|
||||
}
|
||||
|
||||
private async initializeTracer() {
|
||||
try {
|
||||
// Create a resource that identifies our service
|
||||
const resource = new Resource({
|
||||
[ATTR_SERVICE_NAME]: "cline-extension",
|
||||
[ATTR_SERVICE_VERSION]: "1.0.0",
|
||||
})
|
||||
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
|
||||
console.log("[ConversationTelemetry] Initializing OpenTelemetry tracer...")
|
||||
|
||||
// Configure the OTLP exporter
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
// Add API key to headers if available
|
||||
if (clineApiKey) {
|
||||
headers["Authorization"] = `Bearer ${clineApiKey}`
|
||||
}
|
||||
|
||||
const exporter = new OTLPTraceExporter({
|
||||
url: this.apiEndpoint,
|
||||
headers,
|
||||
})
|
||||
|
||||
// Create the span processor
|
||||
const spanProcessor = new SimpleSpanProcessor(exporter as any)
|
||||
|
||||
// Create the trace provider with the span processor in the config
|
||||
this.tracerProvider = new NodeTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [spanProcessor as any],
|
||||
})
|
||||
|
||||
// Register the provider
|
||||
this.tracerProvider.register()
|
||||
|
||||
// Get a tracer
|
||||
this.tracer = trace.getTracer("cline-conversation-tracer")
|
||||
|
||||
console.log("[ConversationTelemetry] OpenTelemetry tracer initialized successfully")
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Failed to initialize OpenTelemetry tracer:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a message in the conversation as an OpenTelemetry span
|
||||
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
|
||||
*/
|
||||
public async captureMessage(taskId: string, message: TelemetryChatMessage, metadata: ConversationMetadata) {
|
||||
// Do NOT capture message if user has not explicitly opted in
|
||||
if (!this.isOptedInToConversationTelemetry()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.tracer) {
|
||||
await this.initializeTracer()
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert taskId to a valid trace ID (must be 32 hex chars)
|
||||
const traceId = this.generateTraceIdFromTimestamp(taskId)
|
||||
|
||||
// Convert message timestamp to a valid span ID (must be 16 hex chars)
|
||||
if (!message.ts && message.ts !== 0) {
|
||||
throw new Error("Message timestamp is required")
|
||||
}
|
||||
|
||||
const timestamp = message.ts
|
||||
const spanId = this.generateSpanIdFromTimestamp(timestamp)
|
||||
|
||||
// Create a span context with our IDs
|
||||
const spanContext = trace.setSpanContext(context.active(), {
|
||||
traceId,
|
||||
spanId,
|
||||
isRemote: false,
|
||||
traceFlags: 1, // Sampled
|
||||
})
|
||||
|
||||
// Start a new span with the context
|
||||
const span = this.tracer.startSpan(
|
||||
`message.${message.role}`,
|
||||
{
|
||||
kind: SpanKind.CLIENT,
|
||||
startTime: this.millisecondsToHrTime(timestamp), // Convert to nanoseconds
|
||||
},
|
||||
spanContext,
|
||||
)
|
||||
|
||||
// Get the message index for this task
|
||||
const messageIndex = this.getNextMessageIndex(taskId)
|
||||
|
||||
// Add attributes to the span
|
||||
span.setAttribute("task.id", taskId)
|
||||
span.setAttribute("user.id", this.distinctId)
|
||||
span.setAttribute("message.role", message.role)
|
||||
span.setAttribute("message.timestamp", timestamp)
|
||||
span.setAttribute("message.index", messageIndex)
|
||||
|
||||
const c = message.content
|
||||
|
||||
// Add Braintrust-compatible attributes
|
||||
span.setAttribute("gen_ai.request.model", metadata.model)
|
||||
|
||||
if (message.role === "user") {
|
||||
span.setAttribute("gen_ai.prompt", this.extractContent(message))
|
||||
} else if (message.role === "assistant") {
|
||||
span.setAttribute("gen_ai.completion", this.extractContent(message))
|
||||
span.setAttribute("gen_ai.usage.prompt_tokens", metadata.tokensIn)
|
||||
span.setAttribute("gen_ai.usage.completion_tokens", metadata.tokensOut)
|
||||
} else if (message.role === "system") {
|
||||
span.setAttribute("gen_ai.system_prompt", this.extractContent(message))
|
||||
}
|
||||
|
||||
// Add custom metadata in Braintrust format
|
||||
span.setAttribute("braintrust.metadata.api_provider", metadata.apiProvider)
|
||||
span.setAttribute("braintrust.metadata.ts", message.ts)
|
||||
|
||||
// End the span immediately since messages are discrete events
|
||||
span.end(this.millisecondsToHrTime(timestamp)) // Convert to nanoseconds
|
||||
|
||||
console.log(`[ConversationTelemetry] Captured ${message.role} message for task ${taskId}`, { span })
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Error capturing message:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decimal timestamp to a valid trace ID (32 hex chars)
|
||||
*/
|
||||
private generateTraceIdFromTimestamp(timestamp: string): string {
|
||||
// Pad with zeros and convert to hex
|
||||
const hex = BigInt(timestamp).toString(16).padStart(32, "0")
|
||||
return hex.substring(0, 32) // Ensure it's exactly 32 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts milliseconds to high-resolution time format expected by OpenTelemetry
|
||||
* Returns [seconds, nanoseconds]
|
||||
*/
|
||||
private millisecondsToHrTime(milliseconds: number): [number, number] {
|
||||
return [
|
||||
Math.floor(milliseconds / 1000), // seconds
|
||||
(milliseconds % 1000) * 1000000, // nanoseconds (remainder in ms * 10^6)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a decimal timestamp to a valid span ID (16 hex chars)
|
||||
*/
|
||||
private generateSpanIdFromTimestamp(timestamp: number): string {
|
||||
// Pad with zeros and convert to hex
|
||||
const hex = BigInt(timestamp).toString(16).padStart(16, "0")
|
||||
return hex.substring(0, 16) // Ensure it's exactly 16 chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to extract content from different message formats
|
||||
*/
|
||||
private extractContent(message: TelemetryChatMessage): string {
|
||||
if (typeof message.content === "string") {
|
||||
return message.content
|
||||
}
|
||||
|
||||
return message.content
|
||||
.map((block) => (block.type === "text" ? block.text : null))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Track message indices per task
|
||||
*/
|
||||
private getNextMessageIndex(taskId: string): number {
|
||||
const currentIndex = this.messageIndices.get(taskId) || 0
|
||||
this.messageIndices.set(taskId, currentIndex + 1)
|
||||
return currentIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends conversation data to cleanup endpoint to remove deleted messages from telemetry
|
||||
* ONLY HAPPENS IF USER IS OPTED INTO CONVERSATION TELEMETRY IN ADVANCED SETTINGS
|
||||
*/
|
||||
public async cleanupTask(taskId: string, conversationData: any): Promise<void> {
|
||||
// Do NOT send data if user has not explicitly opted in
|
||||
if (!this.isOptedInToConversationTelemetry()) {
|
||||
return
|
||||
}
|
||||
|
||||
const clineApiKey = await this.getClineApiKey()
|
||||
if (!clineApiKey) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Configure the headers with API key
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
// Add API key to headers
|
||||
headers["Authorization"] = `Bearer ${clineApiKey}`
|
||||
|
||||
// Send the data to the cleanup endpoint
|
||||
const cleanupEndpoint = `${this.apiEndpoint.replace("/traces", "/traces/cleanup")}`
|
||||
|
||||
// Use fetch API to send the data
|
||||
const response = await fetch(cleanupEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
taskId: taskId,
|
||||
conversationData,
|
||||
userId: this.distinctId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to send cleanup data: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`[ConversationTelemetry] Cleanup data sent for task ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error("[ConversationTelemetry] Error sending cleanup data:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the tracer provider
|
||||
*/
|
||||
public async shutdown(): Promise<void> {
|
||||
if (this.tracerProvider) {
|
||||
await this.tracerProvider.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,78 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import * as vscode from "vscode"
|
||||
import { version as extensionVersion } from "../../../package.json"
|
||||
|
||||
/**
|
||||
* PostHogClient handles telemetry event tracking for the Cline extension
|
||||
* Uses PostHog analytics to track user interactions and system events
|
||||
* Respects user privacy settings and VSCode's global telemetry configuration
|
||||
*/
|
||||
class PostHogClient {
|
||||
private static instance: PostHogClient
|
||||
private client: PostHog
|
||||
private distinctId: string = vscode.env.machineId
|
||||
private telemetryEnabled: boolean = false
|
||||
// Event constants for tracking user interactions and system events
|
||||
private static readonly EVENTS = {
|
||||
// Task-related events for tracking conversation and execution flow
|
||||
TASK: {
|
||||
// Tracks when a new task/conversation is started
|
||||
CREATED: "task.created",
|
||||
// Tracks when a task is reopened
|
||||
RESTARTED: "task.restarted",
|
||||
// Tracks when a task is finished, with acceptance or rejection status
|
||||
COMPLETED: "task.completed",
|
||||
// Tracks when a message is sent in a conversation
|
||||
CONVERSATION_TURN: "task.conversation_turn",
|
||||
// Tracks token consumption for cost and usage analysis
|
||||
TOKEN_USAGE: "task.tokens",
|
||||
// Tracks switches between plan and act modes
|
||||
MODE_SWITCH: "task.mode",
|
||||
// Tracks usage of the git-based checkpoint system (shadow_git_initialized, commit_created, branch_created, branch_deleted_active, branch_deleted_inactive, restored)
|
||||
CHECKPOINT_USED: "task.checkpoint_used",
|
||||
// Tracks when tools (like file operations, commands) are used
|
||||
TOOL_USED: "task.tool_used",
|
||||
// Tracks when a historical task is loaded from storage
|
||||
HISTORICAL_LOADED: "task.historical_loaded",
|
||||
// Tracks when the retry button is clicked for failed operations
|
||||
RETRY_CLICKED: "task.retry_clicked",
|
||||
},
|
||||
// UI interaction events for tracking user engagement
|
||||
UI: {
|
||||
// Tracks when user switches between API providers
|
||||
PROVIDER_SWITCH: "ui.provider_switch",
|
||||
// Tracks when images are attached to a conversation
|
||||
IMAGE_ATTACHED: "ui.image_attached",
|
||||
// Tracks general button click interactions
|
||||
BUTTON_CLICK: "ui.button_click",
|
||||
// Tracks when the marketplace view is opened
|
||||
MARKETPLACE_OPENED: "ui.marketplace_opened",
|
||||
// Tracks when settings panel is opened
|
||||
SETTINGS_OPENED: "ui.settings_opened",
|
||||
// Tracks when task history view is opened
|
||||
HISTORY_OPENED: "ui.history_opened",
|
||||
// Tracks when a task is removed from history
|
||||
TASK_POPPED: "ui.task_popped",
|
||||
// Tracks when a different model is selected
|
||||
MODEL_SELECTED: "ui.model_selected",
|
||||
// Tracks when planning mode is toggled on
|
||||
PLAN_MODE_TOGGLED: "ui.plan_mode_toggled",
|
||||
// Tracks when action mode is toggled on
|
||||
ACT_MODE_TOGGLED: "ui.act_mode_toggled",
|
||||
},
|
||||
}
|
||||
|
||||
/** Singleton instance of the PostHogClient */
|
||||
private static instance: PostHogClient
|
||||
/** PostHog client instance for sending analytics events */
|
||||
private client: PostHog
|
||||
/** Unique identifier for the current VSCode instance */
|
||||
private distinctId: string = vscode.env.machineId
|
||||
/** Whether telemetry is currently enabled based on user and VSCode settings */
|
||||
private telemetryEnabled: boolean = false
|
||||
/** Current version of the extension */
|
||||
private readonly version: string = extensionVersion
|
||||
|
||||
/**
|
||||
* Private constructor to enforce singleton pattern
|
||||
* Initializes PostHog client with configuration
|
||||
*/
|
||||
private constructor() {
|
||||
this.client = new PostHog("phc_qfOAGxZw2TL5O8p9KYd9ak3bPBFzfjC8fy5L6jNWY7K", {
|
||||
host: "https://us.i.posthog.com",
|
||||
@@ -14,6 +80,11 @@ class PostHogClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the telemetry state based on user preferences and VSCode settings
|
||||
* Only enables telemetry if both VSCode global telemetry is enabled and user has opted in
|
||||
* @param didUserOptIn Whether the user has explicitly opted into telemetry
|
||||
*/
|
||||
public updateTelemetryState(didUserOptIn: boolean): void {
|
||||
this.telemetryEnabled = false
|
||||
|
||||
@@ -26,17 +97,18 @@ class PostHogClient {
|
||||
this.telemetryEnabled = didUserOptIn
|
||||
}
|
||||
|
||||
// Update PostHog client state based on telemetry preference and use machineId to tie it to the webview
|
||||
// Update PostHog client state based on telemetry preference
|
||||
if (this.telemetryEnabled) {
|
||||
this.client.optIn()
|
||||
this.client.identify({ distinctId: this.distinctId })
|
||||
// console.log("Telemetry enabled")
|
||||
} else {
|
||||
this.client.optOut()
|
||||
// console.log("Telemetry disabled")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or creates the singleton instance of PostHogClient
|
||||
* @returns The PostHogClient instance
|
||||
*/
|
||||
public static getInstance(): PostHogClient {
|
||||
if (!PostHogClient.instance) {
|
||||
PostHogClient.instance = new PostHogClient()
|
||||
@@ -44,14 +116,310 @@ class PostHogClient {
|
||||
return PostHogClient.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a telemetry event if telemetry is enabled
|
||||
* @param event The event to capture with its properties
|
||||
*/
|
||||
public capture(event: { event: string; properties?: any }): void {
|
||||
// Only send events if telemetry is enabled
|
||||
if (this.telemetryEnabled) {
|
||||
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: event.properties })
|
||||
// console.log("Captured event", { distinctId: this.distinctId, event: event.event, properties: event.properties })
|
||||
// Include extension version in all event properties
|
||||
const propertiesWithVersion = {
|
||||
...event.properties,
|
||||
extension_version: this.version,
|
||||
}
|
||||
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: propertiesWithVersion })
|
||||
}
|
||||
}
|
||||
|
||||
// Task events
|
||||
/**
|
||||
* Records when a new task/conversation is started
|
||||
* @param taskId Unique identifier for the new task
|
||||
*/
|
||||
public captureTaskCreated(taskId: string, apiProvider?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.CREATED,
|
||||
properties: { taskId, apiProvider },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a task/conversation is restarted
|
||||
* @param taskId Unique identifier for the new task
|
||||
*/
|
||||
public captureTaskRestarted(taskId: string, apiProvider?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.RESTARTED,
|
||||
properties: { taskId, apiProvider },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when cline calls the task completion_result tool signifying that cline is done with the task
|
||||
* @param taskId Unique identifier for the task
|
||||
*/
|
||||
public captureTaskCompleted(taskId: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.COMPLETED,
|
||||
properties: { taskId },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures that a message was sent, and includes the API provider and model used
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param provider The API provider (e.g., OpenAI, Anthropic)
|
||||
* @param model The specific model used (e.g., GPT-4, Claude)
|
||||
* @param source The source of the message ("user" | "model"). Used to track message patterns and identify when users need to correct the model's responses.
|
||||
*/
|
||||
public captureConversationTurnEvent(
|
||||
taskId: string,
|
||||
provider: string = "unknown",
|
||||
model: string = "unknown",
|
||||
source: "user" | "assistant",
|
||||
) {
|
||||
// Ensure required parameters are provided
|
||||
if (!taskId || !provider || !model || !source) {
|
||||
console.warn("TelemetryService: Missing required parameters for message capture")
|
||||
return
|
||||
}
|
||||
|
||||
const properties: Record<string, any> = {
|
||||
taskId,
|
||||
provider,
|
||||
model,
|
||||
source,
|
||||
timestamp: new Date().toISOString(), // Add timestamp for message sequencing
|
||||
}
|
||||
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.CONVERSATION_TURN,
|
||||
properties,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO
|
||||
* Records token usage metrics for cost tracking and usage analysis
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param tokensIn Number of input tokens consumed
|
||||
* @param tokensOut Number of output tokens generated
|
||||
* @param model The model used for token calculation
|
||||
*/
|
||||
public captureTokenUsage(taskId: string, tokensIn: number, tokensOut: number, model: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.TOKEN_USAGE,
|
||||
properties: {
|
||||
taskId,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
model,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a task switches between plan and act modes
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param mode The mode being switched to (plan or act)
|
||||
*/
|
||||
public captureModeSwitch(taskId: string, mode: "plan" | "act") {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.MODE_SWITCH,
|
||||
properties: {
|
||||
taskId,
|
||||
mode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Tool events
|
||||
/**
|
||||
* Records when a tool is used during task execution
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param tool Name of the tool being used
|
||||
* @param autoApproved Whether the tool was auto-approved based on settings
|
||||
* @param success Whether the tool execution was successful
|
||||
*/
|
||||
public captureToolUsage(taskId: string, tool: string, autoApproved: boolean, success: boolean) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.TOOL_USED,
|
||||
properties: {
|
||||
taskId,
|
||||
tool,
|
||||
autoApproved,
|
||||
success,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records interactions with the git-based checkpoint system
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param action The type of checkpoint action
|
||||
* @param durationMs Optional duration of the operation in milliseconds
|
||||
*/
|
||||
public captureCheckpointUsage(
|
||||
taskId: string,
|
||||
action: "shadow_git_initialized" | "commit_created" | "restored" | "diff_generated",
|
||||
durationMs?: number,
|
||||
) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.CHECKPOINT_USED,
|
||||
properties: {
|
||||
taskId,
|
||||
action,
|
||||
durationMs,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// UI events
|
||||
/**
|
||||
* Records when the user switches between different API providers
|
||||
* @param from Previous provider name
|
||||
* @param to New provider name
|
||||
* @param location Where the switch occurred (settings panel or bottom bar)
|
||||
* @param taskId Optional task identifier if switch occurred during a task
|
||||
*/
|
||||
public captureProviderSwitch(from: string, to: string, location: "settings" | "bottom", taskId?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.PROVIDER_SWITCH,
|
||||
properties: {
|
||||
from,
|
||||
to,
|
||||
location,
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when images are attached to a conversation
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param imageCount Number of images attached
|
||||
*/
|
||||
public captureImageAttached(taskId: string, imageCount: number) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.IMAGE_ATTACHED,
|
||||
properties: {
|
||||
taskId,
|
||||
imageCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records general button click interactions in the UI
|
||||
* @param button Identifier for the button that was clicked
|
||||
* @param taskId Optional task identifier if click occurred during a task
|
||||
*/
|
||||
public captureButtonClick(button: string, taskId?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.BUTTON_CLICK,
|
||||
properties: {
|
||||
button,
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when the marketplace view is opened
|
||||
* @param taskId Optional task identifier if marketplace was opened during a task
|
||||
*/
|
||||
public captureMarketplaceOpened(taskId?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.MARKETPLACE_OPENED,
|
||||
properties: {
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when the settings panel is opened
|
||||
* @param taskId Optional task identifier if settings were opened during a task
|
||||
*/
|
||||
public captureSettingsOpened(taskId?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.SETTINGS_OPENED,
|
||||
properties: {
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when the task history view is opened
|
||||
* @param taskId Optional task identifier if history was opened during a task
|
||||
*/
|
||||
public captureHistoryOpened(taskId?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.HISTORY_OPENED,
|
||||
properties: {
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a task is removed from the task history
|
||||
* @param taskId Unique identifier for the task being removed
|
||||
*/
|
||||
public captureTaskPopped(taskId: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.TASK_POPPED,
|
||||
properties: {
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a different model is selected for use
|
||||
* @param model Name of the selected model
|
||||
* @param provider Provider of the selected model
|
||||
* @param taskId Optional task identifier if model was selected during a task
|
||||
*/
|
||||
public captureModelSelected(model: string, provider: string, taskId?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.UI.MODEL_SELECTED,
|
||||
properties: {
|
||||
model,
|
||||
provider,
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a historical task is loaded from storage
|
||||
* @param taskId Unique identifier for the historical task
|
||||
*/
|
||||
public captureHistoricalTaskLoaded(taskId: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.HISTORICAL_LOADED,
|
||||
properties: {
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when the retry button is clicked for failed operations
|
||||
* @param taskId Unique identifier for the task being retried
|
||||
*/
|
||||
public captureRetryClicked(taskId: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.RETRY_CLICKED,
|
||||
properties: {
|
||||
taskId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public isTelemetryEnabled(): boolean {
|
||||
return this.telemetryEnabled
|
||||
}
|
||||
@@ -61,5 +429,4 @@ class PostHogClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Export a single instance
|
||||
export const telemetryService = PostHogClient.getInstance()
|
||||
|
||||
@@ -84,6 +84,8 @@ function separateFiles(allFiles: string[]): {
|
||||
"java",
|
||||
"php",
|
||||
"swift",
|
||||
// Kotlin
|
||||
"kt",
|
||||
].map((e) => `.${e}`)
|
||||
const filesToParse = allFiles.filter((file) => extensions.includes(path.extname(file))).slice(0, 50) // 50 files max
|
||||
const remainingFiles = allFiles.filter((file) => !filesToParse.includes(file))
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
javaQuery,
|
||||
phpQuery,
|
||||
swiftQuery,
|
||||
kotlinQuery,
|
||||
} from "./queries"
|
||||
|
||||
export interface LanguageParser {
|
||||
@@ -120,6 +121,10 @@ export async function loadRequiredLanguageParsers(filesToParse: string[]): Promi
|
||||
language = await loadLanguage("swift")
|
||||
query = language.query(swiftQuery)
|
||||
break
|
||||
case "kt":
|
||||
language = await loadLanguage("kotlin")
|
||||
query = language.query(kotlinQuery)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported language: ${ext}`)
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export { default as cQuery } from "./c"
|
||||
export { default as csharpQuery } from "./c-sharp"
|
||||
export { default as goQuery } from "./go"
|
||||
export { default as swiftQuery } from "./swift"
|
||||
export { default as kotlinQuery } from "./kotlin"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export default `
|
||||
(class_declaration
|
||||
name: (simple_identifier) @name.definition.class) @definition.class
|
||||
|
||||
(function_declaration
|
||||
name: (simple_identifier) @name.definition.function) @definition.function
|
||||
|
||||
(interface_declaration
|
||||
name: (simple_identifier) @name.definition.interface) @definition.interface
|
||||
|
||||
(object_declaration
|
||||
name: (simple_identifier) @name.definition.object) @definition.object
|
||||
|
||||
(property_declaration
|
||||
name: (simple_identifier) @name.definition.property) @definition.property
|
||||
|
||||
(enum_declaration
|
||||
name: (simple_identifier) @name.definition.enum) @definition.enum
|
||||
|
||||
(typealias_declaration
|
||||
name: (simple_identifier) @name.definition.typealias) @definition.typealias
|
||||
)`
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface BalanceResponse {
|
||||
currentBalance: number
|
||||
}
|
||||
|
||||
export interface UsageTransaction {
|
||||
spentAt: string
|
||||
credits: string
|
||||
modelProvider: string
|
||||
model: string
|
||||
promptTokens: string
|
||||
completionTokens: string
|
||||
}
|
||||
|
||||
export interface PaymentTransaction {
|
||||
paidAt: string
|
||||
amountCents: string
|
||||
credits: string
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ChatSettings } from "./ChatSettings"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer, McpMarketplaceCatalog, McpMarketplaceItem, McpDownloadResponse } from "./mcp"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
import type { BalanceResponse, UsageTransaction, PaymentTransaction } from "../shared/ClineAccount"
|
||||
|
||||
// webview will hold state
|
||||
export interface ExtensionMessage {
|
||||
@@ -27,12 +28,18 @@ export interface ExtensionMessage {
|
||||
| "relinquishControl"
|
||||
| "vsCodeLmModels"
|
||||
| "requestVsCodeLmModels"
|
||||
| "emailSubscribed"
|
||||
| "authCallback"
|
||||
| "mcpMarketplaceCatalog"
|
||||
| "mcpDownloadDetails"
|
||||
| "commitSearchResults"
|
||||
| "openGraphData"
|
||||
| "isImageUrlResult"
|
||||
| "didUpdateSettings"
|
||||
| "userCreditsBalance"
|
||||
| "userCreditsUsage"
|
||||
| "userCreditsPayments"
|
||||
| "totalTasksSize"
|
||||
| "addToInput"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
@@ -42,7 +49,8 @@ export interface ExtensionMessage {
|
||||
| "didBecomeVisible"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
invoke?: "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
|
||||
| "accountButtonClicked"
|
||||
invoke?: Invoke
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
ollamaModels?: string[]
|
||||
@@ -53,6 +61,7 @@ export interface ExtensionMessage {
|
||||
openRouterModels?: Record<string, ModelInfo>
|
||||
openAiModels?: string[]
|
||||
mcpServers?: McpServer[]
|
||||
customToken?: string
|
||||
mcpMarketplaceCatalog?: McpMarketplaceCatalog
|
||||
error?: string
|
||||
mcpDownloadDetails?: McpDownloadResponse
|
||||
@@ -67,34 +76,40 @@ export interface ExtensionMessage {
|
||||
}
|
||||
url?: string
|
||||
isImage?: boolean
|
||||
userCreditsBalance?: BalanceResponse
|
||||
userCreditsUsage?: UsageTransaction[]
|
||||
userCreditsPayments?: PaymentTransaction[]
|
||||
totalTasksSize?: number | null
|
||||
}
|
||||
|
||||
export type Invoke = "sendMessage" | "primaryButtonClick" | "secondaryButtonClick"
|
||||
|
||||
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"
|
||||
|
||||
export const DEFAULT_PLATFORM = "unknown"
|
||||
|
||||
export interface ExtensionState {
|
||||
version: string
|
||||
apiConfiguration?: ApiConfiguration
|
||||
customInstructions?: string
|
||||
uriScheme?: string
|
||||
currentTaskItem?: HistoryItem
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
taskHistory: HistoryItem[]
|
||||
shouldShowAnnouncement: boolean
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
browserSettings: BrowserSettings
|
||||
chatSettings: ChatSettings
|
||||
isLoggedIn: boolean
|
||||
checkpointTrackerErrorMessage?: string
|
||||
clineMessages: ClineMessage[]
|
||||
currentTaskItem?: HistoryItem
|
||||
customInstructions?: string
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
planActSeparateModelsSetting: boolean
|
||||
platform: Platform
|
||||
shouldShowAnnouncement: boolean
|
||||
taskHistory: HistoryItem[]
|
||||
telemetrySetting: TelemetrySetting
|
||||
uriScheme?: string
|
||||
userInfo?: {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
mcpMarketplaceEnabled?: boolean
|
||||
telemetrySetting: TelemetrySetting
|
||||
version: string
|
||||
vscMachineId: string
|
||||
}
|
||||
|
||||
@@ -195,6 +210,18 @@ export interface ClineAskUseMcpServer {
|
||||
uri?: string
|
||||
}
|
||||
|
||||
export interface ClinePlanModeResponse {
|
||||
response: string
|
||||
options?: string[]
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface ClineAskQuestion {
|
||||
question: string
|
||||
options?: string[]
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface ClineApiReqInfo {
|
||||
request?: string
|
||||
tokensIn?: number
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface UserInfo {
|
||||
displayName: string | null
|
||||
email: string | null
|
||||
photoURL: string | null
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { ApiConfiguration } from "./api"
|
||||
import { AutoApprovalSettings } from "./AutoApprovalSettings"
|
||||
import { BrowserSettings } from "./BrowserSettings"
|
||||
import { ChatSettings } from "./ChatSettings"
|
||||
import { UserInfo } from "./UserInfo"
|
||||
import { ChatContent } from "./ChatContent"
|
||||
import { TelemetrySetting } from "./TelemetrySetting"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "apiConfiguration"
|
||||
| "customInstructions"
|
||||
| "webviewDidLaunch"
|
||||
| "newTask"
|
||||
| "askResponse"
|
||||
@@ -44,7 +45,9 @@ export interface WebviewMessage {
|
||||
| "getLatestState"
|
||||
| "accountLoginClicked"
|
||||
| "accountLogoutClicked"
|
||||
| "subscribeEmail"
|
||||
| "showAccountViewClicked"
|
||||
| "authStateChanged"
|
||||
| "authCallback"
|
||||
| "fetchMcpMarketplace"
|
||||
| "downloadMcp"
|
||||
| "silentlyRefreshMcpMarketplace"
|
||||
@@ -56,6 +59,12 @@ export interface WebviewMessage {
|
||||
| "updateMcpTimeout"
|
||||
| "fetchOpenGraphData"
|
||||
| "checkIsImageUrl"
|
||||
| "invoke"
|
||||
| "updateSettings"
|
||||
| "clearAllTaskHistory"
|
||||
| "fetchUserCreditsData"
|
||||
| "optionsResponse"
|
||||
| "requestTotalTasksSize"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
@@ -69,14 +78,20 @@ export interface WebviewMessage {
|
||||
chatSettings?: ChatSettings
|
||||
chatContent?: ChatContent
|
||||
mcpId?: string
|
||||
|
||||
timeout?: number
|
||||
// For toggleToolAutoApprove
|
||||
serverName?: string
|
||||
toolName?: string
|
||||
autoApprove?: boolean
|
||||
|
||||
// For auth
|
||||
user?: UserInfo | null
|
||||
customToken?: string
|
||||
// For openInBrowser
|
||||
url?: string
|
||||
planActSeparateModelsSetting?: boolean
|
||||
telemetrySetting?: TelemetrySetting
|
||||
customInstructionsSetting?: string
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
+535
-8
@@ -14,12 +14,16 @@ export type ApiProvider =
|
||||
| "qwen"
|
||||
| "mistral"
|
||||
| "vscode-lm"
|
||||
| "cline"
|
||||
| "litellm"
|
||||
| "asksage"
|
||||
| "xai"
|
||||
| "sambanova"
|
||||
|
||||
export interface ApiHandlerOptions {
|
||||
apiModelId?: string
|
||||
apiKey?: string // anthropic
|
||||
clineApiKey?: string
|
||||
liteLlmBaseUrl?: string
|
||||
liteLlmModelId?: string
|
||||
liteLlmApiKey?: string
|
||||
@@ -27,21 +31,25 @@ export interface ApiHandlerOptions {
|
||||
openRouterApiKey?: string
|
||||
openRouterModelId?: string
|
||||
openRouterModelInfo?: ModelInfo
|
||||
openRouterProviderSorting?: string
|
||||
awsAccessKey?: string
|
||||
awsSecretKey?: string
|
||||
awsSessionToken?: string
|
||||
awsRegion?: string
|
||||
awsUseCrossRegionInference?: boolean
|
||||
awsBedrockUsePromptCache?: boolean
|
||||
awsUseProfile?: boolean
|
||||
awsProfile?: string
|
||||
awsBedrockEndpoint?: string
|
||||
vertexProjectId?: string
|
||||
vertexRegion?: string
|
||||
openAiBaseUrl?: string
|
||||
openAiApiKey?: string
|
||||
openAiModelId?: string
|
||||
openAiModelInfo?: ModelInfo
|
||||
openAiModelInfo?: OpenAiCompatibleModelInfo
|
||||
ollamaModelId?: string
|
||||
ollamaBaseUrl?: string
|
||||
ollamaApiOptionsCtxNum?: string
|
||||
lmStudioModelId?: string
|
||||
lmStudioBaseUrl?: string
|
||||
geminiApiKey?: string
|
||||
@@ -57,8 +65,11 @@ export interface ApiHandlerOptions {
|
||||
vsCodeLmModelSelector?: any
|
||||
o3MiniReasoningEffort?: string
|
||||
qwenApiLine?: string
|
||||
asksageApiUrl?: string
|
||||
asksageApiKey?: string
|
||||
xaiApiKey?: string
|
||||
thinkingBudgetTokens?: number
|
||||
sambanovaApiKey?: string
|
||||
}
|
||||
|
||||
export type ApiConfiguration = ApiHandlerOptions & {
|
||||
@@ -80,6 +91,10 @@ export interface ModelInfo {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface OpenAiCompatibleModelInfo extends ModelInfo {
|
||||
temperature?: number
|
||||
}
|
||||
|
||||
// Anthropic
|
||||
// https://docs.anthropic.com/en/docs/about-claude/models // prices updated 2025-01-02
|
||||
export type AnthropicModelId = keyof typeof anthropicModels
|
||||
@@ -144,6 +159,33 @@ export const anthropicModels = {
|
||||
export type BedrockModelId = keyof typeof bedrockModels
|
||||
export const bedrockDefaultModelId: BedrockModelId = "anthropic.claude-3-7-sonnet-20250219-v1:0"
|
||||
export const bedrockModels = {
|
||||
"amazon.nova-pro-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 3.2,
|
||||
},
|
||||
"amazon.nova-lite-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.06,
|
||||
outputPrice: 0.24,
|
||||
},
|
||||
"amazon.nova-micro-v1:0": {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsComputerUse: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.035,
|
||||
outputPrice: 0.14,
|
||||
},
|
||||
"anthropic.claude-3-7-sonnet-20250219-v1:0": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
@@ -160,17 +202,21 @@ export const bedrockModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
cacheReadsPrice: 0.3,
|
||||
},
|
||||
"anthropic.claude-3-5-haiku-20241022-v1:0": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 1.0,
|
||||
outputPrice: 5.0,
|
||||
cacheWritesPrice: 1.0,
|
||||
cacheReadsPrice: 0.08,
|
||||
},
|
||||
"anthropic.claude-3-5-sonnet-20240620-v1:0": {
|
||||
maxTokens: 8192,
|
||||
@@ -204,6 +250,14 @@ export const bedrockModels = {
|
||||
inputPrice: 0.25,
|
||||
outputPrice: 1.25,
|
||||
},
|
||||
"deepseek.r1-v1:0": {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.35,
|
||||
outputPrice: 5.4,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// OpenRouter
|
||||
@@ -288,15 +342,104 @@ export const vertexModels = {
|
||||
cacheWritesPrice: 0.3,
|
||||
cacheReadsPrice: 0.03,
|
||||
},
|
||||
"gemini-2.0-flash-001": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.4,
|
||||
},
|
||||
"gemini-2.0-flash-thinking-exp-1219": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 32_767,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-2.0-flash-exp": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-2.0-pro-exp-02-05": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-2.0-flash-thinking-exp-01-21": {
|
||||
maxTokens: 65_536,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-exp-1206": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-1.5-flash-002": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-1.5-flash-exp-0827": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-1.5-flash-8b-exp-0827": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-1.5-pro-002": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-1.5-pro-exp-0827": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const openAiModelInfoSaneDefaults: ModelInfo = {
|
||||
export const openAiModelInfoSaneDefaults: OpenAiCompatibleModelInfo = {
|
||||
maxTokens: -1,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
temperature: 0,
|
||||
}
|
||||
|
||||
// Gemini
|
||||
@@ -462,11 +605,19 @@ export const openAiNativeModels = {
|
||||
outputPrice: 0.6,
|
||||
cacheReadsPrice: 0.075,
|
||||
},
|
||||
"gpt-4.5-preview": {
|
||||
"chatgpt-4o-latest": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 5,
|
||||
outputPrice: 15,
|
||||
},
|
||||
"gpt-4.5-preview": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 75,
|
||||
outputPrice: 150,
|
||||
},
|
||||
@@ -506,9 +657,11 @@ export const deepSeekModels = {
|
||||
|
||||
// Qwen
|
||||
// https://bailian.console.aliyun.com/
|
||||
export type QwenModelId = keyof typeof qwenModels
|
||||
export const qwenDefaultModelId: QwenModelId = "qwen-coder-plus-latest"
|
||||
export const qwenModels = {
|
||||
export type MainlandQwenModelId = keyof typeof mainlandQwenModels
|
||||
export type InternationalQwenModelId = keyof typeof internationalQwenModels
|
||||
export const internationalQwenDefaultModelId: InternationalQwenModelId = "qwen-coder-plus-latest"
|
||||
export const mainlandQwenDefaultModelId: MainlandQwenModelId = "qwen-coder-plus-latest"
|
||||
export const internationalQwenModels = {
|
||||
"qwen2.5-coder-32b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_072,
|
||||
@@ -711,6 +864,229 @@ export const qwenModels = {
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const mainlandQwenModels = {
|
||||
"qwen2.5-coder-32b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.002,
|
||||
outputPrice: 0.006,
|
||||
cacheWritesPrice: 0.002,
|
||||
cacheReadsPrice: 0.006,
|
||||
},
|
||||
"qwen2.5-coder-14b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.002,
|
||||
outputPrice: 0.006,
|
||||
cacheWritesPrice: 0.002,
|
||||
cacheReadsPrice: 0.006,
|
||||
},
|
||||
"qwen2.5-coder-7b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.001,
|
||||
outputPrice: 0.002,
|
||||
cacheWritesPrice: 0.001,
|
||||
cacheReadsPrice: 0.002,
|
||||
},
|
||||
"qwen2.5-coder-3b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwen2.5-coder-1.5b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwen2.5-coder-0.5b-instruct": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwen-coder-plus-latest": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.5,
|
||||
outputPrice: 7,
|
||||
cacheWritesPrice: 3.5,
|
||||
cacheReadsPrice: 7,
|
||||
},
|
||||
"qwen-plus-latest": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 2,
|
||||
cacheWritesPrice: 0.8,
|
||||
cacheReadsPrice: 0.2,
|
||||
},
|
||||
"qwen-turbo-latest": {
|
||||
maxTokens: 1_000_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 2,
|
||||
cacheWritesPrice: 0.8,
|
||||
cacheReadsPrice: 2,
|
||||
},
|
||||
"qwen-max-latest": {
|
||||
maxTokens: 30_720,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 2.4,
|
||||
outputPrice: 9.6,
|
||||
cacheWritesPrice: 2.4,
|
||||
cacheReadsPrice: 9.6,
|
||||
},
|
||||
"qwq-plus-latest": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_071,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwq-plus": {
|
||||
maxTokens: 8_192,
|
||||
contextWindow: 131_071,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.0,
|
||||
outputPrice: 0.0,
|
||||
cacheWritesPrice: 0.0,
|
||||
cacheReadsPrice: 0.0,
|
||||
},
|
||||
"qwen-coder-plus": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3.5,
|
||||
outputPrice: 7,
|
||||
cacheWritesPrice: 3.5,
|
||||
cacheReadsPrice: 7,
|
||||
},
|
||||
"qwen-plus": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.8,
|
||||
outputPrice: 2,
|
||||
cacheWritesPrice: 0.8,
|
||||
cacheReadsPrice: 0.2,
|
||||
},
|
||||
"qwen-turbo": {
|
||||
maxTokens: 1_000_000,
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 0.6,
|
||||
cacheWritesPrice: 0.3,
|
||||
cacheReadsPrice: 0.6,
|
||||
},
|
||||
"qwen-max": {
|
||||
maxTokens: 30_720,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 2.4,
|
||||
outputPrice: 9.6,
|
||||
cacheWritesPrice: 2.4,
|
||||
cacheReadsPrice: 9.6,
|
||||
},
|
||||
"deepseek-v3": {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0.28,
|
||||
cacheWritesPrice: 0.14,
|
||||
cacheReadsPrice: 0.014,
|
||||
},
|
||||
"deepseek-r1": {
|
||||
maxTokens: 8_000,
|
||||
contextWindow: 64_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 2.19,
|
||||
cacheWritesPrice: 0.55,
|
||||
cacheReadsPrice: 0.14,
|
||||
},
|
||||
"qwen-vl-max": {
|
||||
maxTokens: 30_720,
|
||||
contextWindow: 32_768,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3,
|
||||
outputPrice: 9,
|
||||
cacheWritesPrice: 3,
|
||||
cacheReadsPrice: 9,
|
||||
},
|
||||
"qwen-vl-max-latest": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 3,
|
||||
outputPrice: 9,
|
||||
cacheWritesPrice: 3,
|
||||
cacheReadsPrice: 9,
|
||||
},
|
||||
"qwen-vl-plus": {
|
||||
maxTokens: 6_000,
|
||||
contextWindow: 8_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.5,
|
||||
outputPrice: 4.5,
|
||||
cacheWritesPrice: 1.5,
|
||||
cacheReadsPrice: 4.5,
|
||||
},
|
||||
"qwen-vl-plus-latest": {
|
||||
maxTokens: 129_024,
|
||||
contextWindow: 131_072,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 1.5,
|
||||
outputPrice: 4.5,
|
||||
cacheWritesPrice: 1.5,
|
||||
cacheReadsPrice: 4.5,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// Mistral
|
||||
// https://docs.mistral.ai/getting-started/models/models_overview/
|
||||
export type MistralModelId = keyof typeof mistralModels
|
||||
@@ -748,6 +1124,14 @@ export const mistralModels = {
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.1,
|
||||
},
|
||||
"mistral-small-latest": {
|
||||
maxTokens: 131_000,
|
||||
contextWindow: 131_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.1,
|
||||
outputPrice: 0.3,
|
||||
},
|
||||
"mistral-small-2501": {
|
||||
maxTokens: 32_000,
|
||||
contextWindow: 32_000,
|
||||
@@ -803,6 +1187,54 @@ export const liteLlmModelInfoSaneDefaults: ModelInfo = {
|
||||
outputPrice: 0,
|
||||
}
|
||||
|
||||
// AskSage Models
|
||||
// https://docs.asksage.ai/
|
||||
export type AskSageModelId = keyof typeof askSageModels
|
||||
export const askSageDefaultModelId: AskSageModelId = "claude-35-sonnet"
|
||||
export const askSageDefaultURL: string = "https://api.asksage.ai/server"
|
||||
export const askSageModels = {
|
||||
"gpt-4o": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gpt-4o-gov": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"claude-35-sonnet": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"aws-bedrock-claude-35-sonnet-gov": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"claude-37-sonnet": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
}
|
||||
|
||||
// X AI
|
||||
// https://docs.x.ai/docs/api-reference
|
||||
export type XAIModelId = keyof typeof xaiModels
|
||||
@@ -881,3 +1313,98 @@ export const xaiModels = {
|
||||
description: "X AI's Grok Beta model (legacy) with 131K context window",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
// SambaNova
|
||||
// https://docs.sambanova.ai/cloud/docs/get-started/supported-models
|
||||
export type SambanovaModelId = keyof typeof sambanovaModels
|
||||
export const sambanovaDefaultModelId: SambanovaModelId = "Meta-Llama-3.3-70B-Instruct"
|
||||
export const sambanovaModels = {
|
||||
"Meta-Llama-3.3-70B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"DeepSeek-R1-Distill-Llama-70B": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 32_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Llama-3.1-Swallow-70B-Instruct-v0.3": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Llama-3.1-Swallow-8B-Instruct-v0.3": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Meta-Llama-3.1-405B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Meta-Llama-3.1-8B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Meta-Llama-3.2-1B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Qwen2.5-72B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"Qwen2.5-Coder-32B-Instruct": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"QwQ-32B-Preview": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"QwQ-32B": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 16_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.5,
|
||||
outputPrice: 1.0,
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
@@ -20,3 +20,37 @@ export function findLast<T>(array: Array<T>, predicate: (value: T, index: number
|
||||
const index = findLastIndex(array, predicate)
|
||||
return index === -1 ? undefined : array[index]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a partial or complete stringified array into an actual array.
|
||||
* Handles both complete JSON strings and incomplete array strings.
|
||||
* Splits on the specific tokens: [" ", " "]
|
||||
* @param arrayString A string representation of an array, which may be incomplete
|
||||
* @returns Array of strings parsed from the input
|
||||
*/
|
||||
export function parsePartialArrayString(arrayString: string): string[] {
|
||||
try {
|
||||
// Try parsing as complete JSON first
|
||||
return JSON.parse(arrayString)
|
||||
} catch {
|
||||
// If JSON parsing fails, handle as partial string
|
||||
const trimmed = arrayString.trim()
|
||||
if (!trimmed.startsWith('["')) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Remove leading ["
|
||||
let content = trimmed.slice(2)
|
||||
// Remove trailing "] if it exists
|
||||
content = content.replace(/"]$/, "")
|
||||
if (!content) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Split on ", " token and handle the parts
|
||||
return content
|
||||
.split('", "')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const DEFAULT_MCP_TIMEOUT_SECONDS = 60 // matches Anthropic's default timeout in their MCP SDK
|
||||
export const MIN_MCP_TIMEOUT_SECONDS = 1
|
||||
export type McpMode = "full" | "server-use-only" | "off"
|
||||
|
||||
export type McpServer = {
|
||||
@@ -9,6 +11,7 @@ export type McpServer = {
|
||||
resources?: McpResource[]
|
||||
resourceTemplates?: McpResourceTemplate[]
|
||||
disabled?: boolean
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export type McpTool = {
|
||||
|
||||
+21
-1
@@ -3,7 +3,7 @@ import { after, describe, it } from "mocha"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import "should"
|
||||
import { createDirectoriesForFile, fileExistsAtPath } from "./fs"
|
||||
import { createDirectoriesForFile, fileExistsAtPath, isDirectory } from "./fs"
|
||||
|
||||
describe("Filesystem Utilities", () => {
|
||||
const tmpDir = path.join(os.tmpdir(), "cline-test-" + Math.random().toString(36).slice(2))
|
||||
@@ -68,4 +68,24 @@ describe("Filesystem Utilities", () => {
|
||||
exists.should.be.true()
|
||||
})
|
||||
})
|
||||
describe("isDirectory", () => {
|
||||
it("should return true for directories", async () => {
|
||||
await fs.mkdir(tmpDir, { recursive: true })
|
||||
const isDir = await isDirectory(tmpDir)
|
||||
isDir.should.be.true()
|
||||
})
|
||||
|
||||
it("should return false for files", async () => {
|
||||
const testFile = path.join(tmpDir, "test.txt")
|
||||
await fs.writeFile(testFile, "test")
|
||||
const isDir = await isDirectory(testFile)
|
||||
isDir.should.be.false()
|
||||
})
|
||||
|
||||
it("should return false for non-existent paths", async () => {
|
||||
const nonExistentPath = path.join(tmpDir, "does-not-exist")
|
||||
const isDir = await isDirectory(nonExistentPath)
|
||||
isDir.should.be.false()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,3 +45,32 @@ export async function fileExistsAtPath(filePath: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the path is a directory
|
||||
* @param filePath - The path to check.
|
||||
* @returns A promise that resolves to true if the path is a directory, false otherwise.
|
||||
*/
|
||||
export async function isDirectory(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath)
|
||||
return stats.isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the size of a file in kilobytes
|
||||
* @param filePath - Path to the file to check
|
||||
* @returns Promise<number> - Size of the file in KB, or 0 if file doesn't exist
|
||||
*/
|
||||
export async function getFileSizeInKB(filePath: string): Promise<number> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath)
|
||||
const fileSizeInKB = stats.size / 1000 // Convert bytes to KB (decimal) - matches OS file size display
|
||||
return fileSizeInKB
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import path from "path"
|
||||
import getFolderSize from "get-folder-size"
|
||||
|
||||
/**
|
||||
* Gets the total size of tasks and checkpoints directories
|
||||
* @param storagePath The base storage path (typically globalStorageUri.fsPath)
|
||||
* @returns The total size in bytes, or null if calculation fails
|
||||
*/
|
||||
export async function getTotalTasksSize(storagePath: string): Promise<number | null> {
|
||||
const tasksDir = path.join(storagePath, "tasks")
|
||||
const checkpointsDir = path.join(storagePath, "checkpoints")
|
||||
|
||||
try {
|
||||
const tasksSize = await getFolderSize.loose(tasksDir)
|
||||
const checkpointsSize = await getFolderSize.loose(checkpointsDir)
|
||||
return tasksSize + checkpointsSize
|
||||
} catch (error) {
|
||||
console.error("Failed to calculate total task size:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function secondsToMs(seconds: number): number {
|
||||
return seconds * 1000
|
||||
}
|
||||
Generated
+1552
-20
File diff suppressed because it is too large
Load Diff
@@ -18,12 +18,14 @@
|
||||
"debounce": "^2.1.1",
|
||||
"dompurify": "^3.2.4",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"firebase": "^11.3.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fzf": "^0.5.2",
|
||||
"mermaid": "^11.4.1",
|
||||
"posthog-js": "^1.224.0",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-remark": "^2.1.0",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
@@ -34,6 +36,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -48,9 +51,10 @@
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.1.1",
|
||||
"vite": "^6.2.1",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import SettingsView from "./components/settings/SettingsView"
|
||||
import WelcomeView from "./components/welcome/WelcomeView"
|
||||
import AccountView from "./components/account/AccountView"
|
||||
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
|
||||
import { FirebaseAuthProvider } from "./context/FirebaseAuthContext"
|
||||
import { vscode } from "./utils/vscode"
|
||||
import McpView from "./components/mcp/McpView"
|
||||
|
||||
@@ -41,7 +42,7 @@ const AppContent = () => {
|
||||
setShowMcp(true)
|
||||
setShowAccount(false)
|
||||
break
|
||||
case "accountLoginClicked":
|
||||
case "accountButtonClicked":
|
||||
setShowSettings(false)
|
||||
setShowHistory(false)
|
||||
setShowMcp(false)
|
||||
@@ -95,6 +96,7 @@ const AppContent = () => {
|
||||
showHistoryView={() => {
|
||||
setShowSettings(false)
|
||||
setShowMcp(false)
|
||||
setShowAccount(false)
|
||||
setShowHistory(true)
|
||||
}}
|
||||
isHidden={showSettings || showHistory || showMcp || showAccount}
|
||||
@@ -112,7 +114,9 @@ const AppContent = () => {
|
||||
const App = () => {
|
||||
return (
|
||||
<ExtensionStateContextProvider>
|
||||
<AppContent />
|
||||
<FirebaseAuthProvider>
|
||||
<AppContent />
|
||||
</FirebaseAuthProvider>
|
||||
</ExtensionStateContextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SVGProps } from "react"
|
||||
|
||||
const ClineLogoWhite = (props: SVGProps<SVGSVGElement>) => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="47" height="50" viewBox="0 0 47 50" fill="none" {...props}>
|
||||
<path
|
||||
d="M46.4075 28.1192L43.5011 22.3166V18.9747C43.5011 13.4354 39.0302 8.94931 33.5162 8.94931H28.5491C28.9086 8.21513 29.106 7.3898 29.106 6.5189C29.106 3.44039 26.6149 0.949219 23.5363 0.949219C20.4578 0.949219 17.9667 3.44039 17.9667 6.5189C17.9667 7.3898 18.1641 8.21513 18.5236 8.94931H13.5565C8.04249 8.94931 3.57155 13.4354 3.57155 18.9747V22.3166L0.604424 28.104C0.305687 28.6863 0.305687 29.3799 0.604424 29.9622L3.57155 35.6838V39.0256C3.57155 44.5649 8.04249 49.0511 13.5565 49.0511H33.5162C39.0302 49.0511 43.5011 44.5649 43.5011 39.0256V35.6838L46.4024 29.942C46.691 29.3698 46.691 28.6964 46.4075 28.1192ZM20.4983 32.8483C20.4983 35.3648 18.4578 37.4053 15.9413 37.4053C13.4248 37.4053 11.3843 35.3648 11.3843 32.8483V24.747C11.3843 22.2305 13.4248 20.19 15.9413 20.19C18.4578 20.19 20.4983 22.2305 20.4983 24.747V32.8483ZM35.182 32.8483C35.182 35.3648 33.1415 37.4053 30.625 37.4053C28.1085 37.4053 26.068 35.3648 26.068 32.8483V24.747C26.068 22.2305 28.1085 20.19 30.625 20.19C33.1415 20.19 35.182 22.2305 35.182 24.747V32.8483Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
export default ClineLogoWhite
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user