Compare commits

...
Author SHA1 Message Date
frostbournesb 7b8a64564d install zustand 2025-05-15 20:22:27 -07:00
Frostbourne 8f6f6464a0 Inject react-devtools (#3569) 2025-05-15 15:43:06 -07:00
Sarah Fortune 8c565b5a7c Run the cline extension as a standalone process outside of vscode. (#3535)
* Add standalone cline server.

Add directory standalone/ with the scripts to generate
a cline instance that runs a gRPC service for the proto bus.

* Rm unused dependencies

* Build standalone extension

Build stubs for the whole vscode SDK.

Import extension.js instead of putting everything in one file.

Move all the files the extension needs at runtime in files/
  Use local packages for vscode and stub-utils instead of module alias.
  Move vscode-impls into the vscode module.
  Create separate package.json for the standalone extension in files/.

* Handlers for gRPC requests

Add code to the bottom of extension.js to export the gRPC handlers.
Add a wrapper to the handlers to catch and log extensions, otherwise the whole server process fails.
Fix use of open module.

* Standalone gRPC server

Export handers from the extension.
Add reflection and healthcheck to the server.
Add vscode launch file for standalone server.

* Fix formatting

* Better error handling in the server template.

Exit if the server could not bind to the port.
Use internal error code if exception is thrown.

* Formatting

* Stop using google-protobuf npm module to generate JS for protos

The code generated by google-protobuf cannot serialize protos from plain objects. It needs the protos to be class instances created with ProtoExample.create().
But, the protos created in the extension are just POJOs.
Use protoLoader instead which is fine with plain objects.
Protoloader is also the method used in the grpc JS documentation: https://grpc.io/docs/languages/node/basics/#loading-service-descriptors-from-proto-files

* Rm proto that was removed in cline/cline

* Rm old protos when building standalone extension.

* Log gRPC requests

* feat(standalone): implement TypeScript gRPC-based standalone extension

The major improvement is that the gRPC implementation is now written in TypeScript instead of JavaScript, and the standalone extension is compiled together with the original extension rather than using the compiled JS output. This provides full type safety throughout the codebase and prevents issues with the TypeScript compiler renaming handlers during compilation, making the system more robust and maintainable.

- Add new standalone implementation files in src/standalone/ directory using TypeScript
- Implement gRPC server setup in extension-standalone.ts with full type safety
- Generate server setup code with service registrations
- Update build script to support the new standalone architecture
- Reorganize runtime files from standalone/files/ to standalone/runtime-files/
- Replace template-based server generation with gRPC service registration

* Fix issues when doing clean build

Use correct build dir in esbuild.js
Remove undefined type.

* Add handler for gRPC methods with streaming response.

Add a handler-wrapper for rpc's with streaming responses.

Fix issue where grpc-js won't deserialize protos in camelcase. It is the default
for generated code for protos to use camelcase (keepCase: false), but I cannot find
where is being set for the proto serializations to keep the case. For now, just convert the
properties of the proto messages to snake case. This is not a good
solution, but trying to fix this is time sink.

* Formatting

* Add streaming response support to the script that generates setup-server.ts

Add types for the handlers.

* Formatting

* Fix case conversion for gRPC requset protos as well.

Convert snake case to camelcase for incoming request protos.

* formatting

* Improve build process / building for standalone extension

Add separate configs for the extension and the standalone in the esbuild config.
Modules that use __dirname to load files at runtime are marked as external in the build config.
Rename vscode-impls to vscode-context.
Remove unecessary files from the standalone runtime.

* Rename extension-standalone.js to standalone.js

* Move generate-server-setup script to protos dir.

Add the script the npm target `protos`, so it is run when the protos are regenerated.

* formatting

* Add a post build step for the npm run target `protos` to format the generated files.

* Move generate-server-setup to scripts directory

* Add a JS script to package the standalone build, replacing the shell script.

Add a post build step for the standalone target that:
    * copies the vscode module files into the output directory.
    * checks that native modules are not included in the output
    * creates a zip of the build.

* Rm files that were included from merge by mistake

* Move scripts from standalone in scripts directory

Remove unused package.json files from standalone/

* Update scripts and launch.json to use correct paths

* During build install external modules in the dist directory.

Add package.json for the distribution.
Set the node path for the vscode launch config.
Make the prettier silent during `npm run protos`

* Fix ellipsis suggestions
2025-05-15 12:04:46 -07:00
AraandCline Evaluation cd1ff2ad25 Refactor reasoning effort option and checkpoint handling (#3454)
• Replace "o3MiniReasoningEffort" with "reasoningEffort" in API providers
• Remove deprecated configuration properties from package.json
• Guard checkpoint tracker initialization and saving using the enableCheckpoints flag

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-15 22:24:16 +05:30
github-actions[bot] d2979631d8 v3.15.5 Release Notes
v3.15.5 Release Notes
2025-05-14 21:20:15 -07:00
Frostbourne 4dfc1358c5 Migrate Task Timeline tooltip to HeroUI (#3547)
* Task Timeline tooltip heroui migration

* decrease closeDelay, and changeset
2025-05-14 21:01:20 -07:00
Tomás Barreiro 6a96c183a3 Handle Gemini Rate Limits (#3532) 2025-05-14 20:23:13 -07:00
pashpashpashandCline Evaluation 9df023b9d0 reverting closing diff edit view because it didnt help gray screen issues (#3546)
Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-14 20:06:07 -07:00
pashpashpashandCline Evaluation 19e4387b86 Optimizing memory management for task timeline via virtuoso (#3545)
* optimizing memory management for task timeline via virtuoso

* removing logs

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-14 19:54:24 -07:00
Saoud Rizwan ab01a518d1 Allow blank issues (#3543) 2025-05-14 19:29:27 -07:00
a66724e312 Refactor auto approve menu to modal (#3537)
* refactor auto approval menu to modal

* changeset

* move constants to shared location; change chevron dynamically; remove useless notes

* address comments

* improve spacing

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
Co-authored-by: frostbournesb <frostbournesb@protonmail.com>
2025-05-14 19:08:53 -07:00
github-actions[bot] cc56486814 v3.15.4 Release Notes
v3.15.4 Release Notes
2025-05-14 16:32:14 -07:00
Dennis Bartlett 277b20a1b2 Add gemini model back to vertex provider (#3538) 2025-05-14 16:27:08 -07:00
Ara 55d12d7556 feat: Add performance telemetry for Gemini API streams (#3523) 2025-05-14 11:01:44 -07:00
canvrno a527acc56c Feat: Workspace filter in Task History View (#3476)
* Filter tasks to current workspace

* Switched custom radio button to tailwind
2025-05-14 10:50:09 -07:00
Sarah Fortune dc1d7f51cb Create proto descriptor set in build-protos.js script. (#3524)
* Create proto descriptor set in build-protos.js script.

Create the descriptor set that will be used by the standalone cline service.
Add the standalone dist directory to the gitignore.
Only call protoc once when generating typescript files, instead of for each file separately.

* Fix undefined var in error message

* Inline the exec options
2025-05-13 17:28:06 -07:00
4ff7e06044 Changeset version bump (#3490)
* changeset version bump

* Updating CHANGELOG.md format

* ready for hotfix release

* language

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-13 16:27:35 -07:00
Dennis Bartlett 2968c8d99c Fix API Options Types in Tests (#3522) 2025-05-13 16:14:21 -07:00
pashpashpashandCline Evaluation c617d2550e fixing parsing v2 thanks to @cte (#3520)
* fixing parsing v2 thanks to @cte

* fixing parsing v2 thanks to @cte

* cleaner PR

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-13 15:58:29 -07:00
pashpashpashandCline Evaluation 7937530c74 Remove free gemini models (#3494)
* removing free gemini provider

* changeset

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-13 15:57:19 -07:00
Frostbourne 3657e903f5 Auto-approve menu stylistic fixes (#3504)
* marginal improvements

* undo forbidding enable all favorite

* changeset
2025-05-13 15:42:10 -07:00
0fcab4d989 Fix/chrome remote debugging user data dir (#3492)
* fix: Add required --user-data-dir flag when launching Chrome with remote debugging port

When Chrome is launched with the --remote-debugging-port flag, it requires a non-default user data directory to be specified using the --user-data-dir flag. Without this flag, Chrome shows the error 'DevTools remote debugging requires a non-default data directory' and the debug port is not opened.

This fix adds the --user-data-dir flag when launching Chrome with the remote debugging port, which resolves the 'Chrome was launched but debug port is not responding' error.

* Add changeset for Chrome remote debugging fix

* fix: Add required --user-data-dir flag when launching Chrome with remote debugging port

* Update src/services/browser/BrowserSession.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Revert "Update src/services/browser/BrowserSession.ts"

This reverts commit 5dbd82aea2.

* import os, quote path arg

* apparently quotes are bad

* probably dont need the whole warning and relaunch flow now

* rename button labels to launch browser

---------

Co-authored-by: Andrei Eternal <garoth@gmail.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-05-14 00:59:31 +05:30
Trevor Hudson afb64c896e add boostrap (#3502)
* add boostrap

* make sure machine ID is there
2025-05-13 11:47:27 -07:00
canvrno 65f1b05420 FIX: Detect directory change when reusing active terminals (#3503)
* Added confirmation of a sucessful cd prior to executing commands in active terminals

* typo fix, fine tuning

* cleanup
2025-05-13 01:27:27 -07:00
Andrei EternalandAndrei Edell 8f37543800 add arm rollup to optional deps so cline can build on my arm linux (#3498)
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-05-12 20:18:45 -07:00
canvrno abbe40ee9c [PROTOBUS] Move downloadMcp to protobus (#3487)
* downloadMcp protobus migration

* added setIsDownloading(false) to error handling
2025-05-12 20:17:47 -07:00
canvrno 5c082762c4 toggleFavoriteModel protobus migration (#3488) 2025-05-12 20:17:38 -07:00
4a230ad878 Add Fireworks API Provider (#3496)
* initial

* finishing touches

* Update webview-ui/src/utils/validate.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update webview-ui/src/components/settings/ApiOptions.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* requested changes

* fix url

* fix vars

* Update webview-ui/src/components/chat/ChatTextArea.tsx

Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>

* Update fireworks API link

* Improve margins

---------

Co-authored-by: Matt Apperson <me@mattapperson.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-05-12 19:59:28 -07:00
pashpashpashandCline Evaluation 94c432f3f3 Activation Events (#3491)
* adding activation events so cline is activated when vs code is open

* changeset

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-12 17:01:16 -07:00
Toshii d88c07c932 PROTO refactor condense tool (#3489)
* proto for condense

* changeset

* condense text
2025-05-12 16:41:13 -07:00
5ee5577010 Changeset version bump (#3453)
* changeset version bump

* Updating CHANGELOG.md format

* changelog + version

* changelog

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-12 15:53:53 -07:00
ksmkzs 01877c1629 fix: prevent IME composition Enter from auto‑sending edited message (#3477) 2025-05-12 15:37:57 -07:00
Toshii f8a7b563aa PROTO refactor reportbug (#3485)
* protos report bug

* changeset
2025-05-12 15:35:31 -07:00
Trevor Hudson 915555f80f Trevhud/auto approve (#3486)
* ship with good defaults

* show items that are checked

* add close button at bottom

* changeset
2025-05-12 15:19:06 -07:00
AraandCline Evaluation 26eafd96dd fix: Resolve all different copy paste issues once and for all (#3443)
* Enhance copy functionality in ChatView to handle selections within code blocks. If the selection is inside a <pre><code> block, copy plain text; otherwise, convert HTML to Markdown before copying. This improves user experience when copying code snippets.

* Make jumps better please

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-12 15:14:30 -07:00
Trevor Hudson e504b9d414 Trevhud/telem defaults (#3449)
* fresh install mode

* add nocapture

* add ui host

* changeset
2025-05-12 13:58:25 -07:00
Toshii 312777ddc5 Remove explicit caching for gemini in OR / Cline provider (#3470)
* remove explicit cache

* changeset
2025-05-12 13:04:19 -07:00
EvanandElephant Lumps c79acf5ffe Disable breaking out of diff auto scroll (#3473)
* disable breaking out of auto scroll

* changeset

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-05-12 12:37:05 -07:00
Shravan Vadeghar 7d5d347cdd feat: Add optimized V2 parser for assistant messages (#3425)
This commit introduces `parseAssistantMessageV2`, a new function designed to parse assistant message strings containing text and XML-like tool usage tags (`<tool_name>...</tool_name>`, `<param_name>...</param_name>`).

Motivation:
The original parser (`V1`) used a character-by-character accumulator, which could lead to performance overhead due to repeated string concatenations and checks (`endsWith`). V2 aims to improve parsing efficiency.

Implementation Details (V2 vs V1):
- V2 iterates through the string using an index and checks for tags using `startsWith` with calculated offsets, avoiding the V1 accumulator.
- It tracks start indices for text, tools, and parameters, performing `slice` operations only when a block is completed or the string ends.
- Known tool and parameter opening tags are precomputed into Maps for potentially faster lookups.
- Special handling for nested tags within `write_to_file`/`new_rule` content parameters is preserved using `indexOf`/`lastIndexOf`.

Other Changes:
- The original parser implementation has been renamed to `parseAssistantMessageV1`.
2025-05-12 12:21:44 -07:00
pashpashpash 95cc15a142 Releasing memory after every diff edit - greyscreen fix? (#3459) 2025-05-12 11:17:13 -07:00
Sarah Fortune f7d464a51d Add request param to accountLoginClicked. (#3471)
All the handlers need to have the same signature f(controller, request),
otherwise the typechecker will be unhappy when setting up the gRPC server.
2025-05-12 18:52:58 +01:00
Hiroki Nakashima a6c4c0c0ea feat: Add detailed configuration options for LiteLLM provider (#2056)
* add configuration to litellm

* update defualt model name

* fix typo

* add changeset

* update default model

* remove model cost setting

* add temperature setting

* remove redandant comment

* use const

* handle model change

* fix unsaved bug
2025-05-12 23:10:44 +05:30
AraandCline Evaluation 976a8fa85e Migrate Browsertools settings to the webview from Vscode settings (#3444)
* Removing redundant settings

* Add chromeExecutablePath to BrowserSettings and UpdateBrowserSettingsRequest

- Introduced optional chromeExecutablePath field in BrowserSettings and UpdateBrowserSettingsRequest.
- Updated updateBrowserSettings function to merge new settings with existing ones, preserving previous values.
- Enhanced BrowserSession to check for the chromeExecutablePath in global state.
- Modified BrowserSettingsSection to include a UI input for specifying the Chrome executable path.

* Removing browser stuff

* Removing browser stuff

* Removing browser stuff

* Removing browser stuff

* Add cute animation

* Add cute animation

* Add cute animation

* Add cute animation

* Add cute animation

* Add cute animation

* Add cute animation

* adding stuff

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-12 23:06:37 +05:30
canvrno 33413e91c6 refreshRequestyModels protobus migration (#3422) 2025-05-11 22:42:14 -07:00
Sarah Fortune 801c59e75e Use correct type for return value of accountLoginClicked. (#3447)
Return type should be cline.String not String from JS global namespace.
Use await when calling async function vscode.env.openExternal.
2025-05-11 22:39:36 -07:00
Trevor Hudson df9c8e2e80 Trevhud/vite auto (#3448)
* move enable all

* add tooltip

* changeset

* fix spacing and move notifications to other section
2025-05-11 16:47:59 -07:00
canvrno 4d480ea3fe Add telemetry enable/disable controls by category to TelemetryService (#3450) 2025-05-11 14:53:33 -07:00
7e26d1117a Changeset version bump (#3440)
* changeset version bump

* Updating CHANGELOG.md format

* package lock

* changelog

* brackets

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-10 19:39:04 -05:00
b04810c480 increased error timeout from 500 -> 5000 for windows users (#3439)
* increased error timeout from 500 -> 5000

* conditionally setting to 5000 if windows

* Create rude-bats-brush.md

---------

Co-authored-by: Cline Evaluation <cline@example.com>
Co-authored-by: Dennis Bartlett <bartlett.dc.1@gmail.com>
2025-05-10 17:23:14 -07:00
github-actions[bot] 5255da936f v3.15.0 Release Notes
v3.15.0 Release Notes
2025-05-09 17:18:17 -07:00
EvanandElephant Lumps 248871d770 Simple home header (#3424)
* simplified home header

* changeset

* add variable color logo for different themes

* random slash

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-05-09 17:26:07 -05:00
nutstore-devandweiwenhan c634bf6368 fix: make sure clineIgnoreController initialized before task start (#3410)
Co-authored-by: weiwenhan <weiwenhan@cn.nutstore.net>
2025-05-09 13:21:02 -07:00
Ara f5dbfaf234 fix: Restore native copy functionality in chat input text area (#3416)
* fix: Restore native copy functionality in chat input text area

* fix: Restore native copy functionality in chat input text area
2025-05-09 10:32:06 -07:00
Trevor Hudsonandellipsis-dev[bot] aa4d97f05d Trevhud/auto approve menu (#3405)
* improved auto-approve

* roll back chevron

* changeset

* add pills

* back to checkboxes

* turn on parent when subAction is turned on

* use vscode colors

* Update webview-ui/src/components/chat/auto-approve-menu/AutoApproveMenu.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update webview-ui/src/components/chat/auto-approve-menu/AutoApproveMenu.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* improve responsiveness

* remove opacity animation

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-09 22:47:14 +05:30
Toshii c20a513b70 prompt wording (#3409)
* base

* changeset
2025-05-09 01:19:02 -07:00
738c03ff3e slash command report bug (#3387)
* slash command report bug

* nits

* nits

* sigh, portible way to open urls with proper escaping because vs code api is broken

* only asking for non-algorithmically derived info

* Update webview-ui/src/components/chat/ChatView.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* gather user system info

* Revert "gather user system info"

This reverts commit fb16c72224.

---------

Co-authored-by: Cline Evaluation <cline@example.com>
Co-authored-by: pashpashpash <nik@cline.bot>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-09 01:10:44 -07:00
canvrno e8a68c49ce [PROTOBUS] Move refreshOpenAiModels to protobus (#3403)
* refreshOpenAiModels protobus migration

* changeset

* Debounce OpenAi model list refresh when users are typing

* debounce cleanup
2025-05-08 23:33:12 -07:00
pashpashpashandCline Evaluation 961400fdca Fixing task lockout after shell integration stream bug leading to terminal hang (#3404)
* shell timeout bug throw error

* changeset

* explanation in comments

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-08 17:23:00 -10:00
canvrno 8827b167ca [PROTOBUS] Move refreshOpenRouterModels to protobus (#3401)
* refreshOpenRouterModels protobus migration

* changeset

* cleanup

* Ellipsis inspired changes

* one small change
2025-05-08 20:14:49 -07:00
Andrei EternalandAndrei Edell e1389a62c7 run prettier correctly on generated protos (#3399)
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2025-05-08 13:21:54 -10:00
canvrno 7b416ccc70 Feat: Task Favorites ️ (#3392)
* Task Favorites

* Task management docs
2025-05-08 15:49:05 -07:00
Alex 29f3cfa894 Update index.css (#3367) 2025-05-09 03:18:53 +05:30
Araandellipsis-dev[bot] 978f34e30b Supporting implicit Caching in Gemini (#3394)
* Refactor GeminiHandler to remove caching logic and update pricing structure

* Removed the enhanced caching system and related logic from GeminiHandler.
* Updated the pricing structure for cache reads in both geminiModels and vertexModels.
* Simplified the message creation process by eliminating unnecessary cache checks and operations.

* Fixing Gemini and vertex cache pricing

* Fixing Gemini and vertex cache pricing

* Update src/api/providers/gemini.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-09 03:17:41 +05:30
canvrno 445e25221a [PROTOBUS] Move requestVsCodeLmModels to protobus (#3344)
* Task Favorites

* getOllamaModels protobus migration

* VsCodeLmModels protobus migration

* cleanup
2025-05-08 10:50:23 -10:00
Araandellipsis-dev[bot] 489a05117c Increasing file sizes for files that can be read by cline (#3396)
* Increasing file sizes for files that can be read by cline

* Update src/integrations/misc/extract-text.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Increasing file sizes for files that can be read by cline

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-08 12:55:40 -07:00
WinterYukky e572ee44f9 fix(bedrock): application inference profile is not work (#3388)
* fix(bedrock): application inference profile is not work

* chore: add change set

* chore: change the encoding condition to whether it contains a slash
2025-05-09 00:26:05 +05:30
pashpashpashandCline Evaluation f4e14bfe3b removing sparkle from command name (#3395)
Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-09 00:16:40 +05:30
watany bddc1b5e96 fix(bedrock); update bedrock api (#3157)
* fix nova

* haiku

* changeset

* changeset

* clean up duplicate changeset

* commented caching write
2025-05-08 10:52:08 -07:00
pashpashpashandCline Evaluation cb0de8f17e tracking models in diff edit failures (#3297)
* tracking models in diff edit failures

* prettier

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-05-08 12:33:29 -05:00
EvanandElephant Lumps e1a0b244de Conditionally initialize posthog webview (#3381)
* conditionally initialize posthog client webview

* changeset

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-05-08 12:08:40 -05:00
Dennis Bartlett a9d5411bf0 Revert "Update deployer team name (#3377)" 2025-05-08 06:57:18 -05:00
Dennis Bartlett 16af9125ec Update variable name (#3384) 2025-05-08 06:51:55 -05:00
Ara 2792e7698f Raise Errors when users try to upload images larger than 7500x7500 pixels (#3336)
* Adding iamge dimension check

* Adding iamge dimension check

* Adding iamge dimension check

* Adding iamge dimension check

* Adding iamge dimension check

* Adding iamge dimension check
2025-05-07 23:48:04 -07:00
196 changed files with 13158 additions and 2445 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add the o4-mini model in the isOminiModel
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Allow option to collect events to send them in a bundle to avoid sending too many events
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
add open ai cache to ui
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
disable autocapture
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Update deployer team name
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Moved rule file conversions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
suggested model exists again
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
getRelativePaths protobus migration
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
getOllamaModels protobus migration
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add FeatureFlagProvider service for the Node.js extension side
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
added copy buttons to task header and assistant messages
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add org to team affiliation check
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add confirmation dialog to Delete All History button
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
breakpoint just in system prompt for gemini for OR and cline provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix for markdown copy excessive escape characters
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
fetch cache details from generation endpoint
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Bump ollama from 0.5.13 to 0.5.15
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add npm script for issue creation
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate more info section to new docs
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Extend ReasoningEffort to non-o3-mini reasoning models for all providers
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Re-enable tests in workflow
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Finishing the migration of Vscode Advanced settings to Settings Webview
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Allow the user to form their next message while Cline is taking action
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Adding mistral medium-3 model
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
updated gemini caching for OR and cline provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
searchFiles protobus migration
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add ability to generate commit message via cline
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
updated OR/cline provider to automate gemini models caching
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
getLmStudioModels protobus migration
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
adding quote reply support
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add enterprise section to new docs
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Make Previous Updates in the Announcement a dropdown
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate prompting section to new docs
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate running models locally section to new docs
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
timestamp visualization
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Allow the user to scroll when Cline is editing a file by disabling auto-scroll when the user scrolls up
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Add image links to docs
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate MCP section to new docs
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate custom model config section to new docs
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
add ui for windsurf and cursor rules
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Batch selection and deletion of tasks in history
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate getting-started section to new docs
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Task Timeline
+1 -1
View File
@@ -209,7 +209,7 @@ class Task {
switch (chunk.type) {
case "text":
// Parse into content blocks
this.assistantMessageContent = parseAssistantMessage(chunk.text)
this.assistantMessageContent = parseAssistantMessageV2(chunk.text)
// Present blocks to user
await this.presentAssistantMessage()
break
+1 -1
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: ✨ Feature Request
url: https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop
+2 -2
View File
@@ -33,8 +33,8 @@ jobs:
uses: morfien101/actions-authorized-user@4a3cfbf0bcb3cafe4a71710a278920c5d94bb38b
with:
username: ${{ github.actor }}
org: ${{ github.repo_owner }}
team: "Deployer"
org: ${{ github.repository_owner }}
team: "deployer"
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if user is authorized
+2 -1
View File
@@ -1,5 +1,6 @@
out
dist
dist-standalone
node_modules
tmp
.vscode-test/
@@ -18,4 +19,4 @@ coverage
# But don't ignore the coverage scripts in .github/scripts/
!.github/scripts/coverage/
*evals.env
*evals.env
+39
View File
@@ -16,6 +16,45 @@
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
}
},
{
"name": "Run Extension (Fresh Install Mode)",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--profile-temp",
"--sync",
"off",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}"
],
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"preLaunchTask": "clean-sandbox",
"internalConsoleOptions": "openOnSessionStart",
"postDebugTask": "stop",
"env": {
"IS_DEV": "true",
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}"
}
},
{
"type": "node",
"request": "launch",
"name": "Run Standalone Extension",
"skipFiles": ["<node_internals>/**"],
"sourceMaps": true,
"resolveSourceMapLocations": ["${workspaceFolder}/**", "!**/node_modules/**"],
"cwd": "${workspaceFolder}/dist-standalone",
"outFiles": ["${workspaceFolder}/dist-standalone/**/*.js"],
"preLaunchTask": "compile-standalone",
"env": {
"GRPC_TRACE": "all",
"GRPC_VERBOSITY": "DEBUG",
"NODE_PATH": "${workspaceFolder}/dist-standalone/node_modules"
},
"program": "standalone.js"
}
]
}
+16
View File
@@ -3,6 +3,16 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "compile-standalone",
"type": "npm",
"script": "compile-standalone",
"group": "build",
"problemMatcher": [],
"presentation": {
"reveal": "always"
}
},
{
"label": "npm: protos",
"type": "npm",
@@ -185,6 +195,12 @@
"label": "stop",
"command": "echo ${input:terminate}",
"type": "shell"
},
{
"label": "clean-sandbox",
"type": "shell",
"dependsOn": ["watch"],
"command": "rm -rf .vscode-dev"
}
],
"inputs": [
+69
View File
@@ -1,5 +1,74 @@
# Changelog
## [3.15.5]
- Fix inefficient memory management in the task timeline
- Fix Gemini rate limitation response not being handled properly (Thanks @BarreiroT!)
## [3.15.4]
- Add gemini model back to vertex provider
- Add gemini telemetry
- Add filtering for tasks tied to the current workspace
## [3.15.3]
- Add Fireworks API Provider
- Fix minor visual issues with auto-approve menu
- Fix one instance of terminal not getting output
- Fix 'Chrome was launched but debug port is not responding' error
## [3.15.2]
- Added details to auto approve menu and more sensible default controls
- Add detailed configuration options for LiteLLM provider
- Add webview telemetry for users who have opted in to telemetry
- Update Gemini in OpenRouter/Cline providers to use implicit caching
- Fix freezing issues during rendering of large streaming text
- Fix grey screen webview crashes by releasing memory after every diff edit
- Fix breaking out of diff auto-scroll
- Fix IME composition Enter autosending edited message
## [3.15.1]
- Fix bug where PowerShell commands weren't given enough time before giving up and showing an error
## [3.15.0]
- Add Task Timeline visualization to tasks (Thanks eomcaleb!)
- Add cache to ui for OpenAi provider
- Add FeatureFlagProvider service for the Node.js extension side
- Add copy buttons to task header and assistant messages
- Add a more simplified home header was added
- Add ability to favorite a task, allowing it to be kept when clearing all tasks
- Add npm script for issue creation (Thanks DaveFres!)
- Add confirmation dialog to Delete All History button
- Add ability to allow the user to type their next message into the chat while Cline is taking action
- Add ability to generate commit message via cline (Thanks zapp88!)
- Add improvements to caching for gemini models on OpenRouter and Cline providers
- Add improvements to allow scrolling the file being edited.
- Add ui for windsurf and cursor rules
- Add mistral medium-3 model
- Add option to collect events to send them in a bundle to avoid sending too many events
- Add support to quote a previous message in chat
- Add support for Gemini Implicit Caching
- Add support for batch selection and deletion of tasks in history (Thanks danix800!)
- Update change suggested models
- Update fetch cache details from generation endpoint
- Update converted docs to Mintlify
- Update the isOminiModel to include o4-mini model (Thanks PeterDaveHello!)
- Update file size that can be read by Cline, allowing larger files
- Update defaults for bedrock API models (Thanks Watany!)
- Update to extend ReasoningEffort to non-o3-mini reasoning models for all providers (Thanks PeterDaveHello!)
- Update to give error when a user tries to upload an image larger than 7500x7500 pixels
- Update announcement so that previous updates are in a dropdown
- Update UI for auto approve with favorited settings
- Fix bug where certain terminal commands would lock you out of a task
- Fix only initialize posthog in the webview if the user has opted into telemetry
- Fix bug where autocapture was on for front-end telemetry
- Fix for markdown copy excessively escaping characters (Thanks weshoke!)
- Fix an issue where loading never finished when using an application inference profile for the model ID (Thanks WinterYukky!)
## [3.14.1]
- Disables autocaptures when initializing feature flags
+1
View File
@@ -62,6 +62,7 @@
"getting-started/installing-dev-essentials",
"getting-started/model-selection-guide",
"getting-started/our-favorite-tech-stack",
"getting-started/task-management",
"getting-started/understanding-context-management",
"getting-started/what-is-cline"
]
+67
View File
@@ -0,0 +1,67 @@
---
title: "Task Management in Cline"
description: "Learn how to effectively manage your task history, use favorites, and organize your work in Cline."
---
# Task Management
As you use Cline, you'll accumulate many tasks over time. The task management system helps you organize, filter, search, and clean up your task history to keep your workspace efficient.
## Accessing Task History
You can access your task history by:
1. Clicking on the "History" button in the Cline sidebar
2. Using the command palette to search for "Cline: Show Task History"
## Task History Features
The task history view provides several powerful features:
### Searching and Filtering
- **Search Bar**: Use the fuzzy search at the top to quickly find tasks by content
- **Sort Options**: Sort tasks by:
- Newest (default)
- Oldest
- Most Expensive (highest API cost)
- Most Tokens (highest token usage)
- Most Relevant (when searching)
- **Favorites Filter**: Toggle to show only favorited tasks
### Task Actions
Each task in the history view has several actions available:
- **Open**: Click on a task to reopen it in the Cline chat
- **Favorite**: Click the star icon to mark a task as a favorite
- **Delete**: Remove individual tasks (favorites are protected from deletion)
- **Export**: Export a task's conversation to markdown
## ⭐ Task Favorites
The favorites feature allows you to mark important tasks that you want to preserve and find quickly.
### How Favorites Work
- **Marking Favorites**: Click the star icon next to any task to toggle its favorite status
- **Protection**: Favorited tasks are protected from individual and bulk deletion operations (can be overridden)
- **Filtering**: Use the favorites filter to quickly access your important tasks
## Batch Operations
The task history view supports several batch operations:
- **Select Multiple**: Use the checkboxes to select multiple tasks
- **Select All/None**: Quickly select or deselect all tasks
- **Delete Selected**: Remove all selected tasks
- **Delete All**: Remove all tasks from history (favorites are preserved unless you choose to include them)
## Best Practices
1. **Favorite Important Tasks**: Mark reference tasks or frequently accessed conversations as favorites
2. **Regular Cleanup**: Periodically remove old or unused tasks to improve performance
3. **Use Search**: Leverage the fuzzy search to quickly find specific conversations
4. **Export Valuable Tasks**: Export important tasks to markdown for external reference
Task management helps you maintain an organized workflow when using Cline, allowing you to quickly find past conversations, preserve important work, and keep your history clean and efficient.
+2 -2
View File
@@ -26,9 +26,9 @@ For complete transparency, you can inspect our [telemetry implementation](https:
### How to Opt Out
Telemetry in Cline is entirely optional and requires your explicit consent:
Telemetry in Cline is entirely optional:
- When you update or install our VS Code extension, you'll see a simple prompt: "Help Improve Cline" with Allow or Deny options
- When you update or install our VS Code extension, you'll see a message about our anonymous telemetry
- You can change your preference anytime in settings
Cline also respects VS Code's global telemetry settings. If you've disabled telemetry at the VS Code level, Cline's telemetry will automatically be disabled as well.
+24 -5
View File
@@ -4,6 +4,8 @@ const path = require("path")
const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch")
const standalone = process.argv.includes("--standalone")
const destDir = standalone ? "dist-standalone" : "dist"
/**
* @type {import('esbuild').Plugin}
@@ -85,7 +87,7 @@ const copyWasmFiles = {
build.onEnd(() => {
// tree sitter
const sourceDir = path.join(__dirname, "node_modules", "web-tree-sitter")
const targetDir = path.join(__dirname, "dist")
const targetDir = path.join(__dirname, destDir)
// Copy tree-sitter.wasm
fs.copyFileSync(path.join(sourceDir, "tree-sitter.wasm"), path.join(targetDir, "tree-sitter.wasm"))
@@ -117,7 +119,8 @@ const copyWasmFiles = {
},
}
const extensionConfig = {
// Base configuration shared between extension and standalone builds
const baseConfig = {
bundle: true,
minify: production,
sourcemap: !production,
@@ -140,16 +143,32 @@ const extensionConfig = {
},
},
],
entryPoints: ["src/extension.ts"],
format: "cjs",
sourcesContent: false,
platform: "node",
outfile: "dist/extension.js",
}
// Extension-specific configuration
const extensionConfig = {
...baseConfig,
entryPoints: ["src/extension.ts"],
outfile: `${destDir}/extension.js`,
external: ["vscode"],
}
// Standalone-specific configuration
const standaloneConfig = {
...baseConfig,
entryPoints: ["src/standalone/standalone.ts"],
outfile: `${destDir}/standalone.js`,
// These gRPC protos need to load files from the module directory at runtime,
// so they cannot be bundled.
external: ["vscode", "@grpc/reflection", "grpc-health-check"],
}
async function main() {
const extensionCtx = await esbuild.context(extensionConfig)
const config = standalone ? standaloneConfig : extensionConfig
const extensionCtx = await esbuild.context(config)
if (watch) {
await extensionCtx.watch()
} else {
+1050 -127
View File
File diff suppressed because it is too large Load Diff
+15 -75
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.14.1",
"version": "3.15.5",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -40,6 +40,8 @@
"llama"
],
"activationEvents": [
"onLanguage",
"onStartupFinished",
"workspaceContains:evals.env"
],
"main": "./dist/extension.js",
@@ -121,7 +123,7 @@
},
{
"command": "cline.generateGitCommitMessage",
"title": "Generate Commit Message with Cline",
"title": "Generate Commit Message with Cline",
"category": "Cline",
"icon": "$(robot)"
}
@@ -234,87 +236,20 @@
},
"configuration": {
"title": "Cline",
"properties": {
"cline.vsCodeLmModelSelector": {
"type": "object",
"properties": {
"vendor": {
"type": "string",
"description": "The vendor of the language model (e.g. copilot)"
},
"family": {
"type": "string",
"description": "The family of the language model (e.g. gpt-4)"
}
},
"description": "Settings for VSCode Language Model API"
},
"cline.enableCheckpoints": {
"type": "boolean",
"default": true,
"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",
"default": false,
"description": "Disables extension from spawning browser session."
},
"cline.modelSettings.o3Mini.reasoningEffort": {
"type": "string",
"enum": [
"low",
"medium",
"high"
],
"default": "medium",
"description": "Controls the reasoning effort when using an OpenAI reasoning model. Higher values may result in more thorough but slower responses."
},
"cline.chromeExecutablePath": {
"type": "string",
"default": null,
"description": "Path to Chrome executable for browser use functionality. If not set, the extension will attempt to find or download it automatically."
},
"cline.preferredLanguage": {
"type": "string",
"enum": [
"English",
"Arabic - العربية",
"Portuguese - Português (Brasil)",
"Czech - Čeština",
"French - Français",
"German - Deutsch",
"Hindi - हिन्दी",
"Hungarian - Magyar",
"Italian - Italiano",
"Japanese - 日本語",
"Korean - 한국어",
"Polish - Polski",
"Portuguese - Português (Portugal)",
"Russian - Русский",
"Simplified Chinese - 简体中文",
"Spanish - Español",
"Traditional Chinese - 繁體中文",
"Turkish - Türkçe"
],
"default": "English",
"description": "The language that Cline should use for communication."
},
"cline.mcpMarketplace.enabled": {
"type": "boolean",
"default": true,
"description": "Controls whether the MCP Marketplace is enabled."
}
}
"properties": {}
}
},
"scripts": {
"vscode:prepublish": "npm run package",
"compile": "npm run check-types && npm run lint && node esbuild.js",
"compile-standalone": "npm run protos && npm run check-types && npm run lint && node esbuild.js --standalone",
"postcompile-standalone": "node scripts/package-standalone.mjs",
"watch": "npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.js --watch",
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
"protos": "node proto/build-proto.js && prettier src/shared/proto --write && prettier src/core/controller --write",
"protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs",
"postprotos": "prettier src/shared/proto src/core/controller webview-ui/src/services src/standalone/server-setup.ts --write --log-level silent",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run lint",
@@ -386,6 +321,7 @@
"@google-cloud/vertexai": "^1.9.3",
"@google/genai": "^0.9.0",
"@grpc/grpc-js": "^1.9.15",
"@grpc/reflection": "^1.0.4",
"@mistralai/mistralai": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.7.0",
"@opentelemetry/api": "^1.4.1",
@@ -396,6 +332,7 @@
"@opentelemetry/semantic-conventions": "^1.30.0",
"@sentry/browser": "^9.12.0",
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"axios": "^1.8.2",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
@@ -409,14 +346,16 @@
"fzf": "^0.5.2",
"get-folder-size": "^5.0.0",
"globby": "^14.0.2",
"grpc-health-check": "^2.0.2",
"iconv-lite": "^0.6.3",
"ignore": "^7.0.3",
"image-size": "^2.0.2",
"isbinaryfile": "^5.0.2",
"jschardet": "^3.1.4",
"mammoth": "^1.8.0",
"monaco-vscode-textmate-theme-converter": "^0.1.7",
"node-cache": "^5.1.2",
"ollama": "^0.5.13",
"open": "^10.1.2",
"open-graph-scraper": "^6.9.0",
"openai": "^4.83.0",
"os-name": "^6.0.0",
@@ -430,6 +369,7 @@
"simple-git": "^3.27.0",
"strip-ansi": "^7.1.0",
"tree-sitter-wasms": "^0.1.11",
"ts-morph": "^25.0.1",
"turndown": "^7.2.0",
"web-tree-sitter": "^0.22.6",
"zod": "^3.24.2"
+4
View File
@@ -40,6 +40,8 @@ message BrowserSettings {
Viewport viewport = 1;
optional string remote_browser_host = 2;
optional bool remote_browser_enabled = 3;
optional string chrome_executable_path = 4;
optional bool disable_tool_use = 5;
}
message UpdateBrowserSettingsRequest {
@@ -47,4 +49,6 @@ message UpdateBrowserSettingsRequest {
Viewport viewport = 2;
optional string remote_browser_host = 3;
optional bool remote_browser_enabled = 4;
optional string chrome_executable_path = 5;
optional bool disable_tool_use = 6;
}
+34 -21
View File
@@ -29,6 +29,7 @@ const serviceNameMap = {
task: "cline.TaskService",
web: "cline.WebService",
models: "cline.ModelsService",
slash: "cline.SlashService",
// Add new services here - no other code changes needed!
}
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey))
@@ -54,30 +55,42 @@ async function main() {
// Process all proto files
console.log(chalk.cyan("Processing proto files from"), SCRIPT_DIR)
const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR })
const protoFiles = await globby("*.proto", { cwd: SCRIPT_DIR, absolute: true })
for (const protoFile of protoFiles) {
console.log(chalk.cyan(`Generating TypeScript code for ${protoFile}...`))
// Build the protoc command with proper path handling for cross-platform
const tsProtocCommand = [
protoc,
`--proto_path="${SCRIPT_DIR}"`,
`--plugin=protoc-gen-ts_proto="${tsProtoPlugin}"`,
`--ts_proto_out="${TS_OUT_DIR}"`,
"--ts_proto_opt=outputServices=generic-definitions,env=node,esModuleInterop=true,useDate=false,useOptionals=messages",
...protoFiles,
].join(" ")
try {
console.log(chalk.cyan(`Generating TypeScript code for:\n${protoFiles.join("\n")}...`))
execSync(tsProtocCommand, { stdio: "inherit" })
} catch (error) {
console.error(chalk.red("Error generating TypeScript for proto files:"), error)
process.exit(1)
}
// Build the protoc command with proper path handling for cross-platform
const protocCommand = [
protoc,
`--plugin=protoc-gen-ts_proto="${tsProtoPlugin}"`,
`--ts_proto_out="${TS_OUT_DIR}"`,
"--ts_proto_opt=outputServices=generic-definitions,env=node,esModuleInterop=true,useDate=false,useOptionals=messages",
`--proto_path="${SCRIPT_DIR}"`,
`"${path.join(SCRIPT_DIR, protoFile)}"`,
].join(" ")
const descriptorOutDir = path.join(ROOT_DIR, "dist-standalone", "proto")
await fs.mkdir(descriptorOutDir, { recursive: true })
try {
const execOptions = {
stdio: "inherit",
}
execSync(protocCommand, execOptions)
} catch (error) {
console.error(chalk.red(`Error generating TypeScript for ${protoFile}:`), error)
process.exit(1)
}
const descriptorFile = path.join(descriptorOutDir, "descriptor_set.pb")
const descriptorProtocCommand = [
protoc,
`--proto_path="${SCRIPT_DIR}"`,
`--descriptor_set_out="${descriptorFile}"`,
"--include_imports",
...protoFiles,
].join(" ")
try {
console.log(chalk.cyan("Generating descriptor set..."))
execSync(descriptorProtocCommand, { stdio: "inherit" })
} catch (error) {
console.error(chalk.red("Error generating descriptor set for proto file:"), error)
process.exit(1)
}
console.log(chalk.green("Protocol Buffer code generation completed successfully."))
+1
View File
@@ -10,6 +10,7 @@ service McpService {
rpc toggleMcpServer(ToggleMcpServerRequest) returns (McpServers);
rpc updateMcpTimeout(UpdateMcpTimeoutRequest) returns (McpServers);
rpc addRemoteMcpServer(AddRemoteMcpServerRequest) returns (McpServers);
rpc downloadMcp(StringRequest) returns (Empty);
}
message ToggleMcpServerRequest {
+46 -1
View File
@@ -10,7 +10,52 @@ import "common.proto";
service ModelsService {
// Fetches available models from Ollama
rpc getOllamaModels(StringRequest) returns (StringArray);
// Fetches available models from LM Studio
rpc getLmStudioModels(StringRequest) returns (StringArray);
// Fetches available models from VS Code LM API
rpc getVsCodeLmModels(EmptyRequest) returns (VsCodeLmModelsArray);
// Refreshes and returns OpenRouter models
rpc refreshOpenRouterModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns OpenAI models
rpc refreshOpenAiModels(OpenAiModelsRequest) returns (StringArray);
// Refreshes and returns Requesty models
rpc refreshRequestyModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
}
// List of VS Code LM models
message VsCodeLmModelsArray {
repeated VsCodeLmModel models = 1;
}
// Structure representing a VS Code LM model
message VsCodeLmModel {
string vendor = 1;
string family = 2;
string version = 3;
string id = 4;
}
// For OpenRouterCompatibleModelInfo structure in OpenRouterModels
message OpenRouterModelInfo {
int32 max_tokens = 1;
int32 context_window = 2;
bool supports_images = 3;
bool supports_prompt_cache = 4;
double input_price = 5;
double output_price = 6;
double cache_writes_price = 7;
double cache_reads_price = 8;
string description = 9;
}
// Shared response message for model information
message OpenRouterCompatibleModelInfo {
map<string, OpenRouterModelInfo> models = 1;
}
// Request for fetching OpenAI models
message OpenAiModelsRequest {
Metadata metadata = 1;
string baseUrl = 2;
string apiKey = 3;
}
+14
View File
@@ -0,0 +1,14 @@
syntax = "proto3";
package cline;
option java_package = "bot.cline.proto";
option java_multiple_files = true;
import "common.proto";
// SlashService provides methods for managing slash
service SlashService {
// Sends button click message
rpc reportBug(StringRequest) returns (Empty);
rpc condense(StringRequest) returns (Empty);
}
+1
View File
@@ -6,6 +6,7 @@ import "common.proto";
service StateService {
rpc getLatestState(EmptyRequest) returns (State);
rpc subscribeToState(EmptyRequest) returns (stream State);
rpc toggleFavoriteModel(StringRequest) returns (Empty);
}
message State {
+62 -1
View File
@@ -16,9 +16,15 @@ service TaskService {
// Creates a new task with the given text and optional images
rpc newTask(NewTaskRequest) returns (Empty);
// Shows a task with the specified ID
rpc showTaskWithId(StringRequest) returns (Empty);
rpc showTaskWithId(StringRequest) returns (TaskResponse);
// Exports a task with the given ID to markdown
rpc exportTaskWithId(StringRequest) returns (Empty);
// Toggles the favorite status of a task
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
// Deletes all non-favorited tasks
rpc deleteNonFavoritedTasks(EmptyRequest) returns (DeleteNonFavoritedTasksResults);
// Gets filtered task history
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
}
// Request message for creating a new task
@@ -28,3 +34,58 @@ message NewTaskRequest {
repeated string images = 3;
}
// Request message for toggling task favorite status
message TaskFavoriteRequest {
Metadata metadata = 1;
string task_id = 2;
bool is_favorited = 3;
}
// Response for task details
message TaskResponse {
string id = 1;
string task = 2;
int64 ts = 3;
bool is_favorited = 4;
int64 size = 5;
double total_cost = 6;
int32 tokens_in = 7;
int32 tokens_out = 8;
int32 cache_writes = 9;
int32 cache_reads = 10;
}
// Results returned when deleting non-favorited tasks
message DeleteNonFavoritedTasksResults {
int32 tasks_preserved = 1;
int32 tasks_deleted = 2;
}
// Request for getting task history with filtering
message GetTaskHistoryRequest {
Metadata metadata = 1;
bool favorites_only = 2;
string search_query = 3;
string sort_by = 4;
bool current_workspace_only = 5;
}
// Response for task history
message TaskHistoryArray {
repeated TaskItem tasks = 1;
int32 total_count = 2;
}
// Task item details for history list
message TaskItem {
string id = 1;
string task = 2;
int64 ts = 3;
bool is_favorited = 4;
int64 size = 5;
double total_cost = 6;
int32 tokens_in = 7;
int32 tokens_out = 8;
int32 cache_writes = 9;
int32 cache_reads = 10;
}
+79
View File
@@ -0,0 +1,79 @@
import * as fs from "fs"
import * as grpc from "@grpc/grpc-js"
import * as protoLoader from "@grpc/proto-loader"
import * as health from "grpc-health-check"
import { fileURLToPath } from "url"
import path from "path"
const OUT_FILE = path.resolve("src/standalone/server-setup.ts")
const DESCRIPTOR_SET = path.resolve("dist-standalone/proto/descriptor_set.pb")
// Load service definitions.
const clineDef = protoLoader.loadFileDescriptorSetFromBuffer(fs.readFileSync(DESCRIPTOR_SET))
const healthDef = protoLoader.loadSync(health.protoPath)
const packageDefinition = { ...clineDef, ...healthDef }
const proto = grpc.loadPackageDefinition(packageDefinition)
/**
* Generate imports and function to add all the handlers to the server for all services defined in the proto files.
*/
function generateHandlersAndExports() {
let imports = []
let handlerSetup = []
for (const [name, def] of Object.entries(proto.cline)) {
if (!def || !("service" in def)) {
continue
}
const domain = name.replace(/Service$/, "")
const dir = domain.charAt(0).toLowerCase() + domain.slice(1)
imports.push(`// ${domain} Service`)
handlerSetup.push(` // ${domain} Service`)
handlerSetup.push(` server.addService(proto.cline.${name}.service, {`)
for (const [rpcName, rpc] of Object.entries(def.service)) {
imports.push(`import { ${rpcName} } from "../core/controller/${dir}/${rpcName}"`)
if (rpc.requestStream) {
throw new Error("Request streaming is not supported")
}
if (rpc.responseStream) {
handlerSetup.push(` ${rpcName}: wrapStreamingResponse(${rpcName}, controller),`)
} else {
handlerSetup.push(` ${rpcName}: wrapper(${rpcName}, controller),`)
}
}
handlerSetup.push(` });`)
imports.push("")
handlerSetup.push("")
}
return {
imports: imports.join("\n"),
handlerSetup: handlerSetup.join("\n"),
}
}
const { imports, handlerSetup } = generateHandlersAndExports()
const scriptName = path.basename(fileURLToPath(import.meta.url))
// Create output file
let output = `// GENERATED CODE -- DO NOT EDIT!
// Generated by ${scriptName}
import * as grpc from "@grpc/grpc-js"
import { Controller } from "../core/controller"
import { GrpcHandlerWrapper, GrpcStreamingResponseHandlerWrapper } from "./grpc-types"
${imports}
export function addServices(
server: grpc.Server,
proto: any,
controller: Controller,
wrapper: GrpcHandlerWrapper,
wrapStreamingResponse: GrpcStreamingResponseHandlerWrapper,
): void {
${handlerSetup}
}
`
// Write output file
fs.writeFileSync(OUT_FILE, output)
console.log(`Generated service handlers in ${OUT_FILE}.`)
+96
View File
@@ -0,0 +1,96 @@
const fs = require("fs")
const path = require("path")
const { Project, SyntaxKind } = require("ts-morph")
function traverse(container, output, prefix = "") {
for (const node of container.getStatements()) {
const kind = node.getKind()
if (kind === SyntaxKind.ModuleDeclaration) {
const name = node.getName().replace(/^['"]|['"]$/g, "")
var fullPrefix
if (prefix) {
fullPrefix = `${prefix}.${name}`
} else {
fullPrefix = name
}
output.push(`${fullPrefix} = {};`)
const body = node.getBody()
if (body && body.getKind() === SyntaxKind.ModuleBlock) {
traverse(body, output, fullPrefix)
}
} else if (kind === SyntaxKind.FunctionDeclaration) {
const name = node.getName()
const params = node.getParameters().map((p, i) => sanitizeParam(p.getName(), i))
const typeNode = node.getReturnTypeNode()
const returnType = typeNode ? typeNode.getText() : ""
const ret = mapReturn(returnType)
output.push(
`${prefix}.${name} = function(${params.join(", ")}) { console.log('Called stubbed function: ${prefix}.${name}'); ${ret} };`,
)
} else if (kind === SyntaxKind.EnumDeclaration) {
const name = node.getName()
const members = node.getMembers().map((m) => m.getName())
output.push(`${prefix}.${name} = { ${members.map((m) => `${m}: 0`).join(", ")} };`)
} else if (kind === SyntaxKind.VariableStatement) {
for (const decl of node.getDeclarations()) {
const name = decl.getName()
output.push(`${prefix}.${name} = createStub("${prefix}.${name}");`)
}
} else if (kind == SyntaxKind.ClassDeclaration) {
const name = node.getName()
output.push(
`${prefix}.${name} = class { constructor(...args) {
console.log('Constructed stubbed class: new ${prefix}.${name}(', args, ')');
return createStub(${prefix}.${name});
}};`,
)
} else if (kind === SyntaxKind.TypeAliasDeclaration || kind === SyntaxKind.InterfaceDeclaration) {
//console.log("Skipping", SyntaxKind[kind], node.getName())
// Skip interfaces and type aliases because they are only used at compile time by typescript.
} else {
console.log("Can't handle: ", SyntaxKind[kind])
}
}
}
function mapReturn(typeStr) {
if (!typeStr) return ""
if (typeStr.includes("void")) return ""
if (typeStr.includes("string")) return `return '';`
if (typeStr.includes("number")) return `return 0;`
if (typeStr.includes("boolean")) return `return false;`
if (typeStr.includes("[]")) return `return [];`
if (typeStr.includes("Thenable")) return `return Promise.resolve(null);`
return `return createStub("unknown");`
}
function sanitizeParam(name, index) {
return name || `arg${index}`
}
async function main() {
const inputPath = "node_modules/@types/vscode/index.d.ts"
const outputPath = "standalone/runtime-files/vscode/vscode-stubs.js"
const project = new Project()
const sourceFile = project.addSourceFileAtPath(inputPath)
const output = []
output.push("// GENERATED CODE -- DO NOT EDIT!")
output.push('console.log("Loading stubs...");')
output.push('const { createStub } = require("./stub-utils")')
traverse(sourceFile, output)
output.push("module.exports = vscode;")
output.push('console.log("Finished loading stubs");')
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
fs.writeFileSync(outputPath, output.join("\n"))
console.log(`Wrote vscode SDK stubs to ${outputPath}`)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -eu
DIR=${1:-src/}
DEST_DIR=dist-standalone
DEST=dist-standalone/vscode-uses.txt
mkdir -p $DEST_DIR
{
git grep -h 'vscode\.' $DIR |
grep -Ev '//.*vscode' | # remove commented out code
sed 's|.*vscode\.|vscode.|'| # remove everything before vscode.
sed 's/[^a-zA-Z0-9_.].*$//' | # remove everything after last identifier
sort | uniq > $DEST
}
echo Done, wrote uses of the vscode SDK to $(realpath $DEST)
+51
View File
@@ -0,0 +1,51 @@
import fs from "fs"
import path from "path"
import { glob } from "glob"
import archiver from "archiver"
import { cp } from "fs/promises"
import { execSync } from "child_process"
const BUILD_DIR = "dist-standalone"
const SOURCE_DIR = "standalone/runtime-files"
await cp(SOURCE_DIR, BUILD_DIR, { recursive: true })
// Run npm install in the distribution directory
console.log("Running npm install in distribution directory...")
const cwd = process.cwd()
process.chdir(BUILD_DIR)
try {
execSync("npm install", { stdio: "inherit" })
} catch (error) {
console.error("Error running npm install:", error)
process.exit(1)
} finally {
process.chdir(cwd)
}
// Check for native .node modules.
const nativeModules = await glob("**/*.node", { cwd: BUILD_DIR, nodir: true })
if (nativeModules.length > 0) {
console.error("Native node modules cannot be included in the standalone distribution:\n", nativeModules.join("\n"))
process.exit(1)
}
// Zip the build directory (excluding any pre-existing output zip).
const zipPath = path.join(BUILD_DIR, "standalone.zip")
const output = fs.createWriteStream(zipPath)
const archive = archiver("zip", { zlib: { level: 9 } })
output.on("close", () => {
console.log(`Created ${zipPath} (${archive.pointer()} bytes)`)
})
archive.on("error", (err) => {
throw err
})
archive.pipe(output)
archive.glob("**/*", {
cwd: BUILD_DIR,
ignore: ["standalone.zip"],
})
await archive.finalize()
+3
View File
@@ -19,6 +19,7 @@ import { DoubaoHandler } from "./providers/doubao"
import { VsCodeLmHandler } from "./providers/vscode-lm"
import { ClineHandler } from "./providers/cline"
import { LiteLlmHandler } from "./providers/litellm"
import { FireworksHandler } from "./providers/fireworks"
import { AskSageHandler } from "./providers/asksage"
import { XAIHandler } from "./providers/xai"
import { SambanovaHandler } from "./providers/sambanova"
@@ -58,6 +59,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new DeepSeekHandler(options)
case "requesty":
return new RequestyHandler(options)
case "fireworks":
return new FireworksHandler(options)
case "together":
return new TogetherHandler(options)
case "qwen":
+6 -2
View File
@@ -272,10 +272,14 @@ export class AwsBedrockHandler implements ApiHandler {
}
/**
* Gets the appropriate model ID, accounting for cross-region inference if enabled
* Gets the appropriate model ID, accounting for cross-region inference if enabled.
* If the model ID is an ARN that contains a slash, you will get the URL encoded ARN.
*/
async getModelId(): Promise<string> {
if (this.options.awsUseCrossRegionInference) {
if (this.options.awsBedrockCustomSelected && this.getModel().id.includes("/")) {
return encodeURIComponent(this.getModel().id)
}
if (!this.options.awsBedrockCustomSelected && this.options.awsUseCrossRegionInference) {
const regionPrefix = this.getRegion().slice(0, 3)
switch (regionPrefix) {
case "us-":
+1 -1
View File
@@ -33,7 +33,7 @@ export class ClineHandler implements ApiHandler {
systemPrompt,
messages,
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
)
+94
View File
@@ -0,0 +1,94 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { withRetry } from "../retry"
import { ApiHandler } from ".."
import {
ApiHandlerOptions,
DeepSeekModelId,
ModelInfo,
deepSeekDefaultModelId,
deepSeekModels,
openAiModelInfoSaneDefaults,
} from "../../shared/api"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
export class FireworksHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.fireworks.ai/inference/v1",
apiKey: this.options.fireworksApiKey,
})
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const modelId = this.options.fireworksModelId ?? ""
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await this.client.chat.completions.create({
model: modelId,
...(this.options.fireworksModelMaxCompletionTokens
? { max_completion_tokens: this.options.fireworksModelMaxCompletionTokens }
: {}),
...(this.options.fireworksModelMaxTokens ? { max_tokens: this.options.fireworksModelMaxTokens } : {}),
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
temperature: 0,
})
let reasoning: string | null = null
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (reasoning || delta?.content?.includes("<think>")) {
reasoning = (reasoning || "") + (delta.content ?? "")
}
if (delta?.content && !reasoning) {
yield {
type: "text",
text: delta.content,
}
}
if (reasoning || ("reasoning_content" in delta && delta.reasoning_content)) {
yield {
type: "reasoning",
reasoning: delta.content || ((delta as any).reasoning_content as string | undefined) || "",
}
if (reasoning?.includes("</think>")) {
// Reset so the next chunk is regular content
reasoning = null
}
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0, // (deepseek reports total input AND cache reads/writes, see context caching: https://api-docs.deepseek.com/guides/kv_cache) where the input tokens is the sum of the cache hits/misses, while anthropic reports them as separate tokens. This is important to know for 1) context management truncation algorithm, and 2) cost calculation (NOTE: we report both input and cache stats but for now set input price to 0 since all the cost calculation will be done using cache hits/misses)
outputTokens: chunk.usage.completion_tokens || 0,
// @ts-ignore-next-line
cacheReadTokens: chunk.usage.prompt_cache_hit_tokens || 0,
// @ts-ignore-next-line
cacheWriteTokens: chunk.usage.prompt_cache_miss_tokens || 0,
}
}
}
}
getModel(): { id: string; info: ModelInfo } {
return {
id: this.options.fireworksModelId ?? "",
info: openAiModelInfoSaneDefaults,
}
}
}
+97 -340
View File
@@ -1,12 +1,12 @@
import type { Anthropic } from "@anthropic-ai/sdk"
// Restore GenerateContentConfig import and add GenerateContentResponseUsageMetadata
import { GoogleGenAI, type Content, type GenerateContentConfig, type GenerateContentResponseUsageMetadata } from "@google/genai"
import NodeCache from "node-cache"
import { GoogleGenAI, type GenerateContentConfig, type GenerateContentResponseUsageMetadata } from "@google/genai"
import { withRetry } from "../retry"
import { ApiHandler } from "../"
import { ApiHandlerOptions, geminiDefaultModelId, GeminiModelId, geminiModels, ModelInfo } from "@shared/api"
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
import { ApiStream } from "../transform/stream"
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
// Define a default TTL for the cache (e.g., 15 minutes in seconds)
const DEFAULT_CACHE_TTL_SECONDS = 900
@@ -39,12 +39,6 @@ export class GeminiHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: GoogleGenAI
// Enhanced caching system
private contentCaches: NodeCache // Stores cache details (key, count, etc.)
private isCacheBusy = false
private taskCacheNames: Map<string, string> = new Map() // Maps taskId to cache name for stable lookup
private taskCacheTokens: Map<string, number> = new Map() // Maps taskId to total tokens in cache
constructor(options: GeminiHandlerOptions) {
// Store the options
this.options = options
@@ -67,26 +61,13 @@ export class GeminiHandler implements ApiHandler {
this.client = new GoogleGenAI({ apiKey: options.geminiApiKey })
}
// Initialize cache with TTL and check period
this.contentCaches = new NodeCache({
stdTTL: DEFAULT_CACHE_TTL_SECONDS,
checkperiod: DEFAULT_CACHE_TTL_SECONDS,
})
}
/**
* Creates a message using the Gemini API with optimized caching and split cost accounting.
*
* This method implements a task-based caching strategy:
* 1. Each task gets its own cache, identified by taskId
* 2. On first call for a task, a new cache is created
* 3. On subsequent calls, the existing cache is reused and only new messages are sent
* 4. Cache operations are tracked for accurate cost accounting
* Creates a message using the Gemini API with implicit caching.
*
* Cost accounting:
* - Immediate costs (returned in the usage object): Input tokens, output tokens, cache read costs
* - Ongoing costs (tracked at task level): Cache storage costs for the TTL period
*
* @param systemPrompt The system prompt to use for the message
* @param messages The conversation history to include in the message
@@ -94,57 +75,9 @@ export class GeminiHandler implements ApiHandler {
*/
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const { id: model, info } = this.getModel()
const { id: modelId, info } = this.getModel()
const contents = messages.map(convertAnthropicMessageToGemini)
// Ensure we have a stable cache key (taskId)
if (!this.options.taskId) {
console.warn("[GeminiHandler] No taskId provided, caching will be disabled")
}
const taskId = this.options.taskId
// Calculate total content length for cache eligibility check
const contentsLength = systemPrompt.length + this.getMessagesLength(contents)
// Minimum token threshold for caching (approx 4096 tokens)
const CONTEXT_CACHE_TOKEN_MINIMUM = 4096
let uncachedContent: Content[] | undefined = undefined
let cachedContent: string | undefined = undefined
// Check if caching is available and content is large enough to benefit from caching
// We only enable caching for conversations above a certain size to avoid overhead for small requests
const isCacheAvailable = info.supportsPromptCache && contentsLength > 4 * CONTEXT_CACHE_TOKEN_MINIMUM && taskId
// This flag tracks whether this operation involves a cache write/update
// It's used to track task-level ongoing costs, not immediate costs
let cacheWrite = false
if (isCacheAvailable) {
// Check if we already have a cache for this task
const existingCacheName = this.taskCacheNames.get(taskId)
const cacheEntry = existingCacheName ? this.contentCaches.get<{ key: string; count: number }>(taskId) : undefined
if (cacheEntry) {
// Use existing cache
uncachedContent = contents.slice(cacheEntry.count, contents.length)
cachedContent = cacheEntry.key
console.log(
`[GeminiHandler] using existing cache for task ${taskId}: ${cacheEntry.count} cached messages (${cacheEntry.key}) and ${uncachedContent.length} uncached messages`,
)
}
// Create or update cache only if there's new content to add
const shouldUpdateCache = !existingCacheName || (cacheEntry && uncachedContent && uncachedContent.length > 0)
if (shouldUpdateCache) {
// If we should update the cache, then there will be a cache write
cacheWrite = true
}
}
const isCacheUsed = !!cachedContent
// Configure thinking budget if supported
const thinkingBudget = this.options.thinkingBudgetTokens ?? 0
const maxBudget = info.thinkingConfig?.maxBudget ?? 0
@@ -153,10 +86,7 @@ export class GeminiHandler implements ApiHandler {
const requestConfig: GenerateContentConfig = {
// Add base URL if configured
httpOptions: this.options.geminiBaseUrl ? { baseUrl: this.options.geminiBaseUrl } : undefined,
// Only include systemInstruction if NOT using the cache
...(isCacheUsed ? {} : { systemInstruction: systemPrompt }),
...{ systemInstruction: systemPrompt },
// Set temperature (default to 0)
temperature: 0,
}
@@ -169,260 +99,114 @@ export class GeminiHandler implements ApiHandler {
}
// Generate content using the configured parameters
const result = await this.client.models.generateContentStream({
model,
contents: uncachedContent ?? contents,
config: {
...requestConfig,
...(isCacheUsed ? { cachedContent } : {}),
},
})
// Update the cache after the LLM request is already sent to avoid blocking
// We only update the cache if we have a taskId and the cache write flag is set
// This is a non-blocking operation and will not affect the response time
if (cacheWrite && taskId) {
this.updateCacheContent(taskId, model, contents, systemPrompt)
}
// Track usage metadata
const sdkCallStartTime = Date.now()
let sdkFirstChunkTime: number | undefined
let ttftSdkMs: number | undefined
let apiSuccess = false
let apiError: string | undefined
let promptTokens = 0
let outputTokens = 0
let cacheReadTokens = 0
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
// Process the stream
for await (const chunk of result) {
if (chunk.text) {
yield {
type: "text",
text: chunk.text,
}
}
if (chunk.usageMetadata) {
lastUsageMetadata = chunk.usageMetadata
}
}
// Yield usage information at the end
if (lastUsageMetadata) {
const inputTokens = lastUsageMetadata.promptTokenCount ?? 0
const outputTokens = lastUsageMetadata.candidatesTokenCount ?? 0
const cacheReadTokens = lastUsageMetadata.cachedContentTokenCount
// Calculate immediate costs only (excluding cache write/storage costs)
const totalCost = this.calculateCost({
info,
inputTokens,
outputTokens,
cacheReadTokens,
})
// Store the token count for task-level ongoing cost tracking
// This is not included in the immediate costs returned to the user
const cacheWriteTokens = cacheWrite ? inputTokens : undefined
// If this is a cache write operation, update the task's ongoing costs
if (cacheWrite && this.options.taskId && inputTokens > 0) {
// Log the ongoing costs for debugging
const ongoingCosts = this.getTaskOngoingCosts(this.options.taskId)
console.log(
`[GeminiHandler] Task ${this.options.taskId} ongoing costs: $${ongoingCosts?.toFixed(6) ?? "unknown"}`,
)
}
yield {
type: "usage",
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
totalCost,
}
}
}
/**
* Lists all caches for the current API key.
*
* According to the Gemini API documentation, you can retrieve metadata for all uploaded caches
* using the caches.list() method. This is useful for monitoring cache usage and cleanup.
*
* @param pageSize Optional number of caches to return per page (default: 10)
* @returns A promise that resolves to an array of cache metadata objects
*/
public async listCaches(pageSize: number = 10): Promise<any[]> {
try {
const caches: any[] = []
const pager = await this.client.caches.list({ config: { pageSize } })
let page = pager.page
while (true) {
for (const cache of page) {
caches.push(cache)
}
if (!pager.hasNextPage()) {
break
}
page = await pager.nextPage()
}
return caches
} catch (error) {
console.error(`[GeminiHandler] Failed to list caches:`, error)
return []
}
}
/**
* Updates the content of a cache for a specific task.
*
* Since the Gemini API doesn't support incremental updates to cache content,
* this method:
* 1. Creates a new cache with the full content (old + new)
* 2. Deletes the old cache if it exists
* 3. Updates our local tracking to point to the new cache
*
* @param taskId The ID of the task whose cache should be updated
* @param model The model to use for the cache
* @param contents The full content to cache (including both old and new messages)
* @param systemInstruction The system instruction to include in the cache
*/
private async updateCacheContent(
taskId: string,
model: string,
contents: Content[],
systemInstruction: string,
): Promise<void> {
if (this.isCacheBusy) {
console.log(`[GeminiHandler] Cache is busy, skipping update for task ${taskId}`)
return
}
this.isCacheBusy = true
const timestamp = Date.now()
const existingCacheName = this.taskCacheNames.get(taskId)
try {
// 1. Create a new cache with the full content
const result = await this.client.caches.create({
model,
const result = await this.client.models.generateContentStream({
model: modelId,
contents: contents,
config: {
contents,
systemInstruction,
ttl: `${DEFAULT_CACHE_TTL_SECONDS}s`,
httpOptions: { timeout: 120_000 },
...requestConfig,
},
})
const { name, usageMetadata } = result
if (name) {
// 2. Delete the old cache if it exists (non-blocking)
// We don't await this operation to avoid blocking the main flow if deletion fails
if (existingCacheName) {
// Schedule cache deletion in the background
setTimeout(() => {
this.client.caches
.delete({ name: existingCacheName })
.then(() => {
console.log(`[GeminiHandler] Deleted old cache ${existingCacheName} for task ${taskId}`)
})
.catch((error) => {
console.error(`[GeminiHandler] Failed to delete old cache ${existingCacheName}:`, error)
console.log(`[GeminiHandler] Continuing without deleting old cache. It will expire after TTL.`)
})
}, 1000)
let isFirstSdkChunk = true
for await (const chunk of result) {
if (isFirstSdkChunk) {
sdkFirstChunkTime = Date.now()
ttftSdkMs = sdkFirstChunkTime - sdkCallStartTime
isFirstSdkChunk = false
}
// 3. Update our local tracking
this.contentCaches.set<{ key: string; count: number }>(taskId, {
key: name,
count: contents.length,
if (chunk.text) {
yield {
type: "text",
text: chunk.text,
}
}
if (chunk.usageMetadata) {
lastUsageMetadata = chunk.usageMetadata
promptTokens = lastUsageMetadata.promptTokenCount ?? promptTokens
outputTokens = lastUsageMetadata.candidatesTokenCount ?? outputTokens
cacheReadTokens = lastUsageMetadata.cachedContentTokenCount ?? cacheReadTokens
}
}
apiSuccess = true
if (lastUsageMetadata) {
const totalCost = this.calculateCost({
info,
inputTokens: promptTokens,
outputTokens,
cacheReadTokens,
})
this.taskCacheNames.set(taskId, name)
yield {
type: "usage",
inputTokens: promptTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens: 0,
totalCost,
}
}
} catch (error) {
apiSuccess = false
// Let the error propagate to be handled by withRetry or Task.ts
// Telemetry will be sent in the finally block.
if (error instanceof Error) {
apiError = error.message
// Track total tokens in cache for ongoing cost calculation
const totalTokens = usageMetadata?.totalTokenCount ?? 0
this.taskCacheTokens.set(taskId, totalTokens)
const operation = existingCacheName ? "Updated" : "Created new"
console.log(
`[GeminiHandler] ${operation} cache for task ${taskId}: ${contents.length} messages (${totalTokens} tokens) in ${Date.now() - timestamp}ms`,
)
return // Indicate that a cache write occurred
// Gemini doesn't include status codes in their errors
// https://github.com/googleapis/js-genai/blob/61f7f27b866c74333ca6331883882489bcb708b9/src/_api_client.ts#L569
if (error.name === "ClientError" && error.message.includes("got status: 429 Too Many Requests.")) {
;(error as any).status = 429
}
} else {
apiError = String(error)
}
return
} catch (error) {
console.error(`[GeminiHandler] Failed to update cache for task ${taskId}:`, error)
return
throw error
} finally {
this.isCacheBusy = false
const sdkCallEndTime = Date.now()
const totalDurationSdkMs = sdkCallEndTime - sdkCallStartTime
const cacheHit = cacheReadTokens > 0
const cacheHitPercentage = promptTokens > 0 ? (cacheReadTokens / promptTokens) * 100 : undefined
const throughputTokensPerSecSdk =
totalDurationSdkMs > 0 && outputTokens > 0 ? outputTokens / (totalDurationSdkMs / 1000) : undefined
if (this.options.taskId) {
telemetryService.captureGeminiApiPerformance(
this.options.taskId,
modelId,
{
ttftSec: ttftSdkMs !== undefined ? ttftSdkMs / 1000 : undefined,
totalDurationSec: totalDurationSdkMs / 1000,
promptTokens,
outputTokens,
cacheReadTokens,
cacheHit,
cacheHitPercentage,
apiSuccess,
apiError,
throughputTokensPerSec: throughputTokensPerSecSdk,
},
true,
)
} else {
console.warn("GeminiHandler: taskId not available for telemetry in createMessage.")
}
}
}
/**
* Updates the TTL of an existing cache.
*
* According to the Gemini API documentation, you can update the TTL of a cache
* using the caches.update() method. This is useful for extending the lifetime
* of a cache that's still being used.
*
* @param taskId The ID of the task whose cache TTL should be updated
* @param ttlSeconds The new TTL in seconds
* @returns A promise that resolves to the updated cache, or undefined if the update fails
*/
public async updateCacheTTL(taskId: string, ttlSeconds: number = DEFAULT_CACHE_TTL_SECONDS): Promise<any> {
const cacheName = this.taskCacheNames.get(taskId)
if (!cacheName) {
console.warn(`[GeminiHandler] No cache found for task ${taskId}, cannot update TTL`)
return
}
try {
const updatedCache = await this.client.caches.update({
name: cacheName,
config: { ttl: `${ttlSeconds}s` },
})
console.log(`[GeminiHandler] Updated TTL for cache ${cacheName} to ${ttlSeconds}s`)
return updatedCache
} catch (error) {
console.error(`[GeminiHandler] Failed to update TTL for cache ${cacheName}:`, error)
}
}
/**
* Calculate the ongoing costs for a task based on cache storage.
*
* This method calculates the cost of holding tokens in cache for the TTL period.
* These costs are separate from the immediate costs of API calls and should be
* tracked at the task level rather than the message level.
*
* TODO: Surface these ongoing costs to the user in the UI, possibly in:
* - The task header/summary
* - A dedicated "costs" panel or tooltip
* - As part of the total cost calculation for the task
*
* @param taskId The ID of the task to calculate ongoing costs for
* @returns The ongoing cost in dollars, or undefined if no cache exists for the task
*/
public getTaskOngoingCosts(taskId: string): number | undefined {
const tokens = this.taskCacheTokens.get(taskId)
if (!tokens) {
return undefined
}
const { info } = this.getModel()
if (!info.cacheWritesPrice) {
return undefined
}
// Calculate the cost of holding tokens in cache for the TTL period
// (tokens / 1M) * (price per 1M tokens) * (cache TTL in hours)
return info.cacheWritesPrice * (tokens / 1_000_000) * (DEFAULT_CACHE_TTL_SECONDS / 3600)
}
/**
* Calculate the immediate dollar cost of the API call based on token usage and model pricing.
*
@@ -430,21 +214,18 @@ export class GeminiHandler implements ApiHandler {
* - Input token costs (for uncached tokens)
* - Output token costs
* - Cache read costs
* - Gemini implicit caching has no write costs
*
* It does NOT include ongoing costs like cache storage, which are tracked separately
* at the task level through getTaskOngoingCosts().
*/
public calculateCost({
info,
inputTokens,
outputTokens,
cacheWriteTokens = 0,
cacheReadTokens = 0,
}: {
info: ModelInfo
inputTokens: number
outputTokens: number
cacheWriteTokens?: number
cacheReadTokens?: number
}) {
// Exit early if any required pricing information is missing
@@ -454,9 +235,7 @@ export class GeminiHandler implements ApiHandler {
let inputPrice = info.inputPrice
let outputPrice = info.outputPrice
let cacheWritesPrice = info.cacheWritesPrice ?? 0
// Right now, we only show the immediate costs of caching and not the ongoing costs of storing the cache
cacheWritesPrice = 0
let cacheReadsPrice = info.cacheReadsPrice ?? 0
// If there's tiered pricing then adjust prices based on the input tokens used
@@ -465,7 +244,6 @@ export class GeminiHandler implements ApiHandler {
if (tier) {
inputPrice = tier.inputPrice ?? inputPrice
outputPrice = tier.outputPrice ?? outputPrice
cacheWritesPrice = tier.cacheWritesPrice ?? cacheWritesPrice
cacheReadsPrice = tier.cacheReadsPrice ?? cacheReadsPrice
}
}
@@ -502,27 +280,6 @@ export class GeminiHandler implements ApiHandler {
return totalCost
}
/**
* Calculate the total length of all messages for cache eligibility check
*/
private getMessagesLength(contents: Content[]): number {
return contents.reduce((total, content) => {
if (!content.parts) {
return total
}
return (
total +
content.parts.reduce((partTotal, part) => {
if (typeof part.text === "string") {
return partTotal + part.text.length
}
return partTotal
}, 0)
)
}, 0)
}
/**
* Get the model ID and info for the current configuration
*/
+2 -2
View File
@@ -65,7 +65,7 @@ export class LiteLlmHandler implements ApiHandler {
const reasoningOn = budgetTokens !== 0 ? true : false
const thinkingConfig = reasoningOn ? { type: "enabled", budget_tokens: budgetTokens } : undefined
let temperature: number | undefined = 0
let temperature: number | undefined = this.options.liteLlmModelInfo?.temperature ?? 0
if (isOminiModel && reasoningOn) {
temperature = undefined // Thinking mode doesn't support temperature
@@ -169,7 +169,7 @@ export class LiteLlmHandler implements ApiHandler {
getModel() {
return {
id: this.options.liteLlmModelId || liteLlmDefaultModelId,
info: liteLlmModelInfoSaneDefaults,
info: this.options.liteLlmModelInfo || liteLlmModelInfoSaneDefaults,
}
}
}
+2 -1
View File
@@ -66,8 +66,9 @@ export class OpenAiNativeHandler implements ApiHandler {
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
stream_options: { include_usage: true },
reasoning_effort: (this.options.o3MiniReasoningEffort as ChatCompletionReasoningEffort) || "medium",
reasoning_effort: (this.options.reasoningEffort as ChatCompletionReasoningEffort) || "medium",
})
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
+1 -1
View File
@@ -65,7 +65,7 @@ export class OpenAiHandler implements ApiHandler {
if (isReasoningModelFamily) {
openAiMessages = [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
temperature = undefined // does not support temperature
reasoningEffort = (this.options.o3MiniReasoningEffort as ChatCompletionReasoningEffort) || "medium"
reasoningEffort = (this.options.reasoningEffort as ChatCompletionReasoningEffort) || "medium"
}
const stream = await this.client.chat.completions.create({
+1 -1
View File
@@ -35,7 +35,7 @@ export class OpenRouterHandler implements ApiHandler {
systemPrompt,
messages,
this.getModel(),
this.options.o3MiniReasoningEffort,
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
this.options.openRouterProviderSorting,
)
+1 -1
View File
@@ -32,7 +32,7 @@ export class RequestyHandler implements ApiHandler {
...convertToOpenAiMessages(messages),
]
const reasoningEffort = this.options.o3MiniReasoningEffort || "medium"
const reasoningEffort = this.options.reasoningEffort || "medium"
const reasoning = { reasoning_effort: reasoningEffort }
const reasoningArgs = model.id.startsWith("openai/o") ? reasoning : {}
+2 -70
View File
@@ -9,7 +9,7 @@ export async function createOpenRouterStream(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: string; info: ModelInfo },
o3MiniReasoningEffort?: string,
reasoningEffort?: string,
thinkingBudgetTokens?: number,
openRouterProviderSorting?: string,
) {
@@ -75,74 +75,6 @@ export async function createOpenRouterStream(
break
}
// handles gemini caching logic
if (model.id.startsWith("google/") && model.info.supportsPromptCache) {
// gemini only uses the last breakpoint for caching, so the others will be ignored
openAiMessages[0] = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
},
],
}
// for safety, but this should always be the case
if (openAiMessages.length >= 2) {
const msg = openAiMessages[1]
if (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" }
}
}
}
// it doesn't make sense to alter breakpoints at all with the gemini cache implementation at this time
/*const GEMINI_CACHE_USER_MESSAGE_INTERVAL = 4 // add new breakpoint every 4 turns
const userMessages = openAiMessages.filter((msg) => msg.role === "user")
const userMessageCount = userMessages.length
const targetUserMessageNumber =
Math.floor(userMessageCount / GEMINI_CACHE_USER_MESSAGE_INTERVAL) * GEMINI_CACHE_USER_MESSAGE_INTERVAL
if (targetUserMessageNumber > 0) {
// otherwise dont need to add a breakpoint
const msg = userMessages[targetUserMessageNumber - 1]
if (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" }
}
}
}*/
}
// 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
@@ -212,7 +144,7 @@ export async function createOpenRouterStream(
stream_options: { include_usage: true },
transforms: shouldApplyMiddleOutTransform ? ["middle-out"] : undefined,
include_reasoning: true,
...(model.id.startsWith("openai/o") ? { reasoning_effort: o3MiniReasoningEffort || "medium" } : {}),
...(model.id.startsWith("openai/o") ? { reasoning_effort: reasoningEffort || "medium" } : {}),
...(reasoning ? { reasoning } : {}),
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
})
+7 -1
View File
@@ -1,6 +1,6 @@
export type AssistantMessageContent = TextContent | ToolUse
export { parseAssistantMessage } from "./parse-assistant-message"
export { parseAssistantMessageV1, parseAssistantMessageV2 } from "./parse-assistant-message"
export interface TextContent {
type: "text"
@@ -25,6 +25,7 @@ export const toolUseNames = [
"attempt_completion",
"new_task",
"condense",
"report_bug",
"new_rule",
] as const
@@ -53,6 +54,11 @@ export const toolParamNames = [
"response",
"result",
"context",
"title",
"what_happened",
"steps_to_reproduce",
"api_request_output",
"additional_context",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@@ -1,6 +1,24 @@
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "."
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "." // Assuming types are defined in index.ts or a similar file
export function parseAssistantMessage(assistantMessage: string) {
/**
* @description **Version 1**
* Parses an assistant message string potentially containing mixed text and tool usage blocks
* marked with XML-like tags into an array of structured content objects.
*
* This version iterates through the message character by character, building an accumulator string.
* It maintains state to track whether it's currently parsing text, a tool use block, or a specific tool parameter.
* It detects the start and end of tool uses and parameters by checking if the accumulator ends with
* the corresponding opening or closing tags.
* Special handling is included for `write_to_file` and `new_rule` tool uses to correctly parse
* the `content` parameter, which might contain the closing tag itself, by looking for the *last*
* occurrence of the closing tag.
* If the input string ends mid-tag or mid-content, the last block (text or tool use) is marked as partial.
*
* @param assistantMessage The raw string output from the assistant.
* @returns An array of `AssistantMessageContent` objects, which can be `TextContent` or `ToolUse`.
* Blocks that were not fully closed by the end of the input string will have their `partial` flag set to `true`.
*/
export function parseAssistantMessageV1(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContent: TextContent | undefined = undefined
let currentTextContentStartIndex = 0
@@ -14,46 +32,56 @@ export function parseAssistantMessage(assistantMessage: string) {
const char = assistantMessage[i]
accumulator += char
// --- State: Parsing a Tool Parameter ---
// there should not be a param without a tool use
if (currentToolUse && currentParamName) {
const currentParamValue = accumulator.slice(currentParamValueStartIndex)
const paramClosingTag = `</${currentParamName}>`
if (currentParamValue.endsWith(paramClosingTag)) {
// end of param value
// End of param value found
currentToolUse.params[currentParamName] = currentParamValue.slice(0, -paramClosingTag.length).trim()
currentParamName = undefined
continue
currentParamName = undefined // Go back to parsing tool content or looking for next param
continue // Move to next character
} else {
// partial param value is accumulating
continue
// Partial param value is accumulating
continue // Move to next character
}
}
// --- State: Parsing a Tool Use (but not a specific parameter) ---
// no currentParamName
if (currentToolUse) {
const currentToolValue = accumulator.slice(currentToolUseStartIndex)
const toolUseClosingTag = `</${currentToolUse.name}>`
if (currentToolValue.endsWith(toolUseClosingTag)) {
// end of a tool use
// End of a tool use found
currentToolUse.partial = false
contentBlocks.push(currentToolUse)
currentToolUse = undefined
continue
currentToolUse = undefined // Go back to parsing text or looking for next tool
// Reset text start index in case text follows immediately
currentTextContentStartIndex = i + 1
continue // Move to next character
} else {
// Check if starting a new parameter within the current tool use
const possibleParamOpeningTags = toolParamNames.map((name) => `<${name}>`)
let foundParamStart = false
for (const paramOpeningTag of possibleParamOpeningTags) {
if (accumulator.endsWith(paramOpeningTag)) {
// start of a new parameter
// Start of a new parameter found
currentParamName = paramOpeningTag.slice(1, -1) as ToolParamName
currentParamValueStartIndex = accumulator.length
foundParamStart = true
break
}
}
if (foundParamStart) {
continue // Move to next character
}
// there's no current param, and not starting a new param
// special case for write_to_file where file contents could contain the closing tag, in which case the param would have closed and we end up with the rest of the file contents here. To work around this, we get the string between the starting content tag and the LAST content tag.
// Special case for write_to_file/new_rule content param allowing nested tags
// Check if a </content> tag appears, potentially indicating the end of the content param
// even if the main tool closing tag hasn't been seen yet.
const contentParamName: ToolParamName = "content"
if (
(currentToolUse.name === "write_to_file" || currentToolUse.name === "new_rule") &&
@@ -63,73 +91,384 @@ export function parseAssistantMessage(assistantMessage: string) {
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
// Use lastIndexOf to handle cases where </content> might appear within the content itself
const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) {
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
// Ensure we found valid start/end tags and end is after start
if (
contentStartIndex !== -1 &&
contentEndIndex !== -1 &&
contentEndIndex > contentStartIndex - contentStartTag.length // Ensure end tag is after start tag begins
) {
// Check if this content param was already being parsed. If so, update it.
// If not, and we just found the closing tag, assign it.
// This handles cases where the </content> detection might fire before
// the <content> tag detection logic, or if the content is very short.
if (currentParamName === contentParamName) {
// Already parsing content, now we found the end tag
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
currentParamName = undefined // Finished with this param
} else if (currentParamName === undefined) {
// Not parsing a param, but found </content>. Assume it closes the content block.
currentToolUse.params[contentParamName] = toolContent.slice(contentStartIndex, contentEndIndex).trim()
// We stay in the "parsing tool use" state, looking for more params or the tool end tag.
}
}
}
// partial tool value is accumulating
continue
// If none of the above, partial tool value is accumulating
continue // Move to next character
}
}
// --- State: Parsing Text (or looking for start of a tool use) ---
// no currentToolUse
let didStartToolUse = false
const possibleToolUseOpeningTags = toolUseNames.map((name) => `<${name}>`)
for (const toolUseOpeningTag of possibleToolUseOpeningTags) {
if (accumulator.endsWith(toolUseOpeningTag)) {
// start of a new tool use
// Start of a new tool use found
const toolName = toolUseOpeningTag.slice(1, -1) as ToolUseName
currentToolUse = {
type: "tool_use",
name: toolUseOpeningTag.slice(1, -1) as ToolUseName,
name: toolName,
params: {},
partial: true,
}
currentToolUseStartIndex = accumulator.length
// this also indicates the end of the current text content
// This also indicates the end of the current text content block (if any)
if (currentTextContent) {
currentTextContent.partial = false
// remove the partially accumulated tool use tag from the end of text (<tool)
currentTextContent.content = currentTextContent.content
.slice(0, -toolUseOpeningTag.slice(0, -1).length)
.trim()
contentBlocks.push(currentTextContent)
// Extract text content, removing the part that formed the tool opening tag
const textEndIndex = accumulator.length - toolUseOpeningTag.length
currentTextContent.content = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
// Only add if there's actual content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
} else {
// Check if there was text before this tool use started
const textEndIndex = accumulator.length - toolUseOpeningTag.length
const potentialText = accumulator.slice(currentTextContentStartIndex, textEndIndex).trim()
if (potentialText.length > 0) {
contentBlocks.push({
type: "text",
content: potentialText,
partial: false, // Ended because tool use started
})
}
}
didStartToolUse = true
break
break // Found tool start, stop checking for others
}
}
if (!didStartToolUse) {
// no tool use, so it must be text either at the beginning or between tools
// No tool use started, so it must be text content accumulating
// (or continuing after a closed tool use)
if (currentTextContent === undefined) {
currentTextContentStartIndex = i
}
currentTextContent = {
type: "text",
content: accumulator.slice(currentTextContentStartIndex).trim(),
partial: true,
}
}
}
// Start of a new text block
currentTextContentStartIndex = i - (accumulator.length - currentTextContentStartIndex - 1) // Adjust start index based on how much we've accumulated since the last block ended or the beginning
// If accumulator starts from 0, start index is i
if (contentBlocks.length === 0 && currentToolUse === undefined) {
currentTextContentStartIndex = accumulator.length - 1 // i
} else {
// Re-calculate based on the actual start of the current text segment
// Find the end of the last block
let lastBlockEndIndex = 0
if (contentBlocks.length > 0) {
const lastBlock = contentBlocks[contentBlocks.length - 1]
// Approximation: find where the accumulator matches the end of the message string representation of the last block. This is complex.
// Simpler: Assume text starts right after the last block ended implicitly at index i.
lastBlockEndIndex = i // Where the loop *was* when the last block finished processing
// Need a more robust way to track the end index of the *raw string* corresponding to the last block.
// Let's stick to the accumulator slice approach for simplicity in this version.
// The start index should be where the current *unmatched* text began.
let lastProcessedIndex = -1
if (contentBlocks.length > 0) {
// This requires knowing the raw string length of the previous block, which V1 doesn't explicitly track easily.
// We'll approximate based on the current accumulator and start index logic.
// The issue arises if a tool tag was just closed. accumulator contains everything up to i.
// lastBlockEndIndex should point to the character *after* the closing tag of the last block.
}
// Reset start index to the beginning of the *current* potential text block
currentTextContentStartIndex = accumulator.length - 1 // Start accumulating from the current character `i`
}
// If we just closed a tool, text starts *after* its closing tag
// The logic needs refinement here for accurate start index after a tool closure.
// Let's assume for now the start index logic inside the loop handles it via slicing.
}
currentTextContent = {
type: "text",
content: "", // Content will be filled by slicing accumulator
partial: true,
}
}
// Update text content based on the accumulator from its start index
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trimStart() // Trim start to avoid leading space if text follows tool
}
} // End of loop
// --- Finalization after loop ---
// If a tool use was open at the end
if (currentToolUse) {
// stream did not complete tool call, add it as partial
// If a parameter was open within that tool use
if (currentParamName) {
// tool call has a parameter that was not completed
// The remaining accumulator content belongs to this partial parameter
currentToolUse.params[currentParamName] = accumulator.slice(currentParamValueStartIndex).trim()
}
// Add the potentially partial tool use block
contentBlocks.push(currentToolUse)
}
// If text content was being accumulated at the end
// Note: Only one of currentToolUse or currentTextContent can be defined here,
// as starting a tool use finalizes the preceding text block.
else if (currentTextContent) {
// Update content one last time
currentTextContent.content = accumulator.slice(currentTextContentStartIndex).trim()
// Add the potentially partial text block only if it contains content
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
}
// Note: it doesn't matter if check for currentToolUse or currentTextContent, only one of them will be defined since only one can be partial at a time
if (currentTextContent) {
// stream did not complete text content, add it as partial
contentBlocks.push(currentTextContent)
return contentBlocks
}
/**
* @description **Version 2**
* Parses an assistant message string potentially containing mixed text and tool usage blocks
* marked with XML-like tags into an array of structured content objects.
*
* This version aims for efficiency by avoiding the character-by-character accumulator of V1.
* It iterates through the string using an index `i`. At each position, it checks if the substring
* *ending* at `i` matches any known opening or closing tags for tools or parameters using `startsWith`
* with an offset.
* It uses pre-computed Maps (`toolUseOpenTags`, `toolParamOpenTags`) for quick tag lookups.
* State is managed using indices (`currentTextContentStart`, `currentToolUseStart`, `currentParamValueStart`)
* pointing to the start of the current block within the original `assistantMessage` string.
* Slicing is used to extract content only when a block (text, parameter, or tool use) is completed.
* Special handling for `write_to_file` and `new_rule` content parameters is included, using `indexOf`
* and `lastIndexOf` on the relevant slice to handle potentially nested closing tags.
* If the input string ends mid-block, the last open block is added and marked as partial.
*
* @param assistantMessage The raw string output from the assistant.
* @returns An array of `AssistantMessageContent` objects, which can be `TextContent` or `ToolUse`.
* Blocks that were not fully closed by the end of the input string will have their `partial` flag set to `true`.
*/
export function parseAssistantMessageV2(assistantMessage: string): AssistantMessageContent[] {
const contentBlocks: AssistantMessageContent[] = []
let currentTextContentStart = 0 // Index where the current text block started
let currentTextContent: TextContent | undefined = undefined
let currentToolUseStart = 0 // Index *after* the opening tag of the current tool use
let currentToolUse: ToolUse | undefined = undefined
let currentParamValueStart = 0 // Index *after* the opening tag of the current param
let currentParamName: ToolParamName | undefined = undefined
// Precompute tags for faster lookups
const toolUseOpenTags = new Map<string, ToolUseName>()
const toolParamOpenTags = new Map<string, ToolParamName>()
for (const name of toolUseNames) {
toolUseOpenTags.set(`<${name}>`, name)
}
for (const name of toolParamNames) {
toolParamOpenTags.set(`<${name}>`, name)
}
const len = assistantMessage.length
for (let i = 0; i < len; i++) {
const currentCharIndex = i
// --- State: Parsing a Tool Parameter ---
if (currentToolUse && currentParamName) {
const closeTag = `</${currentParamName}>`
// Check if the string *ending* at index `i` matches the closing tag
if (
currentCharIndex >= closeTag.length - 1 &&
assistantMessage.startsWith(
closeTag,
currentCharIndex - closeTag.length + 1, // Start checking from potential start of tag
)
) {
// Found the closing tag for the parameter
const value = assistantMessage
.slice(
currentParamValueStart, // Start after the opening tag
currentCharIndex - closeTag.length + 1, // End before the closing tag
)
.trim()
currentToolUse.params[currentParamName] = value
currentParamName = undefined // Go back to parsing tool content
// We don't continue loop here, need to check for tool close or other params at index i
} else {
continue // Still inside param value, move to next char
}
}
// --- State: Parsing a Tool Use (but not a specific parameter) ---
if (currentToolUse && !currentParamName) {
// Ensure we are not inside a parameter already
// Check if starting a new parameter
let startedNewParam = false
for (const [tag, paramName] of toolParamOpenTags.entries()) {
if (currentCharIndex >= tag.length - 1 && assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)) {
currentParamName = paramName
currentParamValueStart = currentCharIndex + 1 // Value starts after the tag
startedNewParam = true
break
}
}
if (startedNewParam) {
continue // Handled start of param, move to next char
}
// Check if closing the current tool use
const toolCloseTag = `</${currentToolUse.name}>`
if (
currentCharIndex >= toolCloseTag.length - 1 &&
assistantMessage.startsWith(toolCloseTag, currentCharIndex - toolCloseTag.length + 1)
) {
// End of the tool use found
// Special handling for content params *before* finalizing the tool
const toolContentSlice = assistantMessage.slice(
currentToolUseStart, // From after the tool opening tag
currentCharIndex - toolCloseTag.length + 1, // To before the tool closing tag
)
// Check if content parameter needs special handling (write_to_file/new_rule)
// This check is important if the closing </content> tag was missed by the parameter parsing logic
// (e.g., if content is empty or parsing logic prioritizes tool close)
const contentParamName: ToolParamName = "content"
if (
currentToolUse.name === "write_to_file" /* || currentToolUse.name === "new_rule" */ &&
toolContentSlice.includes(`<${contentParamName}>`)
) {
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStart = toolContentSlice.indexOf(contentStartTag)
// Use lastIndexOf for robustness against nested tags
const contentEnd = toolContentSlice.lastIndexOf(contentEndTag)
if (contentStart !== -1 && contentEnd !== -1 && contentEnd > contentStart) {
const contentValue = toolContentSlice.slice(contentStart + contentStartTag.length, contentEnd).trim()
currentToolUse.params[contentParamName] = contentValue
}
}
currentToolUse.partial = false // Mark as complete
contentBlocks.push(currentToolUse)
currentToolUse = undefined // Reset state
currentTextContentStart = currentCharIndex + 1 // Potential text starts after this tag
continue // Move to next char
}
// If not starting a param and not closing the tool, continue accumulating tool content implicitly
continue
}
// --- State: Parsing Text / Looking for Tool Start ---
if (!currentToolUse) {
// Check if starting a new tool use
let startedNewTool = false
for (const [tag, toolName] of toolUseOpenTags.entries()) {
if (currentCharIndex >= tag.length - 1 && assistantMessage.startsWith(tag, currentCharIndex - tag.length + 1)) {
// End current text block if one was active
if (currentTextContent) {
currentTextContent.content = assistantMessage
.slice(
currentTextContentStart, // From where text started
currentCharIndex - tag.length + 1, // To before the tool tag starts
)
.trim()
currentTextContent.partial = false // Ended because tool started
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
currentTextContent = undefined
} else {
// Check for any text between the last block and this tag
const potentialText = assistantMessage
.slice(
currentTextContentStart, // From where text *might* have started
currentCharIndex - tag.length + 1, // To before the tool tag starts
)
.trim()
if (potentialText.length > 0) {
contentBlocks.push({
type: "text",
content: potentialText,
partial: false,
})
}
}
// Start the new tool use
currentToolUse = {
type: "tool_use",
name: toolName,
params: {},
partial: true, // Assume partial until closing tag is found
}
currentToolUseStart = currentCharIndex + 1 // Tool content starts after the opening tag
startedNewTool = true
break
}
}
if (startedNewTool) {
continue // Handled start of tool, move to next char
}
// If not starting a tool, it must be text content
if (!currentTextContent) {
// Start a new text block if we aren't already in one
currentTextContentStart = currentCharIndex // Text starts at the current character
// Check if the current char is the start of potential text *immediately* after a tag
// This needs the previous state - simpler to let slicing handle it later.
// Resetting start index accurately is key.
// It should be the index *after* the last processed tag.
// The logic managing currentTextContentStart after closing tags handles this.
currentTextContent = {
type: "text",
content: "", // Will be determined by slicing at the end or when a tool starts
partial: true,
}
}
// Continue accumulating text implicitly; content is extracted later.
}
} // End of loop
// --- Finalization after loop ---
// Finalize any open parameter within an open tool use
if (currentToolUse && currentParamName) {
currentToolUse.params[currentParamName] = assistantMessage
.slice(currentParamValueStart) // From param start to end of string
.trim()
// Tool use remains partial
}
// Finalize any open tool use (which might contain the finalized partial param)
if (currentToolUse) {
// Tool use is partial because the loop finished before its closing tag
contentBlocks.push(currentToolUse)
}
// Finalize any trailing text content
// Only possible if a tool use wasn't open at the very end
else if (currentTextContent) {
currentTextContent.content = assistantMessage
.slice(currentTextContentStart) // From text start to end of string
.trim()
// Text is partial because the loop finished
if (currentTextContent.content.length > 0) {
contentBlocks.push(currentTextContent)
}
}
return contentBlocks
@@ -2,6 +2,7 @@ import * as vscode from "vscode"
import crypto from "crypto"
import { Controller } from "../index"
import { storeSecret } from "../../storage/state"
import { EmptyRequest, String } from "../../../shared/proto/common"
/**
* Handles the user clicking the login link in the UI.
@@ -11,7 +12,7 @@ import { storeSecret } from "../../storage/state"
* @param controller The controller instance.
* @returns The login URL as a string.
*/
export async function accountLoginClicked(controller: Controller): Promise<String> {
export async function accountLoginClicked(controller: Controller, unused: EmptyRequest): Promise<String> {
// Generate nonce for state validation
const nonce = crypto.randomBytes(32).toString("hex")
await storeSecret(controller.context, "authNonce", nonce)
@@ -25,6 +26,8 @@ export async function accountLoginClicked(controller: Controller): Promise<Strin
const authUrl = vscode.Uri.parse(
`https://app.cline.bot/auth?state=${encodeURIComponent(nonce)}&callback_url=${encodeURIComponent(`${uriScheme || "vscode"}://saoudrizwan.claude-dev/auth`)}`,
)
vscode.env.openExternal(authUrl)
return authUrl.toString()
await vscode.env.openExternal(authUrl)
return {
value: authUrl.toString(),
}
}
@@ -1,8 +1,8 @@
import { UpdateBrowserSettingsRequest } from "../../../shared/proto/browser"
import { Boolean } from "../../../shared/proto/common"
import { Controller } from "../index"
import { updateGlobalState } from "../../storage/state"
import { BrowserSettings as SharedBrowserSettings } from "../../../shared/BrowserSettings"
import { updateGlobalState, getGlobalState } from "../../storage/state"
import { BrowserSettings as SharedBrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../../shared/BrowserSettings"
/**
* Update browser settings
@@ -12,23 +12,39 @@ import { BrowserSettings as SharedBrowserSettings } from "../../../shared/Browse
*/
export async function updateBrowserSettings(controller: Controller, request: UpdateBrowserSettingsRequest): Promise<Boolean> {
try {
// Convert from protobuf format to shared format
const browserSettings: SharedBrowserSettings = {
// Get current browser settings to preserve fields not in the request
const currentSettings = (await getGlobalState(controller.context, "browserSettings")) as SharedBrowserSettings | undefined
const mergedWithDefaults = { ...DEFAULT_BROWSER_SETTINGS, ...currentSettings }
// Convert from protobuf format to shared format, merging with existing settings
const newBrowserSettings: SharedBrowserSettings = {
...mergedWithDefaults, // Start with existing settings (and defaults)
viewport: {
width: request.viewport?.width || 900,
height: request.viewport?.height || 600,
// Apply updates from request
width: request.viewport?.width || mergedWithDefaults.viewport.width,
height: request.viewport?.height || mergedWithDefaults.viewport.height,
},
remoteBrowserEnabled: request.remoteBrowserEnabled || false,
remoteBrowserHost: request.remoteBrowserHost || undefined,
// Explicitly handle optional boolean and string fields from the request
remoteBrowserEnabled:
request.remoteBrowserEnabled === undefined
? mergedWithDefaults.remoteBrowserEnabled
: request.remoteBrowserEnabled,
remoteBrowserHost:
request.remoteBrowserHost === undefined ? mergedWithDefaults.remoteBrowserHost : request.remoteBrowserHost,
chromeExecutablePath:
// If chromeExecutablePath is explicitly in the request (even as ""), use it.
// Otherwise, fall back to mergedWithDefaults.
"chromeExecutablePath" in request ? request.chromeExecutablePath : mergedWithDefaults.chromeExecutablePath,
disableToolUse: request.disableToolUse === undefined ? mergedWithDefaults.disableToolUse : request.disableToolUse,
}
// Update global state with new settings
await updateGlobalState(controller.context, "browserSettings", browserSettings)
await updateGlobalState(controller.context, "browserSettings", newBrowserSettings)
// Update task browser settings if task exists
if (controller.task) {
controller.task.browserSettings = browserSettings
controller.task.browserSession.browserSettings = browserSettings
controller.task.browserSettings = newBrowserSettings
controller.task.browserSession.browserSettings = newBrowserSettings
}
// Post updated state to webview
@@ -12,6 +12,7 @@ import { handleStateServiceRequest, handleStateServiceStreamingRequest } from ".
import { handleTaskServiceRequest, handleTaskServiceStreamingRequest } from "./task/index"
import { handleWebServiceRequest, handleWebServiceStreamingRequest } from "./web/index"
import { handleModelsServiceRequest, handleModelsServiceStreamingRequest } from "./models/index"
import { handleSlashServiceRequest, handleSlashServiceStreamingRequest } from "./slash/index"
/**
* Configuration for a service handler
@@ -67,4 +68,8 @@ export const serviceHandlers: Record<string, ServiceHandlerConfig> = {
requestHandler: handleModelsServiceRequest,
streamingHandler: handleModelsServiceStreamingRequest,
},
"cline.SlashService": {
requestHandler: handleSlashServiceRequest,
streamingHandler: handleSlashServiceStreamingRequest,
},
}
+82 -374
View File
@@ -1,6 +1,5 @@
import { Anthropic } from "@anthropic-ai/sdk"
import axios from "axios"
import type { AxiosRequestConfig } from "axios"
import fs from "fs/promises"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
@@ -8,6 +7,8 @@ import pWaitFor from "p-wait-for"
import * as path from "path"
import * as vscode from "vscode"
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
import { handleModelsServiceRequest } from "./models"
import { EmptyRequest } from "@shared/proto/common"
import { buildApiHandler } from "@api/index"
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
import { downloadTask } from "@integrations/misc/export-markdown"
@@ -19,7 +20,6 @@ import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
import { ClineAccountService } from "@services/account/ClineAccountService"
import { BrowserSession } from "@services/browser/BrowserSession"
import { McpHub } from "@services/mcp/McpHub"
import { searchWorkspaceFiles } from "@services/search/file-search"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { ApiProvider, ModelInfo } from "@shared/api"
import { ChatContent } from "@shared/ChatContent"
@@ -28,11 +28,10 @@ import { ExtensionMessage, ExtensionState, Invoke, Platform } from "@shared/Exte
import { HistoryItem } from "@shared/HistoryItem"
import { McpDownloadResponse, McpMarketplaceCatalog, McpServer } from "@shared/mcp"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { ClineCheckpointRestore, WebviewMessage } from "@shared/WebviewMessage"
import { WebviewMessage } from "@shared/WebviewMessage"
import { fileExistsAtPath } from "@utils/fs"
import { searchCommits, getWorkingState } from "@utils/git"
import { getWorkingState } from "@utils/git"
import { extractCommitMessage } from "@integrations/git/commit-message-generator"
import { getWorkspacePath } from "@utils/path"
import { getTotalTasksSize } from "@utils/storage"
import { openMention } from "../mentions"
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
@@ -67,7 +66,7 @@ export class Controller {
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
accountService: ClineAccountService
private latestAnnouncementId = "may-02-2025_16:27:00" // update to some unique identifier when we add a new announcement
private latestAnnouncementId = "may-09-2025_17:11:00" // update to some unique identifier when we add a new announcement
constructor(
readonly context: vscode.ExtensionContext,
@@ -146,6 +145,7 @@ export class Controller {
browserSettings,
chatSettings,
shellIntegrationTimeout,
enableCheckpointsSetting,
} = await getAllExtensionState(this.context)
if (autoApprovalSettings) {
@@ -169,6 +169,7 @@ export class Controller {
browserSettings,
chatSettings,
shellIntegrationTimeout,
enableCheckpointsSetting ?? true,
customInstructions,
task,
images,
@@ -232,15 +233,15 @@ export class Controller {
}
})
this.silentlyRefreshMcpMarketplace()
this.refreshOpenRouterModels().then(async (openRouterModels) => {
if (openRouterModels) {
handleModelsServiceRequest(this, "refreshOpenRouterModels", EmptyRequest.create()).then(async (response) => {
if (response && response.models) {
// update model info in state (this needs to be done here since we don't want to update state while settings is open, and we may refresh models there)
const { apiConfiguration } = await getAllExtensionState(this.context)
if (apiConfiguration.openRouterModelId) {
if (apiConfiguration.openRouterModelId && response.models[apiConfiguration.openRouterModelId]) {
await updateGlobalState(
this.context,
"openRouterModelInfo",
openRouterModels[apiConfiguration.openRouterModelId],
response.models[apiConfiguration.openRouterModelId],
)
await this.postStateToWebview()
}
@@ -250,7 +251,7 @@ export class Controller {
// If user already opted in to telemetry, enable telemetry service
this.getStateToPostToWebview().then((state) => {
const { telemetrySetting } = state
const isOptedIn = telemetrySetting === "enabled"
const isOptedIn = telemetrySetting !== "disabled"
telemetryService.updateTelemetryState(isOptedIn)
})
break
@@ -272,9 +273,6 @@ export class Controller {
// initializing new instance of Cline will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task
await this.initTask(message.text, message.images)
break
case "condense":
this.task?.handleWebviewAskResponse("yesButtonClicked")
break
case "apiConfiguration":
if (message.apiConfiguration) {
await updateApiConfiguration(this.context, message.apiConfiguration)
@@ -332,21 +330,6 @@ export class Controller {
case "resetState":
await this.resetState()
break
case "requestVsCodeLmModels":
const vsCodeLmModels = await this.getVsCodeLmModels()
this.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
break
case "refreshOpenRouterModels":
await this.refreshOpenRouterModels()
break
case "refreshRequestyModels":
await this.refreshRequestyModels()
break
case "refreshOpenAiModels":
const { apiConfiguration } = await getAllExtensionState(this.context)
const openAiModels = await this.getOpenAiModels(apiConfiguration.openAiBaseUrl, apiConfiguration.openAiApiKey)
this.postMessageToWebview({ type: "openAiModels", openAiModels })
break
case "refreshClineRules":
await refreshClineRulesToggles(this.context, cwd)
await refreshExternalRulesToggles(this.context, cwd)
@@ -392,19 +375,6 @@ export class Controller {
await this.fetchMcpMarketplace(message.bool)
break
}
case "downloadMcp": {
if (message.mcpId) {
// 1. Toggle to act mode if we are in plan mode
const { chatSettings } = await this.getStateToPostToWebview()
if (chatSettings.mode === "plan") {
await this.togglePlanActModeWithChatSettings({ mode: "act" })
}
// 2. download MCP
await this.downloadMcp(message.mcpId)
}
break
}
case "silentlyRefreshMcpMarketplace": {
await this.silentlyRefreshMcpMarketplace()
break
@@ -595,6 +565,22 @@ export class Controller {
// plan act setting
await updateGlobalState(this.context, "planActSeparateModelsSetting", message.planActSeparateModelsSetting)
if (typeof message.enableCheckpointsSetting === "boolean") {
await updateGlobalState(this.context, "enableCheckpointsSetting", message.enableCheckpointsSetting)
}
if (typeof message.mcpMarketplaceEnabled === "boolean") {
await updateGlobalState(this.context, "mcpMarketplaceEnabled", message.mcpMarketplaceEnabled)
}
// chat settings (including preferredLanguage and openAIReasoningEffort)
if (message.chatSettings) {
await updateGlobalState(this.context, "chatSettings", message.chatSettings)
if (this.task) {
this.task.chatSettings = message.chatSettings
}
}
// after settings are updated, post state to webview
await this.postStateToWebview()
@@ -603,11 +589,18 @@ export class Controller {
}
case "clearAllTaskHistory": {
const answer = await vscode.window.showWarningMessage(
"Are you sure you want to delete all history?",
"Delete",
"What would you like to delete?",
{ modal: true },
"Delete All Except Favorites",
"Delete Everything",
"Cancel",
)
if (answer === "Delete") {
if (answer === "Delete All Except Favorites") {
await this.deleteNonFavoriteTaskHistory()
await this.postStateToWebview()
this.refreshTotalTasksSize()
} else if (answer === "Delete Everything") {
await this.deleteAllTaskHistory()
await this.postStateToWebview()
this.refreshTotalTasksSize()
@@ -615,27 +608,6 @@ export class Controller {
this.postMessageToWebview({ type: "relinquishControl" })
break
}
case "toggleFavoriteModel": {
if (message.modelId) {
const { apiConfiguration } = await getAllExtensionState(this.context)
const favoritedModelIds = apiConfiguration.favoritedModelIds || []
// Toggle favorite status
const updatedFavorites = favoritedModelIds.includes(message.modelId)
? favoritedModelIds.filter((id) => id !== message.modelId)
: [...favoritedModelIds, message.modelId]
await updateGlobalState(this.context, "favoritedModelIds", updatedFavorites)
// Capture telemetry for model favorite toggle
const isFavorited = !favoritedModelIds.includes(message.modelId)
telemetryService.captureModelFavoritesUsage(message.modelId, isFavorited)
// Post state to webview without changing any other configuration
await this.postStateToWebview()
}
break
}
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this, message.grpc_request)
@@ -679,7 +651,7 @@ export class Controller {
async updateTelemetrySetting(telemetrySetting: TelemetrySetting) {
await updateGlobalState(this.context, "telemetrySetting", telemetrySetting)
const isOptedIn = telemetrySetting === "enabled"
const isOptedIn = telemetrySetting !== "disabled"
telemetryService.updateTelemetryState(isOptedIn)
}
@@ -759,6 +731,7 @@ export class Controller {
break
case "litellm":
await updateGlobalState(this.context, "previousModeModelId", apiConfiguration.liteLlmModelId)
await updateGlobalState(this.context, "previousModeModelInfo", apiConfiguration.liteLlmModelInfo)
break
case "requesty":
await updateGlobalState(this.context, "previousModeModelId", apiConfiguration.requestyModelId)
@@ -812,7 +785,8 @@ export class Controller {
await updateGlobalState(this.context, "lmStudioModelId", newModelId)
break
case "litellm":
await updateGlobalState(this.context, "liteLlmModelId", newModelId)
await updateGlobalState(this.context, "previousModeModelId", apiConfiguration.liteLlmModelId)
await updateGlobalState(this.context, "previousModeModelInfo", apiConfiguration.liteLlmModelInfo)
break
case "requesty":
await updateGlobalState(this.context, "requestyModelId", newModelId)
@@ -884,18 +858,6 @@ export class Controller {
}
}
// VSCode LM API
private async getVsCodeLmModels() {
try {
const models = await vscode.lm.selectChatModels({})
return models || []
} catch (error) {
console.error("Error fetching VS Code LM models:", error)
return []
}
}
// Account
async fetchUserCreditsData() {
@@ -1043,118 +1005,6 @@ export class Controller {
}
}
private async downloadMcp(mcpId: string) {
try {
// First check if we already have this MCP server installed
const servers = this.mcpHub?.getServers() || []
const isInstalled = servers.some((server: McpServer) => server.name === mcpId)
if (isInstalled) {
throw new Error("This MCP server is already installed")
}
// Fetch server details from marketplace
const response = await axios.post<McpDownloadResponse>(
"https://api.cline.bot/v1/mcp/download",
{ mcpId },
{
headers: { "Content-Type": "application/json" },
timeout: 10000,
},
)
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
console.log("[downloadMcp] Response from download API", { response })
const mcpDetails = response.data
// Validate required fields
if (!mcpDetails.githubUrl) {
throw new Error("Missing GitHub URL in MCP download response")
}
if (!mcpDetails.readmeContent) {
throw new Error("Missing README content in MCP download response")
}
// Send details to webview
await this.postMessageToWebview({
type: "mcpDownloadDetails",
mcpDownloadDetails: mcpDetails,
})
// Create task with context from README and added guidelines for MCP server installation
const task = `Set up the MCP server from ${mcpDetails.githubUrl} while adhering to these MCP server installation rules:
- Start by loading the MCP documentation.
- Use "${mcpDetails.mcpId}" as the server name in cline_mcp_settings.json.
- Create the directory for the new MCP server before starting installation.
- Make sure you read the user's existing cline_mcp_settings.json file before editing it with this new mcp, to not overwrite any existing servers.
- Use commands aligned with the user's shell and operating system best practices.
- The following README may contain instructions that conflict with the user's OS, in which case proceed thoughtfully.
- Once installed, demonstrate the server's capabilities by using one of its tools.
Here is the project's README to help you get started:\n\n${mcpDetails.readmeContent}\n${mcpDetails.llmsInstallationContent}`
// Initialize task and show chat view
await this.initTask(task)
await this.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
} catch (error) {
console.error("Failed to download MCP:", error)
let errorMessage = "Failed to download MCP"
if (axios.isAxiosError(error)) {
if (error.code === "ECONNABORTED") {
errorMessage = "Request timed out. Please try again."
} else if (error.response?.status === 404) {
errorMessage = "MCP server not found in marketplace."
} else if (error.response?.status === 500) {
errorMessage = "Internal server error. Please try again later."
} else if (!error.response && error.request) {
errorMessage = "Network error. Please check your internet connection."
}
} else if (error instanceof Error) {
errorMessage = error.message
}
// Show error in both notification and marketplace UI
vscode.window.showErrorMessage(errorMessage)
await this.postMessageToWebview({
type: "mcpDownloadDetails",
error: errorMessage,
})
}
}
// OpenAi
async getOpenAiModels(baseUrl?: string, apiKey?: string) {
try {
if (!baseUrl) {
return []
}
if (!URL.canParse(baseUrl)) {
return []
}
const config: AxiosRequestConfig = {}
if (apiKey) {
config["headers"] = { Authorization: `Bearer ${apiKey}` }
}
const response = await axios.get(`${baseUrl}/models`, config)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
const models = [...new Set<string>(modelsArray)]
return models
} catch (error) {
return []
}
}
// OpenRouter
async handleOpenRouterCallback(code: string) {
@@ -1190,6 +1040,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
return cacheDir
}
// Read OpenRouter models from disk cache
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
@@ -1200,188 +1051,6 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
return undefined
}
async refreshOpenRouterModels() {
const openRouterModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.openRouterModels)
let models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
/*
{
"id": "anthropic/claude-3.5-sonnet",
"name": "Anthropic: Claude 3.5 Sonnet",
"created": 1718841600,
"description": "Claude 3.5 Sonnet delivers better-than-Opus capabilities, faster-than-Sonnet speeds, at the same Sonnet prices. Sonnet is particularly good at:\n\n- Coding: Autonomously writes, edits, and runs code with reasoning and troubleshooting\n- Data science: Augments human data science expertise; navigates unstructured data while using multiple tools for insights\n- Visual processing: excelling at interpreting charts, graphs, and images, accurately transcribing text to derive insights beyond just the text alone\n- Agentic tasks: exceptional tool use, making it great at agentic tasks (i.e. complex, multi-step problem solving tasks that require engaging with other systems)\n\n#multimodal",
"context_length": 200000,
"architecture": {
"modality": "text+image-\u003Etext",
"tokenizer": "Claude",
"instruct_type": null
},
"pricing": {
"prompt": "0.000003",
"completion": "0.000015",
"image": "0.0048",
"request": "0"
},
"top_provider": {
"context_length": 200000,
"max_completion_tokens": 8192,
"is_moderated": true
},
"per_request_limits": null
},
*/
if (response.data?.data) {
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
for (const rawModel of rawModels) {
const modelInfo: ModelInfo = {
maxTokens: rawModel.top_provider?.max_completion_tokens,
contextWindow: rawModel.context_length,
supportsImages: rawModel.architecture?.modality?.includes("image"),
supportsPromptCache: false,
inputPrice: parsePrice(rawModel.pricing?.prompt),
outputPrice: parsePrice(rawModel.pricing?.completion),
description: rawModel.description,
}
switch (rawModel.id) {
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
// NOTE: this needs to be synced with api.ts/openrouter default model info
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
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.5-haiku":
case "anthropic/claude-3.5-haiku:beta":
case "anthropic/claude-3.5-haiku-20241022":
case "anthropic/claude-3.5-haiku-20241022:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 1.25
modelInfo.cacheReadsPrice = 0.1
break
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 18.75
modelInfo.cacheReadsPrice = 1.5
break
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 0.3
modelInfo.cacheReadsPrice = 0.03
break
case "deepseek/deepseek-chat":
modelInfo.supportsPromptCache = true
// see api.ts/deepSeekModels for more info
modelInfo.inputPrice = 0
modelInfo.cacheWritesPrice = 0.14
modelInfo.cacheReadsPrice = 0.014
break
default:
if (rawModel.id.startsWith("openai/")) {
modelInfo.cacheReadsPrice = parsePrice(rawModel.pricing?.input_cache_read)
if (modelInfo.cacheReadsPrice) {
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = parsePrice(rawModel.pricing?.input_cache_write)
// openrouter charges no cache write pricing for openAI models
}
} else if (rawModel.id.startsWith("google/")) {
modelInfo.cacheReadsPrice = parsePrice(rawModel.pricing?.input_cache_read)
if (modelInfo.cacheReadsPrice) {
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = parsePrice(rawModel.pricing?.input_cache_write)
}
}
break
}
models[rawModel.id] = modelInfo
}
} else {
console.error("Invalid response from OpenRouter API")
}
await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
console.log("OpenRouter models fetched and saved", models)
} catch (error) {
console.error("Error fetching OpenRouter models:", error)
}
await this.postMessageToWebview({
type: "openRouterModels",
openRouterModels: models,
})
return models
}
async refreshRequestyModels() {
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
let models: Record<string, ModelInfo> = {}
try {
const apiKey = await getSecret(this.context, "requestyApiKey")
const headers = {
Authorization: `Bearer ${apiKey}`,
}
const response = await axios.get("https://router.requesty.ai/v1/models", { headers })
if (response.data?.data) {
for (const model of response.data.data) {
const modelInfo: ModelInfo = {
maxTokens: model.max_output_tokens || undefined,
contextWindow: model.context_window,
supportsImages: model.supports_vision || undefined,
supportsPromptCache: model.supports_caching || undefined,
inputPrice: parsePrice(model.input_price),
outputPrice: parsePrice(model.output_price),
cacheWritesPrice: parsePrice(model.caching_price),
cacheReadsPrice: parsePrice(model.cached_price),
description: model.description,
}
models[model.id] = modelInfo
}
console.log("Requesty models fetched", models)
} else {
console.error("Invalid response from Requesty API")
}
} catch (error) {
console.error("Error fetching Requesty models:", error)
}
await this.postMessageToWebview({
type: "requestyModels",
requestyModels: models,
})
return models
}
// Context menus and code actions
getFileMentionFromPath(filePath: string) {
@@ -1556,6 +1225,43 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
// await this.postStateToWebview()
}
async deleteNonFavoriteTaskHistory() {
await this.clearTask()
const taskHistory = ((await getGlobalState(this.context, "taskHistory")) as HistoryItem[]) || []
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
// If user has no favorited tasks, show a warning message
if (favoritedTasks.length === 0) {
vscode.window.showWarningMessage("No favorited tasks found. Please favorite tasks before using this option.")
await this.postStateToWebview()
return
}
await updateGlobalState(this.context, "taskHistory", favoritedTasks)
// Delete non-favorited task directories
try {
const preserveTaskIds = favoritedTasks.map((task) => task.id)
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks")
if (await fileExistsAtPath(taskDirPath)) {
const taskDirs = await fs.readdir(taskDirPath)
for (const taskDir of taskDirs) {
if (!preserveTaskIds.includes(taskDir)) {
await fs.rm(path.join(taskDirPath, taskDir), { recursive: true, force: true })
}
}
}
} catch (error) {
vscode.window.showErrorMessage(
`Error deleting task history: ${error instanceof Error ? error.message : String(error)}`,
)
}
await this.postStateToWebview()
}
async refreshTotalTasksSize() {
getTotalTasksSize(this.context.globalStorageUri.fsPath)
.then((newTotalSize) => {
@@ -1644,6 +1350,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
mcpMarketplaceEnabled,
telemetrySetting,
planActSeparateModelsSetting,
enableCheckpointsSetting,
globalClineRulesToggles,
shellIntegrationTimeout,
} = await getAllExtensionState(this.context)
@@ -1678,6 +1385,7 @@ Here is the project's README to help you get started:\n\n${mcpDetails.readmeCont
mcpMarketplaceEnabled,
telemetrySetting,
planActSeparateModelsSetting,
enableCheckpointsSetting: enableCheckpointsSetting ?? true,
vscMachineId: vscode.env.machineId,
globalClineRulesToggles: globalClineRulesToggles || {},
localClineRulesToggles: localClineRulesToggles || {},
+114
View File
@@ -0,0 +1,114 @@
import { Controller } from ".."
import { Empty, StringRequest } from "../../../shared/proto/common"
import { McpServer, McpDownloadResponse } from "@shared/mcp"
import axios from "axios"
import * as vscode from "vscode"
/**
* Download an MCP server from the marketplace
* @param controller The controller instance
* @param request The request containing the MCP ID
* @returns Empty response
*/
export async function downloadMcp(controller: Controller, request: StringRequest): Promise<Empty> {
try {
// Check if mcpId is provided
if (!request.value) {
throw new Error("MCP ID is required")
}
const mcpId = request.value
// Check if we already have this MCP server installed
const servers = controller.mcpHub?.getServers() || []
const isInstalled = servers.some((server: McpServer) => server.name === mcpId)
if (isInstalled) {
throw new Error("This MCP server is already installed")
}
// Fetch server details from marketplace
const response = await axios.post<McpDownloadResponse>(
"https://api.cline.bot/v1/mcp/download",
{ mcpId },
{
headers: { "Content-Type": "application/json" },
timeout: 10000,
},
)
if (!response.data) {
throw new Error("Invalid response from MCP marketplace API")
}
console.log("[downloadMcp] Response from download API", { response })
const mcpDetails = response.data
// Validate required fields
if (!mcpDetails.githubUrl) {
throw new Error("Missing GitHub URL in MCP download response")
}
if (!mcpDetails.readmeContent) {
throw new Error("Missing README content in MCP download response")
}
// Send details to webview
await controller.postMessageToWebview({
type: "mcpDownloadDetails",
mcpDownloadDetails: mcpDetails,
})
// Create task with context from README and added guidelines for MCP server installation
const task = `Set up the MCP server from ${mcpDetails.githubUrl} while adhering to these MCP server installation rules:
- Start by loading the MCP documentation.
- Use "${mcpDetails.mcpId}" as the server name in cline_mcp_settings.json.
- Create the directory for the new MCP server before starting installation.
- Make sure you read the user's existing cline_mcp_settings.json file before editing it with this new mcp, to not overwrite any existing servers.
- Use commands aligned with the user's shell and operating system best practices.
- The following README may contain instructions that conflict with the user's OS, in which case proceed thoughtfully.
- Once installed, demonstrate the server's capabilities by using one of its tools.
Here is the project's README to help you get started:\n\n${mcpDetails.readmeContent}\n${mcpDetails.llmsInstallationContent}`
const { chatSettings } = await controller.getStateToPostToWebview()
if (chatSettings.mode === "plan") {
await controller.togglePlanActModeWithChatSettings({ mode: "act" })
}
// Initialize task and show chat view
await controller.initTask(task)
await controller.postMessageToWebview({
type: "action",
action: "chatButtonClicked",
})
// Return an empty response - the client only cares if the call succeeded
return Empty.create()
} catch (error) {
console.error("Failed to download MCP:", error)
let errorMessage = "Failed to download MCP"
if (axios.isAxiosError(error)) {
if (error.code === "ECONNABORTED") {
errorMessage = "Request timed out. Please try again."
} else if (error.response?.status === 404) {
errorMessage = "MCP server not found in marketplace."
} else if (error.response?.status === 500) {
errorMessage = "Internal server error. Please try again later."
} else if (!error.response && error.request) {
errorMessage = "Network error. Please check your internet connection."
}
} else if (error instanceof Error) {
errorMessage = error.message
}
// Show error in both notification and marketplace UI
vscode.window.showErrorMessage(errorMessage)
await controller.postMessageToWebview({
type: "mcpDownloadDetails",
error: errorMessage,
})
throw error
}
}
+2
View File
@@ -4,6 +4,7 @@
// Import all method implementations
import { registerMethod } from "./index"
import { addRemoteMcpServer } from "./addRemoteMcpServer"
import { downloadMcp } from "./downloadMcp"
import { toggleMcpServer } from "./toggleMcpServer"
import { updateMcpTimeout } from "./updateMcpTimeout"
@@ -11,6 +12,7 @@ import { updateMcpTimeout } from "./updateMcpTimeout"
export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("addRemoteMcpServer", addRemoteMcpServer)
registerMethod("downloadMcp", downloadMcp)
registerMethod("toggleMcpServer", toggleMcpServer)
registerMethod("updateMcpTimeout", updateMcpTimeout)
}
@@ -0,0 +1,24 @@
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { VsCodeLmModelsArray } from "../../../shared/proto/models"
import * as vscode from "vscode"
import { convertVsCodeNativeModelsToProtoModels } from "../../../shared/proto-conversions/models/vscode-lm-models-conversion"
/**
* Fetches available models from VS Code LM API
* @param controller The controller instance
* @param request Empty request
* @returns Array of VS Code LM models
*/
export async function getVsCodeLmModels(controller: Controller, request: EmptyRequest): Promise<VsCodeLmModelsArray> {
try {
const models = await vscode.lm.selectChatModels({})
const protoModels = convertVsCodeNativeModelsToProtoModels(models || [])
return VsCodeLmModelsArray.create({ models: protoModels })
} catch (error) {
console.error("Error fetching VS Code LM models:", error)
return VsCodeLmModelsArray.create({ models: [] })
}
}
+8
View File
@@ -5,10 +5,18 @@
import { registerMethod } from "./index"
import { getLmStudioModels } from "./getLmStudioModels"
import { getOllamaModels } from "./getOllamaModels"
import { getVsCodeLmModels } from "./getVsCodeLmModels"
import { refreshOpenAiModels } from "./refreshOpenAiModels"
import { refreshOpenRouterModels } from "./refreshOpenRouterModels"
import { refreshRequestyModels } from "./refreshRequestyModels"
// Register all models service methods
export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("getLmStudioModels", getLmStudioModels)
registerMethod("getOllamaModels", getOllamaModels)
registerMethod("getVsCodeLmModels", getVsCodeLmModels)
registerMethod("refreshOpenAiModels", refreshOpenAiModels)
registerMethod("refreshOpenRouterModels", refreshOpenRouterModels)
registerMethod("refreshRequestyModels", refreshRequestyModels)
}
@@ -0,0 +1,37 @@
import { Controller } from ".."
import { OpenAiModelsRequest } from "../../../shared/proto/models"
import { StringArray } from "../../../shared/proto/common"
import axios from "axios"
import type { AxiosRequestConfig } from "axios"
/**
* Fetches available models from the OpenAI API
* @param controller The controller instance
* @param request Request containing the base URL and API key
* @returns Array of model names
*/
export async function refreshOpenAiModels(controller: Controller, request: OpenAiModelsRequest): Promise<StringArray> {
try {
if (!request.baseUrl) {
return StringArray.create({ values: [] })
}
if (!URL.canParse(request.baseUrl)) {
return StringArray.create({ values: [] })
}
const config: AxiosRequestConfig = {}
if (request.apiKey) {
config["headers"] = { Authorization: `Bearer ${request.apiKey}` }
}
const response = await axios.get(`${request.baseUrl}/models`, config)
const modelsArray = response.data?.data?.map((model: any) => model.id) || []
const models = [...new Set<string>(modelsArray)]
return StringArray.create({ values: models })
} catch (error) {
console.error("Error fetching OpenAI models:", error)
return StringArray.create({ values: [] })
}
}
@@ -0,0 +1,175 @@
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "../../../shared/proto/models"
import axios from "axios"
import path from "path"
import fs from "fs/promises"
import { fileExistsAtPath } from "@utils/fs"
import { GlobalFileNames } from "@core/storage/disk"
/**
* Refreshes the OpenRouter models and returns the updated model list
* @param controller The controller instance
* @param request Empty request object
* @returns Response containing the OpenRouter models
*/
export async function refreshOpenRouterModels(
controller: Controller,
request: EmptyRequest,
): Promise<OpenRouterCompatibleModelInfo> {
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.openRouterModels)
let models: Record<string, Partial<OpenRouterModelInfo>> = {}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models")
if (response.data?.data) {
const rawModels = response.data.data
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
for (const rawModel of rawModels) {
const modelInfo: Partial<OpenRouterModelInfo> = {
maxTokens: rawModel.top_provider?.max_completion_tokens,
contextWindow: rawModel.context_length,
supportsImages: rawModel.architecture?.modality?.includes("image"),
supportsPromptCache: false,
inputPrice: parsePrice(rawModel.pricing?.prompt),
outputPrice: parsePrice(rawModel.pricing?.completion),
description: rawModel.description,
}
switch (rawModel.id) {
case "anthropic/claude-3-7-sonnet":
case "anthropic/claude-3-7-sonnet:beta":
case "anthropic/claude-3.7-sonnet":
case "anthropic/claude-3.7-sonnet:beta":
case "anthropic/claude-3.7-sonnet:thinking":
case "anthropic/claude-3.5-sonnet":
case "anthropic/claude-3.5-sonnet:beta":
// NOTE: this needs to be synced with api.ts/openrouter default model info
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
case "anthropic/claude-3.5-sonnet-20240620":
case "anthropic/claude-3.5-sonnet-20240620:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 3.75
modelInfo.cacheReadsPrice = 0.3
break
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.5-haiku":
case "anthropic/claude-3.5-haiku:beta":
case "anthropic/claude-3.5-haiku-20241022":
case "anthropic/claude-3.5-haiku-20241022:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 1.25
modelInfo.cacheReadsPrice = 0.1
break
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 18.75
modelInfo.cacheReadsPrice = 1.5
break
case "anthropic/claude-3-haiku":
case "anthropic/claude-3-haiku:beta":
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = 0.3
modelInfo.cacheReadsPrice = 0.03
break
case "deepseek/deepseek-chat":
modelInfo.supportsPromptCache = true
// see api.ts/deepSeekModels for more info
modelInfo.inputPrice = 0
modelInfo.cacheWritesPrice = 0.14
modelInfo.cacheReadsPrice = 0.014
break
default:
if (rawModel.id.startsWith("openai/")) {
modelInfo.cacheReadsPrice = parsePrice(rawModel.pricing?.input_cache_read)
if (modelInfo.cacheReadsPrice) {
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = parsePrice(rawModel.pricing?.input_cache_write)
// openrouter charges no cache write pricing for openAI models
}
} else if (rawModel.id.startsWith("google/")) {
modelInfo.cacheReadsPrice = parsePrice(rawModel.pricing?.input_cache_read)
if (modelInfo.cacheReadsPrice) {
modelInfo.supportsPromptCache = true
modelInfo.cacheWritesPrice = parsePrice(rawModel.pricing?.input_cache_write)
}
}
break
}
models[rawModel.id] = modelInfo
}
} else {
console.error("Invalid response from OpenRouter API")
}
await fs.writeFile(openRouterModelsFilePath, JSON.stringify(models))
console.log("OpenRouter models fetched and saved", models)
} catch (error) {
console.error("Error fetching OpenRouter models:", error)
// If we failed to fetch models, try to read cached models
const cachedModels = await readOpenRouterModels(controller)
if (cachedModels) {
models = cachedModels
}
}
// Convert the Record<string, Partial<OpenRouterModelInfo>> to Record<string, OpenRouterModelInfo>
// by filling in any missing required fields with defaults
const typedModels: Record<string, OpenRouterModelInfo> = {}
for (const [key, model] of Object.entries(models)) {
typedModels[key] = {
maxTokens: model.maxTokens ?? 0,
contextWindow: model.contextWindow ?? 0,
supportsImages: model.supportsImages ?? false,
supportsPromptCache: model.supportsPromptCache ?? false,
inputPrice: model.inputPrice ?? 0,
outputPrice: model.outputPrice ?? 0,
cacheWritesPrice: model.cacheWritesPrice ?? 0,
cacheReadsPrice: model.cacheReadsPrice ?? 0,
description: model.description ?? "",
}
}
return OpenRouterCompatibleModelInfo.create({ models: typedModels })
}
/**
* Reads cached OpenRouter models from disk
*/
async function readOpenRouterModels(controller: Controller): Promise<Record<string, Partial<OpenRouterModelInfo>> | undefined> {
const openRouterModelsFilePath = path.join(await ensureCacheDirectoryExists(controller), GlobalFileNames.openRouterModels)
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
if (fileExists) {
try {
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
return JSON.parse(fileContents)
} catch (error) {
console.error("Error reading cached OpenRouter models:", error)
return undefined
}
}
return undefined
}
/**
* Ensures the cache directory exists and returns its path
*/
async function ensureCacheDirectoryExists(controller: Controller): Promise<string> {
const cacheDir = path.join(controller.context.globalStorageUri.fsPath, "cache")
await fs.mkdir(cacheDir, { recursive: true })
return cacheDir
}
@@ -0,0 +1,55 @@
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { OpenRouterCompatibleModelInfo, OpenRouterModelInfo } from "../../../shared/proto/models"
import axios from "axios"
import { getSecret } from "@core/storage/state"
/**
* Refreshes the Requesty models and returns the updated model list
* @param controller The controller instance
* @param request Empty request object
* @returns Response containing the Requesty models
*/
export async function refreshRequestyModels(
controller: Controller,
request: EmptyRequest,
): Promise<OpenRouterCompatibleModelInfo> {
const parsePrice = (price: any) => {
if (price) {
return parseFloat(price) * 1_000_000
}
return undefined
}
let models: Record<string, OpenRouterModelInfo> = {}
try {
const apiKey = await getSecret(controller.context, "requestyApiKey")
const headers = {
Authorization: `Bearer ${apiKey}`,
}
const response = await axios.get("https://router.requesty.ai/v1/models", { headers })
if (response.data?.data) {
for (const model of response.data.data) {
const modelInfo: OpenRouterModelInfo = {
maxTokens: model.max_output_tokens || undefined,
contextWindow: model.context_window,
supportsImages: model.supports_vision || undefined,
supportsPromptCache: model.supports_caching || undefined,
inputPrice: parsePrice(model.input_price) || 0,
outputPrice: parsePrice(model.output_price) || 0,
cacheWritesPrice: parsePrice(model.caching_price) || 0,
cacheReadsPrice: parsePrice(model.cached_price) || 0,
description: model.description,
}
models[model.id] = modelInfo
}
console.log("Requesty models fetched", models)
} else {
console.error("Invalid response from Requesty API")
}
} catch (error) {
console.error("Error fetching Requesty models:", error)
}
return OpenRouterCompatibleModelInfo.create({ models })
}
+10
View File
@@ -0,0 +1,10 @@
import { Controller } from ".."
import { StringRequest, Empty } from "../../../shared/proto/common"
/**
* Command slash command logic
*/
export async function condense(controller: Controller, request: StringRequest): Promise<Empty> {
await controller.task?.handleWebviewAskResponse("yesButtonClicked")
return Empty.create()
}
+22
View File
@@ -0,0 +1,22 @@
// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
import { createServiceRegistry, ServiceMethodHandler, StreamingMethodHandler } from "../grpc-service"
import { StreamingResponseHandler } from "../grpc-handler"
import { registerAllMethods } from "./methods"
// Create slash service registry
const slashService = createServiceRegistry("slash")
// Export the method handler types and registration function
export type SlashMethodHandler = ServiceMethodHandler
export type SlashStreamingMethodHandler = StreamingMethodHandler
export const registerMethod = slashService.registerMethod
// Export the request handlers
export const handleSlashServiceRequest = slashService.handleRequest
export const handleSlashServiceStreamingRequest = slashService.handleStreamingRequest
export const isStreamingMethod = slashService.isStreamingMethod
// Register all slash methods
registerAllMethods()
+14
View File
@@ -0,0 +1,14 @@
// AUTO-GENERATED FILE - DO NOT MODIFY DIRECTLY
// Generated by proto/build-proto.js
// Import all method implementations
import { registerMethod } from "./index"
import { condense } from "./condense"
import { reportBug } from "./reportBug"
// Register all slash service methods
export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("condense", condense)
registerMethod("reportBug", reportBug)
}
+10
View File
@@ -0,0 +1,10 @@
import { Controller } from ".."
import { StringRequest, Empty } from "../../../shared/proto/common"
/**
* Report bug slash command logic
*/
export async function reportBug(controller: Controller, request: StringRequest): Promise<Empty> {
await controller.task?.handleWebviewAskResponse("yesButtonClicked")
return Empty.create()
}
+2
View File
@@ -5,6 +5,7 @@
import { registerMethod } from "./index"
import { getLatestState } from "./getLatestState"
import { subscribeToState } from "./subscribeToState"
import { toggleFavoriteModel } from "./toggleFavoriteModel"
// Streaming methods for this service
export const streamingMethods = ["subscribeToState"]
@@ -14,4 +15,5 @@ export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("getLatestState", getLatestState)
registerMethod("subscribeToState", subscribeToState, { isStreaming: true })
registerMethod("toggleFavoriteModel", toggleFavoriteModel)
}
@@ -0,0 +1,46 @@
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { Controller } from ".."
import { Empty, StringRequest } from "../../../shared/proto/common"
import { updateGlobalState } from "@/core/storage/state"
/**
* Toggles a model's favorite status
* @param controller The controller instance
* @param request The request containing the model ID to toggle
* @returns An empty response
*/
export async function toggleFavoriteModel(controller: Controller, request: StringRequest): Promise<Empty> {
try {
if (!request.value) {
throw new Error("Model ID is required")
}
const modelId = request.value
const { apiConfiguration } = await controller.getStateToPostToWebview()
if (!apiConfiguration) {
throw new Error("API configuration not found")
}
const favoritedModelIds = apiConfiguration.favoritedModelIds || []
// Toggle favorite status
const updatedFavorites = favoritedModelIds.includes(modelId)
? favoritedModelIds.filter((id) => id !== modelId)
: [...favoritedModelIds, modelId]
await updateGlobalState(controller.context, "favoritedModelIds", updatedFavorites)
// Capture telemetry for model favorite toggle
const isFavorited = !favoritedModelIds.includes(modelId)
telemetryService.captureModelFavoritesUsage(modelId, isFavorited)
// Post state to webview without changing any other configuration
await controller.postStateToWebview()
return Empty.create()
} catch (error) {
console.error(`Failed to toggle favorite status for model ${request.value}:`, error)
throw error
}
}
@@ -0,0 +1,88 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { EmptyRequest } from "../../../shared/proto/common"
import { DeleteNonFavoritedTasksResults } from "../../../shared/proto/task"
import { getGlobalState, updateGlobalState } from "../../storage/state"
import { fileExistsAtPath } from "../../../utils/fs"
/**
* Deletes all non-favorited tasks, preserving only favorited ones
* @param controller The controller instance
* @param request Empty request
* @returns DeleteNonFavoritedTasksResults with counts of preserved and deleted tasks
*/
export async function deleteNonFavoritedTasks(
controller: Controller,
_request: EmptyRequest,
): Promise<DeleteNonFavoritedTasksResults> {
try {
// Clear current task first
await controller.clearTask()
// Get existing task history
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
// Filter out non-favorited tasks
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
const deletedCount = taskHistory.length - favoritedTasks.length
console.log(`[deleteNonFavoritedTasks] Found ${favoritedTasks.length} favorited tasks to preserve`)
// Update global state
if (favoritedTasks.length > 0) {
await updateGlobalState(controller.context, "taskHistory", favoritedTasks)
} else {
await updateGlobalState(controller.context, "taskHistory", undefined)
}
// Handle file system cleanup for deleted tasks
const preserveTaskIds = favoritedTasks.map((task) => task.id)
await cleanupTaskFiles(controller, preserveTaskIds)
// Update webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
console.error("Error posting to webview:", webviewErr)
}
return {
tasksPreserved: favoritedTasks.length,
tasksDeleted: deletedCount,
}
} catch (error) {
console.error("Error in deleteNonFavoritedTasks:", error)
throw error
}
}
/**
* Helper function to cleanup task files while preserving specified tasks
*/
async function cleanupTaskFiles(controller: Controller, preserveTaskIds: string[]) {
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
try {
if (await fileExistsAtPath(taskDirPath)) {
if (preserveTaskIds.length > 0) {
const taskDirs = await fs.readdir(taskDirPath)
console.debug(`[cleanupTaskFiles] Found ${taskDirs.length} task directories`)
// Delete only non-preserved task directories
for (const dir of taskDirs) {
if (!preserveTaskIds.includes(dir)) {
await fs.rm(path.join(taskDirPath, dir), { recursive: true, force: true })
}
}
} else {
// No tasks to preserve, delete everything
await fs.rm(taskDirPath, { recursive: true, force: true })
}
}
} catch (error) {
console.error("Error cleaning up task files:", error)
}
return true
}
+117
View File
@@ -0,0 +1,117 @@
import { Controller } from ".."
import { GetTaskHistoryRequest, TaskHistoryArray } from "../../../shared/proto/task"
import { getGlobalState } from "../../storage/state"
import { getWorkspacePath, arePathsEqual } from "../../../utils/path"
/**
* Gets filtered task history
* @param controller The controller instance
* @param request Filter parameters for task history
* @returns TaskHistoryArray with filtered task list
*/
export async function getTaskHistory(controller: Controller, request: GetTaskHistoryRequest): Promise<TaskHistoryArray> {
try {
const { favoritesOnly, currentWorkspaceOnly, searchQuery, sortBy } = request
// Get task history from global state
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
const workspacePath = getWorkspacePath()
// Apply filters
let filteredTasks = taskHistory.filter((item) => {
// Basic filter: must have timestamp and task content
const hasRequiredFields = item.ts && item.task
if (!hasRequiredFields) {
return false
}
// Apply favorites filter if requested
if (favoritesOnly && !item.isFavorited) {
return false
}
// Apply current workspace filter if requested
if (currentWorkspaceOnly) {
let isInWorkspace = false
// First check the cwdOnTaskInitialization property - Only present on tasks from this change forward
if (item.cwdOnTaskInitialization) {
if (arePathsEqual(item.cwdOnTaskInitialization, workspacePath)) {
isInWorkspace = true
}
}
// For tasks without cwdOnTaskInitialization, check the older shadowGitConfigWorkTree property
if (!isInWorkspace && item.shadowGitConfigWorkTree) {
if (arePathsEqual(item.shadowGitConfigWorkTree, workspacePath)) {
isInWorkspace = true
}
}
if (!isInWorkspace) {
return false
}
}
return true
})
// Apply search if provided
if (searchQuery) {
// Simple search implementation
const query = searchQuery.toLowerCase()
filteredTasks = filteredTasks.filter((item) => item.task.toLowerCase().includes(query))
}
// Calculate total count before sorting
const totalCount = filteredTasks.length
// Apply sorting
if (sortBy) {
filteredTasks.sort((a, b) => {
switch (sortBy) {
case "oldest":
return a.ts - b.ts
case "mostExpensive":
return (b.totalCost || 0) - (a.totalCost || 0)
case "mostTokens":
return (
(b.tokensIn || 0) +
(b.tokensOut || 0) +
(b.cacheWrites || 0) +
(b.cacheReads || 0) -
((a.tokensIn || 0) + (a.tokensOut || 0) + (a.cacheWrites || 0) + (a.cacheReads || 0))
)
case "newest":
default:
return b.ts - a.ts
}
})
} else {
// Default sort by newest
filteredTasks.sort((a, b) => b.ts - a.ts)
}
// Map to response format
const tasks = filteredTasks.map((item) => ({
id: item.id,
task: item.task,
ts: item.ts,
isFavorited: item.isFavorited || false,
size: item.size || 0,
totalCost: item.totalCost || 0,
tokensIn: item.tokensIn || 0,
tokensOut: item.tokensOut || 0,
cacheWrites: item.cacheWrites || 0,
cacheReads: item.cacheReads || 0,
}))
return {
tasks,
totalCount,
}
} catch (error) {
console.error("Error in getTaskHistory:", error)
throw error
}
}
+6
View File
@@ -5,18 +5,24 @@
import { registerMethod } from "./index"
import { cancelTask } from "./cancelTask"
import { clearTask } from "./clearTask"
import { deleteNonFavoritedTasks } from "./deleteNonFavoritedTasks"
import { deleteTasksWithIds } from "./deleteTasksWithIds"
import { exportTaskWithId } from "./exportTaskWithId"
import { getTaskHistory } from "./getTaskHistory"
import { newTask } from "./newTask"
import { showTaskWithId } from "./showTaskWithId"
import { toggleTaskFavorite } from "./toggleTaskFavorite"
// Register all task service methods
export function registerAllMethods(): void {
// Register each method with the registry
registerMethod("cancelTask", cancelTask)
registerMethod("clearTask", clearTask)
registerMethod("deleteNonFavoritedTasks", deleteNonFavoritedTasks)
registerMethod("deleteTasksWithIds", deleteTasksWithIds)
registerMethod("exportTaskWithId", exportTaskWithId)
registerMethod("getTaskHistory", getTaskHistory)
registerMethod("newTask", newTask)
registerMethod("showTaskWithId", showTaskWithId)
registerMethod("toggleTaskFavorite", toggleTaskFavorite)
}

Some files were not shown because too many files have changed in this diff Show More