Compare commits

...

45 Commits

Author SHA1 Message Date
Cline Evaluation bac990e5ad Adding Gemini CLI 2025-06-25 12:56:48 -06:00
Cline Evaluation a694fa27eb Adding Gemini CLI 2025-06-25 12:17:35 -06:00
Cline Evaluation 9edbccc54f Adding Gemini CLI 2025-06-25 11:36:02 -06:00
Toshii f8667f8a9d base (#4441) 2025-06-24 21:08:50 -07:00
Toshii 077561fb71 refactor Anthropic, AskSage provider ui (#4428)
* anthropic provider

Co-authored-by: StvLz <lizarazo.steven@gmail.com>

* base

---------

Co-authored-by: StvLz <lizarazo.steven@gmail.com>
2025-06-24 21:07:28 -07:00
Toshii 97fd1daec6 base (#4440) 2025-06-24 21:06:09 -07:00
Toshii 5c6e1d2ce9 fix thinking budget slider ui for gemini (#4435)
* fix

* remove some models
2025-06-24 21:05:29 -07:00
pashpashpash ec4d515d28 claude code docs (#4437)
* claude code docs

* Update docs/provider-config/claude-code.mdx

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

---------

Co-authored-by: Cline Evaluation <cline@example.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-06-24 17:14:48 -07:00
celestial-vault 59efda3e22 fix link by calling grpc (#4422) 2025-06-24 16:07:19 -07:00
canvrno 936923d334 [PROTOBUS] Move telemetrySetting to protobus (#3711)
* telemtrySetting protobus migration

* merge conflcit fix

* Updated telemetrySettingRequest to use create

* rebase/merge fixes
2025-06-24 16:05:07 -07:00
celestial-vault 0a57ec3b7d remove remaining postMessage calls (#4432) 2025-06-24 15:52:36 -07:00
canvrno 670f3a1d62 [PROTOBUS] Move clearAllTaskHistory to protobus (#3674)
* clearAllTaskHistory protobus migration

* changset

* Fixed protobuf object literal usage

* cleanup

* remove old methods + return zero when user tries to delete favorites but none exist

* Removed deleteTaskWithId from controller and removed legacy claude_messages.json code
2025-06-24 14:30:59 -07:00
github-actions[bot] 8104f18f5a Changeset version bump (#4344)
* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md and package.json for version 3.17.16

---------

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-06-24 12:38:22 -07:00
canvrno 4de7790fa1 pretty (#4307) 2025-06-24 11:43:05 -07:00
celestial-vault adfb5a2b6e add eslint rule banning postMessage in webview (#4414) 2025-06-24 07:54:23 -10:00
pashpashpash d55a23448d fixing case in dashboard where no valid results (#4408)
Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-23 21:04:53 -07:00
celestial-vault 1dd164f482 simplify is (#4404)
streaming boolean to just use lastMessage.partial
2025-06-23 18:29:13 -07:00
pashpashpash 50b43c0559 Diff Evals - Replay feature (#4407)
* wip

* ok replays

* replays cooking

* docs

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-23 18:25:26 -07:00
Sarah Fortune 943c52f0b3 Update scripts/get-vscode-usages.sh (#4402) 2025-06-23 16:19:14 -07:00
Tomás Barreiro b84084936b fix: Handle long Claude code messages (#4287)
* Handle partial messages

* Parse chunks separately

* Add changeset

* refactor

* disallow tools

* Handle incomplete chunks

* Do not log costs when using a subscription, improve error handling and refactor rl usage

* Improve output handling. Prefer returning partial data to nothing.

* Set the total cost to 0 instead of leaving it undefined

* Fix the model infos and stop supporting images

* Reduce timeout to 10 minutes

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-24 03:09:21 +05:30
Sarah Fortune 014910deb9 Add clean script to package.json (#4326)
Add a script to remove build artifacts.
2025-06-23 14:24:08 -07:00
celestial-vault a30cefa595 Move mode to controller (#4343)
* you know what im talking about

* store mode in controller and target sendStateUpdate by controller ID
2025-06-23 14:23:20 -07:00
Daniel Steigman 620f402f36 Added protobuf development rule file (#4401)
* added protobuf development rule file

* new UI based example
2025-06-23 14:10:57 -07:00
Sarah Fortune 22ff68565b Add content security policy for external webview provider. (#4337)
Replace placeholder with correct csp source.
2025-06-23 11:51:05 -07:00
Sarah Fortune 3e1565da59 In the reflection output the standalone service should only show the Protobus services (#4339)
* The standalone service should only show the Protobus services in the reflection output.

The proto descriptor set is including all the proto services, allowlist the services in the cline and health packages.

* Remove debug code

* Fix variable name
2025-06-23 11:35:09 -07:00
Ara f8a284c6fe Fixing the contributor flow for Cline to force users to make issues first (#4324)
* Fixing the contributor flow for Cline to force users to make issues first

* Fixing the contributor flow for Cline to force users to make issues first

* Fixing the contributor flow for Cline to force users to make issues first

* Fixing the contributor flow for Cline to force users to make issues first

* Update feature_contribution.yml

Co-authored-by: Toshii <94262432+0xToshii@users.noreply.github.com>

---------

Co-authored-by: Cline Evaluation <cline@example.com>
Co-authored-by: Toshii <94262432+0xToshii@users.noreply.github.com>
2025-06-22 08:11:01 -07:00
Anthony Gentile 39718cc521 #3775 remove deps clsx tailwind merge (#4147)
* v3.17.12 Release Notes

* changeset version bump

* Updating CHANGELOG.md format

* Update CHANGELOG.md for version 3.17.12

* changelog language

* changelog language

* attribution

---------

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>

* #3720 first pass remove clsx / tailwind-merge and replace with template literals

* package-lock.json changes

* update changeset for 3720, remove clsx & tailwind-merge deps and replace with template literals

* #3775 Handle empty or null className

---------

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-06-21 13:39:15 -07:00
Jorge García Rey e945a45102 feat: Add taskIdas metadata to use from LiteLLM (#3696)
* feat: add cline_task_id metatada to use in LiteLLM

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

* refactor: remove comment

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

* docs: add changeset

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

* refactor: apply suggestions type

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

* fix: format

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>

---------

Signed-off-by: Jorge García Rey <jorgegar@inditex.com>
2025-06-21 11:17:31 -07:00
celestial-vault e2f9c38902 Migrate fetchUserCreditsData protobus (#3801)
* migrate fetchUserCreditsData

* changeset

* remove parseInt

* linter error

* change proto fields to snake case

* syntax error

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-06-21 11:06:09 -07:00
Toshii 447a6ba4d5 update stale workflow (#4340) 2025-06-20 21:01:00 -07:00
Toshii 3f914f5092 refactor open ai compatible and sambanova providers (#4338)
* open ai compatible

Co-authored-by: StvLz <lizarazo.steven@gmail.com>

* Sambanova

* order

* reusable components

---------

Co-authored-by: StvLz <lizarazo.steven@gmail.com>
2025-06-20 20:29:20 -07:00
celestial-vault b26997abc0 Migrate authStateChanged protobus (#3835)
* migrate authStateChanges

* changeset

* fix types and linter errors:

---------

Co-authored-by: Elephant Lumps <celestial_vault@Elephants-MacBook-Pro.local>
2025-06-20 19:46:23 -07:00
Andrew Hood 560c79b885 Add new regions to available Bedrock options (#4056)
* Update ApiOptions.tsx

Added eu-south-1 (Milan) and eu-south-2 (Spain) to selection menu

* Update ApiOptions.tsx

Fixed names of eu-south-1 and eu-south-2 in settings menu
2025-06-20 18:24:30 -07:00
celestial-vault 77cc05c843 Create tool executor class (#4323)
* move apiConversationHistory to MessageStateHandler

* move clineMessages state to MessageStateManager class

* remove unused imports

* reorganize task class state variables and refactor out utility functions in recursivelyMakeClineRequests

* move task ephemeral state to state class

* extract tool logic into tool executor class

* integrate ToolExecutor into task class
2025-06-20 14:53:28 -07:00
Sarah Fortune 7db8c6ae4c Support external webview (#4336)
* Add ui.getWebviewHtml to the protobus

This will return the HTML content for external clients.

* Add getUri to ExternalWebviewProvider

Change getUri to return URIs for files in an appropriate format for the external web view.
Use URI from npm module in ExternalWebviewProvider.
Use a default value for the cline dir, ~/.cline
Turn off gRPC debugging

* Include node modules used as assets in the standalone package.

* Throw an error if trying to recreate webview panel in standalone app.
2025-06-20 13:44:22 -07:00
github-actions[bot] 6400901821 v3.17.15 Release Notes
v3.17.15 Release Notes
2025-06-20 12:38:29 -07:00
Toshii 07c354314a deepseek and together refactor (#4325)
Co-authored-by: StvLz <lizarazo.steven@gmail.com>
2025-06-20 11:51:38 -07:00
pashpashpash 430074d0c2 diff evals (#4154)
* cleaning up a bit

* cleaning up some more

* readme

* added max limit

* making it portable

* ignore

* committing plans for now

* strealit hooked up, multi model runs, better db torage

* docs

* VALID attempts

* logging

* more stability

* strategy

* cleaning deps

* docs

* streamlit dashboard work

* dashboard showing bad cases

* better parallelization pt1

* global worker pool for even better more robust parallelization

* bumping up default max parallel requests from 20 -> 80

* better devx

* better docs

* docs

* better devx

* better docs

* better presentation

* dark mode

* removed unused import

---------

Co-authored-by: Cline Evaluation <cline@example.com>
2025-06-20 11:41:16 -07:00
Toshii 0eec3b928b suggestion (#4305) 2025-06-20 11:40:31 -07:00
Toshii cb525ee588 gemini ga (#4304) 2025-06-20 11:40:13 -07:00
Yukio Nozawa 1b700805b6 Fix: Add role and aria-checked attributes to plan / act mode switch so that screen readers can tell its state ( Resolves #4244 ) (#4263)
* Fix: Screen readers now report plan / act mode switch state

* Create changeset
2025-06-20 11:39:56 -07:00
rikaaa0928 584c0da4ad fix: preferred language setting (#4282) 2025-06-20 11:39:17 -07:00
Ramy Ben Aroya c54cf38974 fix: omit undefined MCP server name when command is missing (#3840)
* fix: omit undefined MCP server name when command is missing

* apply to all prompts

---------

Co-authored-by: 0xtoshii <94262432+0xToshii@users.noreply.github.com>
2025-06-20 11:38:57 -07:00
Ryan Nauman f79d1b9c02 chore: add run instructions to CONTRIBUTING.md (#3779) 2025-06-20 11:38:22 -07:00
celestial-vault 0876b3f335 Fix tab menu buttons affecting other tabs (#4329) 2025-06-19 22:06:22 -07:00
150 changed files with 13191 additions and 6944 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix: respect setting litellm models for plan and act
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": minor
---
Migrate didBecomeVisible to protobus
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Do not clear the input value when changing modes without sending a message
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Prevent reading IS_DEV from the users environment
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix(bedrock): remove custom Model encode
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Updated Timeline tooltips for `followup` messages and consolidated color retrieval code.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
telemtrySetting protobus migration
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
clearAllTaskHistory protobus migration
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
The close task button and delete task button in the task header are now correctly announced by screen readers.
+89
View File
@@ -0,0 +1,89 @@
# Cline Protobuf Development Guide
This guide outlines how to add new gRPC endpoints for communication between the webview (frontend) and the extension host (backend).
## Overview
Cline uses [Protobuf](https://protobuf.dev/) to define a strongly-typed API, ensuring efficient and type-safe communication. All definitions are in the `/proto` directory. The compiler and plugins are included as project dependencies, so no manual installation is needed.
## Key Concepts & Best Practices
- **File Structure**: Each feature domain should have its own `.proto` file (e.g., `account.proto`, `task.proto`).
- **Message Design**:
- For simple, single-value data, use the shared types in `proto/common.proto` (e.g., `StringRequest`, `Empty`, `Int64Request`). This promotes consistency.
- For complex data structures, define custom messages within the feature's `.proto` file (see `task.proto` for examples like `NewTaskRequest`).
- **Naming Conventions**:
- Services: `PascalCaseService` (e.g., `AccountService`).
- RPCs: `camelCase` (e.g., `accountEmailIdentified`).
- Messages: `PascalCase` (e.g., `StringRequest`).
- **Streaming**: For server-to-client streaming, use the `stream` keyword on the response type. See `subscribeToAuthCallback` in `account.proto` for an example.
---
## 4-Step Development Workflow
Heres how to add a new RPC, using `scrollToSettings` as an example.
### 1. Define the RPC in a `.proto` File
Add your service method to the appropriate file in the `proto/` directory.
**File: `proto/ui.proto`**
```proto
service UiService {
// ... other RPCs
// Scrolls to a specific settings section in the settings view
rpc scrollToSettings(StringRequest) returns (KeyValuePair);
}
```
Here, we use the common `StringRequest` and `KeyValuePair` types.
### 2. Compile Definitions
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
```bash
npm run protos
```
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
### 3. Implement the Backend Handler
Create the RPC implementation in the backend. Handlers are located in `src/core/controller/[service-name]/`.
**File: `src/core/controller/ui/scrollToSettings.ts`**
```typescript
import { Controller } from ".."
import { StringRequest, KeyValuePair } from "../../../shared/proto/common"
/**
* Executes a scroll to settings action
* @param controller The controller instance
* @param request The request containing the ID of the settings section to scroll to
* @returns KeyValuePair with action and value fields for the UI to process
*/
export async function scrollToSettings(controller: Controller, request: StringRequest): Promise<KeyValuePair> {
return KeyValuePair.create({
key: "scrollToSettings",
value: request.value || "",
})
}
```
### 4. Call the RPC from the Webview
Call the new RPC from a React component in `webview-ui/`. The generated client makes this simple.
**File: `webview-ui/src/components/browser/BrowserSettingsMenu.tsx`** (Example)
```tsx
import { UiServiceClient } from "../../../services/grpc"
import { StringRequest } from "../../../../shared/proto/common"
// ... inside a React component
const handleMenuClick = async () => {
try {
await UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))
} catch (error) {
console.error("Error scrolling to browser settings:", error)
}
}
```
@@ -0,0 +1,116 @@
name: 💡 Feature Proposal & Contribution
description: Propose a new feature or improvement, and optionally offer to implement feature as a contributor
labels: ["proposal"]
body:
- type: markdown
attributes:
value: |
**Feature Proposal & Contribution for Cline**
Thank you for proposing a feature or improvement for Cline! This template helps us understand the problem, evaluate the solution, and coordinate implementation.
**For detailed proposals:** Please provide comprehensive information to enable fast prioritization and discussion.
**For contribution offers:** You can indicate your willingness to implement the feature yourself.
Before submitting:
- Search existing [Issues](https://github.com/cline/cline/issues) and [Discussions](https://github.com/cline/cline/discussions) to avoid duplicates
- Read the [Contributing Guide](https://github.com/cline/cline/blob/main/CONTRIBUTING.md) if you plan to contribute
- Don't start implementation until the proposal is reviewed and approved
- type: textarea
id: problem-description
attributes:
label: What problem does this solve?
description: |
Describe the problem clearly from a user's point of view. Focus on why this matters, who it affects, and when it occurs.
✅ Good examples:
- "LLM provider returns 400 error when nearing the context window instead of truncating"
- "Submit button is invisible in dark mode"
- "Users can't easily share their Cline configurations with team members"
❌ Avoid vague descriptions:
- "Performance is bad"
- "UI needs work"
Your description should include:
- Who is affected?
- When does it happen?
- What's the current vs expected behavior?
- What is the impact?
placeholder: Be specific about the problem, who it affects, and the impact.
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: What's the proposed solution?
description: |
Describe how the problem should be solved. Be specific about UX, system behavior, and any flows that would change.
✅ Good examples:
- "Add error handling immediately after attempting to create the llm stream and retry after manually truncating"
- "Update button styling to ensure contrast in all themes"
- "Add export/import functionality in settings with JSON format"
❌ Avoid vague solutions:
- "Improve performance"
- "Fix the bug"
Your solution should include:
- What exactly will change?
- How will users interact with it?
- What's the expected outcome?
placeholder: Describe the proposed changes and how they solve the problem.
validations:
required: false
- type: dropdown
id: contribution-intent
attributes:
label: Are you interested in implementing this?
description: Let us know if you'd like to contribute to this feature
options:
- "No, just proposing the idea"
- "Yes, I'd like to implement this myself"
- "Yes, I'd like to collaborate with others"
- "Maybe, depending on complexity and guidance"
validations:
required: false
- type: textarea
id: implementation-approach
attributes:
label: Implementation approach (if contributing)
description: |
**Only fill this out if you selected "Yes" above.**
How do you plan to implement this? Include:
- High-level technical approach
- Files/components that would be affected
- Any new dependencies required
- Potential challenges or considerations you've identified
This helps us provide better guidance and ensures alignment before you start coding.
placeholder: "My implementation approach would be..."
- type: checkboxes
id: checklist
attributes:
label: Proposal checklist
options:
- label: I've checked for existing issues or related proposals
required: true
- label: I understand this needs review before implementation can start
required: true
- type: checkboxes
id: contribution-checklist
attributes:
label: Contribution checklist (if contributing)
description: Only check these if you plan to contribute
options:
- label: I've read the [Contributing Guide](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
- label: I'm willing to make changes based on feedback
- label: I understand the code review process and requirements
-130
View File
@@ -1,130 +0,0 @@
name: 📝 Detailed Feature Proposal
description: Propose a new feature or improvement
labels: ["proposal"]
body:
- type: markdown
attributes:
value: |
**Feature Proposal for Cline**
Thank you for creating a feature proposal for Cline! This template is for clear, actionable proposals that define a specific problem and a high-confidence solution. Please provide enough detail to enable fast prioritization, discussion, and execution.
Detailed proposals will be prioritized, while vague proposals may be closed or require extensive back and forth communication.
Before submitting:
- Search existing [Issues](https://github.com/cline/cline/issues) and [Discussions](https://github.com/cline/cline/discussions) to avoid duplicates
- Dont start implementation until the proposal is reviewed and approved
- type: textarea
id: problem-description
attributes:
label: What problem does this solve?
description: |
Describe the problem clearly from a user's point of view. Focus on why this matters, who it affects, and when it occurs.
✅ Solid:
- "LLM provider returns 400 error when nearing the context window instead of truncating"
- "Submit button is invisible in dark mode"
❌ Avoid:
- "Performance is bad"
- "UI needs work"
Your description should include:
- Who is affected?
- When does it happen?
- What's the current vs expected behavior?
- What is the impact?
placeholder: Be specific about the problem, who it affects, and the impact.
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Whats the proposed solution?
description: |
Describe how the problem should be solved. Be specific about UX, system behavior, and any flows that would change.
✅ Solid:
- "Add error handling immediately after attempting to create the llm stream and retry after manually truncating"
- "Update button styling to ensure contrast in all themes"
❌ Avoid:
- "Improve performance"
- "Fix the bug"
Your solution should include:
- What exactly will change?
- How will users interact with it?
- Whats the expected outcome?
placeholder: Describe the proposed changes and how they solve the problem.
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: How will we know it works? (Acceptance Criteria)
description: Define clear, testable success criteria.
placeholder: Provide specific and testable conditions for success.
validations:
required: true
- type: textarea
id: estimated-effort
attributes:
label: Estimated effort and complexity
description: |
Help us understand scope and risks. Include:
- Size estimate (XS/S/M/L/XL or hours/days)
- Why this size? Whats technically involved?
- Any tricky parts, refactors, or risks?
- Performance or compatibility concerns?
- Any dependencies on systems, teams, or libraries?
placeholder: Size, reasoning, risks, and dependencies.
validations:
required: true
- type: textarea
id: technical-considerations
attributes:
label: Technical considerations, tradeoffs, and/or risks (optional)
description: |
Include any technical context that helps us evaluate or implement the proposal more effectively.
You may include:
- Architectural changes or required refactors
- Performance implications or system-level impacts
- Known limitations, risks, or tricky edge cases
- Compatibility concerns or migration steps
- Alternative approaches you considered and why they were not chosen
- Dependencies on other systems, teams, or libraries
- Were other approaches considered? Why is this one preferred?
placeholder: Technical considerations, tradeoffs, and/or risks.
- type: textarea
id: additional-context
attributes:
label: Additional context (optional)
description: Diagrams, mockups, logs, links, or anything else that helps explain or justify the proposal.
placeholder: Diagrams, mockups, logs, links, or anything else that helps explain or justify the proposal.
- type: checkboxes
id: checklist
attributes:
label: Proposal checklist
options:
- label: Ive checked for existing issues or related proposals
required: true
- label: I understand this needs review before implementation can start
required: true
- type: checkboxes
id: willingness-to-contribute
attributes:
label: Interested in implementing this?
description: Optional
options:
- label: Yes, Id like to help implement this feature
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@28ca103
- uses: actions/stale@v9
with:
days-before-issue-stale: 60
days-before-issue-close: 14
+3
View File
@@ -35,4 +35,7 @@ webview-ui/src/services/grpc-client.ts
# Host bridge
src/hosts/vscode/*/methods.ts
src/hosts/vscode/*/index.ts
src/hosts/vscode/client/host-grpc-client.ts
src/hosts/vscode/host-grpc-service-config.ts
src/standalone/server-setup.ts
gemini-cli
+1
View File
@@ -5,3 +5,4 @@ webview-ui/build/
package-lock.json
src/core/prompts/system.ts
src/core/prompts/model_prompts/claude4.ts
evals/
+4 -3
View File
@@ -50,10 +50,11 @@
"outFiles": ["${workspaceFolder}/dist-standalone/**/*.js"],
"preLaunchTask": "compile-standalone",
"env": {
"GRPC_TRACE": "all",
"GRPC_VERBOSITY": "DEBUG",
// Turns on grpc debug log.
//"GRPC_TRACE": "all",
//"GRPC_VERBOSITY": "DEBUG",
"NODE_PATH": "${workspaceFolder}/dist-standalone/node_modules",
"CLINE_DIR": "${userHome}/.cline-standalone",
"HOST_BRIDGE_ADDRESS": "localhost:50052"
},
"program": "standalone.js"
+16
View File
@@ -1,5 +1,21 @@
# Changelog
## [3.17.16]
- Fix Claude Code provider error handling for incomplete messages during long-running tasks (Thanks @BarreiroT!)
- Add taskId as metadata to LiteLLM API requests for better request tracing (Thanks @jorgegarciarey!)
## [3.17.15]
- Fix LiteLLM provider to properly respect selected model IDs when switching between Plan and Act modes (Thanks @sammcj!)
- Fix chat input being cleared when switching between Plan/Act modes without sending a message (Thanks @BarreiroT!)
- Fix MCP server name display to avoid showing "undefined" for SSE servers, preventing tool/resource invocation failures (Thanks @ramybenaroya!)
- Fix AWS Bedrock provider by removing deprecated custom model encoding (Thanks @watany-dev!)
- Fix timeline tooltips for followup messages and improve color retrieval code (Thanks @char8x!)
- Improve accessibility by making task header buttons properly announced by screen readers (Thanks @yncat!)
- Improve accessibility by adding proper state reporting for Plan/Act mode switch for screen readers (Thanks @yncat!)
- Prevent reading development environment variables from user's environment (Thanks @BarreiroT!)
## [3.17.14]
- Add Claude Code as a new API provider, allowing integration with Anthropic's Claude Code CLI tool and Claude Max Plan (Thanks @BarreiroT!)
+60 -12
View File
@@ -10,13 +10,6 @@ Bug reports help make Cline better for everyone! Before creating a new issue, pl
🔐 <b>Important:</b> If you discover a security vulnerability, please use the <a href="https://github.com/cline/cline/security/advisories/new">Github security tool to report it privately</a>.
</blockquote>
## Deciding What to Work On
Looking for a good first contribution? Check out issues labeled ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) or ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). These are specifically curated for new contributors and areas where we'd love some help!
We also welcome contributions to our [documentation](https://github.com/cline/cline/tree/main/docs)! Whether it's fixing typos, improving existing guides, or creating new educational content - we'd love to build a community-driven repository of resources that helps everyone get the most out of Cline. You can start by diving into `/docs` and looking for areas that need improvement.
If you're planning to work on a bigger feature, please create a [feature request](https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop) first so we can discuss whether it aligns with Cline's vision.
## Before Contributing
@@ -24,14 +17,70 @@ All contributions must begin with a GitHub Issue, unless the change is for small
- **Check existing issues**: Search [GitHub Issues](https://github.com/cline/cline/issues).
- **Create an issue**: Use appropriate templates:
- **Bugs:** "Bug Report" template.
- **Features:** "Detailed Feature Proposal" template. Approval from a core Cline contributor required before starting.
- **Claim issues**: Comment your interest.
- **Contributions:** Use the "Contribution Request" template to propose what you'd like to work on.
- **Bugs:** "Bug Report" template for reporting issues.
- **Features:** "Detailed Feature Proposal" template for suggesting new features.
- **Wait for approval**: A core Cline contributor must approve your contribution request before you start implementation.
- **Claim issues**: Once approved, the issue will be assigned to you.
**PRs without approved issues may be closed.**
## Deciding What to Work On
Looking for a good first contribution? Check out issues labeled ["good first issue"](https://github.com/cline/cline/labels/good%20first%20issue) or ["help wanted"](https://github.com/cline/cline/labels/help%20wanted). These are specifically curated for new contributors and areas where we'd love some help!
We also welcome contributions to our [documentation](https://github.com/cline/cline/tree/main/docs)! Whether it's fixing typos, improving existing guides, or creating new educational content - we'd love to build a community-driven repository of resources that helps everyone get the most out of Cline. You can start by diving into `/docs` and looking for areas that need improvement.
## Development Setup
### Local Development Instructions
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
```bash
git clone https://github.com/cline/cline.git
```
2. Open the project in VSCode:
```bash
code cline
```
3. Install the necessary dependencies for the extension and webview-gui:
```bash
npm run install:all
```
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
### Creating a Pull Request
1. Before creating a PR, generate a changeset entry:
```bash
npm run changeset
```
This will prompt you for:
- Type of change (major, minor, patch)
- `major` → breaking changes (1.0.0 → 2.0.0)
- `minor` → new features (1.0.0 → 1.1.0)
- `patch` → bug fixes (1.0.0 → 1.0.1)
- Description of your changes
2. Commit your changes and the generated `.changeset` file
3. Push your branch and create a PR on GitHub. Our CI will:
- Run tests and checks
- Changesetbot will create a comment showing the version impact
- When merged to main, changesetbot will create a Version Packages PR
- When the Version Packages PR is merged, a new release will be published
4. Testing
- Run `npm run test` to run tests locally.
- Before submitting PR, run `npm run format:fix` to format your code
- Run `npm run test:ci` to run tests locally
### Extension
1. **VS Code Extensions**
- When opening the project, VS Code will prompt you to install recommended extensions
@@ -41,6 +90,7 @@ All contributions must begin with a GitHub Issue, unless the change is for small
2. **Local Development**
- Run `npm run install:all` to install dependencies
- Run `npm run test` to run tests locally
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
- Before submitting PR, run `npm run format:fix` to format your code
3. **Linux-specific Setup**
@@ -85,8 +135,6 @@ All contributions must begin with a GitHub Issue, unless the change is for small
xvfb
```
- Run `npm run test:ci` to run tests locally
## Writing and Submitting Code
Anyone can contribute code to Cline, but we ask that you follow these guidelines to ensure your contributions can be smoothly integrated:
-44
View File
@@ -141,50 +141,6 @@ For example, when working with a local web server, you can use 'Restore Workspac
To contribute to the project, start with our [Contributing Guide](CONTRIBUTING.md) to learn the basics. You can also join our [Discord](https://discord.gg/cline) to chat with other contributors in the `#contributors` channel. If you're looking for full-time work, check out our open positions on our [careers page](https://cline.bot/join-us)!
<details>
<summary>Local Development Instructions</summary>
1. Clone the repository _(Requires [git-lfs](https://git-lfs.com/))_:
```bash
git clone https://github.com/cline/cline.git
```
2. Open the project in VSCode:
```bash
code cline
```
3. Install the necessary dependencies for the extension and webview-gui:
```bash
npm run install:all
```
4. Launch by pressing `F5` (or `Run`->`Start Debugging`) to open a new VSCode window with the extension loaded. (You may need to install the [esbuild problem matchers extension](https://marketplace.visualstudio.com/items?itemName=connor4312.esbuild-problem-matchers) if you run into issues building the project.)
</details>
<details>
<summary>Creating a Pull Request</summary>
1. Before creating a PR, generate a changeset entry:
```bash
npm run changeset
```
This will prompt you for:
- Type of change (major, minor, patch)
- `major` → breaking changes (1.0.0 → 2.0.0)
- `minor` → new features (1.0.0 → 1.1.0)
- `patch` → bug fixes (1.0.0 → 1.0.1)
- Description of your changes
2. Commit your changes and the generated `.changeset` file
3. Push your branch and create a PR on GitHub. Our CI will:
- Run tests and checks
- Changesetbot will create a comment showing the version impact
- When merged to main, changesetbot will create a Version Packages PR
- When the Version Packages PR is merged, a new release will be published
</details>
## License
[Apache 2.0 © 2025 Cline Bot Inc.](./LICENSE)
+1
View File
@@ -146,6 +146,7 @@
"group": "Provider Configuration",
"pages": [
"provider-config/anthropic",
"provider-config/claude-code",
"provider-config/aws-bedrock-with-credentials-authentication",
"provider-config/aws-bedrock-with-profile-authentication",
"provider-config/gcp-vertex-ai",
+93
View File
@@ -0,0 +1,93 @@
# Gemini CLI Provider for Cline
This document describes how to use the Gemini CLI as a provider in Cline.
## Overview
The Gemini CLI provider allows you to use Google's Gemini models through the Gemini CLI tool instead of using API keys directly. This can be useful if you:
- Already have the Gemini CLI installed and configured
- Want to use the CLI's authentication methods (OAuth, etc.)
- Prefer to manage your Gemini access through the CLI
## Prerequisites
1. **Install the Gemini CLI**: The Gemini CLI must be installed and accessible from your system PATH.
2. **Set up authentication**: The CLI needs to be authenticated. You can either:
- Set the `GEMINI_API_KEY` environment variable
- Use the CLI's built-in authentication methods
3. **Build the CLI** (if using from source):
```bash
cd gemini-cli/packages/cli
npm install
npm run build
```
## Configuration
To use the Gemini CLI provider in Cline:
1. Open Cline settings
2. Select "Gemini CLI" as your API provider
3. (Optional) Specify the path to the Gemini CLI executable if it's not in your PATH
4. Select your desired Gemini model
## Supported Models
The Gemini CLI provider supports the same models as the regular Gemini provider:
- gemini-2.5-pro
- gemini-2.5-flash
- gemini-2.0-flash-001
- gemini-1.5-flash-002
- gemini-1.5-pro-002
## How It Works
1. When you send a message in Cline, it converts your conversation to a format the Gemini CLI understands
2. Cline spawns the Gemini CLI as a subprocess with your prompt
3. The CLI's response is streamed back to Cline
4. The response is displayed in the Cline interface
## Limitations
- **No image support**: The Gemini CLI in non-interactive mode doesn't support images
- **No tool execution**: The CLI handles its own tool execution, which doesn't integrate with Cline's tools
- **Token counting**: Token usage is estimated based on text length rather than actual token counts
- **No caching**: The CLI doesn't support Anthropic-style prompt caching
## Troubleshooting
### CLI not found
- Ensure the Gemini CLI is installed and in your PATH
- Or specify the full path to the CLI in Cline settings
### Authentication errors
- Check that `GEMINI_API_KEY` is set in your environment
- Or ensure the CLI is authenticated using its built-in methods
### No response
- Try running the CLI manually to ensure it works:
```bash
gemini --prompt "Hello, world!"
```
### Build errors
If using from source, ensure the CLI is built:
```bash
cd gemini-cli/packages/cli
npm install
npm run build
```
## Testing
You can test the integration using the provided test script:
```bash
node test-gemini-cli.js
```
This will attempt to run a simple prompt through the CLI and report whether it succeeded.
+86
View File
@@ -0,0 +1,86 @@
---
title: "Claude Code"
description: "Use your Claude Max subscription with Cline instead of paying per token. Learn how to set up and configure the Claude Code provider."
---
**Website:** [https://docs.anthropic.com/en/docs/claude-code/setup](https://docs.anthropic.com/en/docs/claude-code/setup)
The Claude Code provider lets you use your existing Claude subscription with Cline. If you have Claude Max, this means you can use Claude in Cline without paying extra API costs.
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/claude-code-use-opus.gif"
alt="Using the Claude Code provider in Cline with Opus model"
/>
</Frame>
## Setup
First, you'll need to install and authenticate Claude Code on your system:
1. **Install Claude Code**: Follow Anthropic's [official setup guide](https://docs.anthropic.com/en/docs/claude-code/setup) to install and authenticate the Claude CLI.
2. **Configure in Cline**:
- Open Cline settings (⚙️ icon)
- Select **Claude Code** from the **API Provider** dropdown
- Set the path to your Claude CLI executable (usually just `claude` if it's in your PATH)
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/claude-code-setup.gif"
alt="Setting up the Claude Code provider in Cline"
/>
</Frame>
### Finding your Claude Code path
If you're not sure where Claude Code is installed:
- **macOS / Linux**: Run `which claude` in your terminal
- **Windows (Command Prompt)**: Run `where claude`
- **Windows (PowerShell)**: Run `Get-Command claude`
## Supported Models
The Claude Code provider supports these models:
- `claude-sonnet-4-20250514` (Recommended)
- `claude-opus-4-20250514`
- `claude-3-7-sonnet-20250219`
- `claude-3-5-sonnet-20241022`
- `claude-3-5-haiku-20241022`
## How it works
When you use Claude Code with Cline, here's what happens behind the scenes:
Cline wraps the Claude Code CLI to handle your requests. Each time you send a message, Cline starts a new `claude` process, sends your conversation, and streams the response back. The AI reasoning comes from Claude Code, but all the actual file editing, terminal commands, and other tools are handled by Cline.
The main difference you'll notice is that responses don't stream character-by-character like other providers. Instead, Claude Code processes your full request before sending back the complete response.
## Limitations
There are a few things to keep in mind with Claude Code:
- Images in your messages get converted to text placeholders since Claude Code doesn't support image uploads through the CLI
- Prompt caching isn't available with this provider
- Responses don't stream in real-time like other providers
## Troubleshooting
If you run into issues:
**Authentication problems**: Make sure you're logged into Claude Code with your subscription account. Run `claude auth status` to check.
**Path issues**: Double-check that the Claude CLI path in Cline's settings is correct. Try running `claude --version` in your terminal to verify it's working.
**Still having trouble?** We're actively improving this integration. Report issues on our [GitHub](https://github.com/cline/cline/issues) or ask for help in our [Discord](https://discord.gg/cline).
## Usage with subscriptions
If you have a Claude Max subscription, your usage in Cline shows up as $0.00 in the billing interface since you're not paying additional API costs. Your usage still counts against your subscription limits, but you won't see per-token charges.
For more details about using Claude Code with your subscription, check out Anthropic's documentation:
- [Claude Code Setup Guide](https://docs.anthropic.com/en/docs/claude-code/setup)
- [Using Claude Code with Pro/Max Plans](https://support.anthropic.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan)
@@ -0,0 +1,74 @@
const { RuleTester: VscodeRuleTester } = require("eslint")
const vscodePostmessageRule = require("../no-vscode-postmessage")
const vscodeRuleTester = new VscodeRuleTester({
parser: require.resolve("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
})
vscodeRuleTester.run("no-vscode-postmessage", vscodePostmessageRule, {
valid: [
// Should allow vscode.postMessage in grpc-client-base.ts
{
code: `vscode.postMessage({ type: "grpc_request", data: {} })`,
filename: "grpc-client-base.ts",
},
{
code: `vscode.postMessage({ type: "grpc_request_cancel" })`,
filename: "/path/to/grpc-client-base.ts",
},
// Should allow other vscode API calls
{
code: `vscode.window.showInformationMessage("Hello")`,
filename: "test.ts",
},
// Should allow postMessage calls on other objects
{
code: `window.postMessage({ type: "test" }, "*")`,
filename: "test.ts",
},
// Should allow variables named vscode but not calling postMessage
{
code: `const vscode = { other: "method" }; vscode.other()`,
filename: "test.ts",
},
],
invalid: [
// Should ban vscode.postMessage in regular files
{
code: `vscode.postMessage({ type: "test", data: {} })`,
filename: "test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should ban vscode.postMessage in components
{
code: `vscode.postMessage({ type: "apiConfiguration", apiConfiguration })`,
filename: "ApiOptions.tsx",
errors: [
{
messageId: "useGrpcClient",
},
],
},
// Should ban vscode.postMessage in test files
{
code: `vscode.postMessage({ type: "newTask", text: message.text })`,
filename: "test.test.ts",
errors: [
{
messageId: "useGrpcClient",
},
],
},
],
})
+3
View File
@@ -1,11 +1,13 @@
// eslint-rules/index.js
const noProtobufObjectLiterals = require("./no-protobuf-object-literals")
const noGrpcClientObjectLiterals = require("./no-grpc-client-object-literals")
const noVscodePostmessage = require("./no-vscode-postmessage")
module.exports = {
rules: {
"no-protobuf-object-literals": noProtobufObjectLiterals,
"no-grpc-client-object-literals": noGrpcClientObjectLiterals,
"no-vscode-postmessage": noVscodePostmessage,
},
configs: {
recommended: {
@@ -13,6 +15,7 @@ module.exports = {
rules: {
"local/no-protobuf-object-literals": "error",
"local/no-grpc-client-object-literals": "error",
"local/no-vscode-postmessage": "error",
},
},
},
+61
View File
@@ -0,0 +1,61 @@
const { ESLintUtils } = require("@typescript-eslint/utils")
const path = require("path")
const createRule = ESLintUtils.RuleCreator((name) => `https://cline.bot/eslint-rules/${name}`)
module.exports = createRule({
name: "no-vscode-postmessage",
meta: {
type: "problem",
docs: {
description: "Ban vscode.postMessage() calls in favor of gRPC service clients, except in grpc-client-base.ts",
recommended: "error",
},
messages: {
useGrpcClient:
"Use gRPC service clients instead of vscode.postMessage().\n" +
"Example: AccountServiceClient.methodName(RequestType.create({...})) instead of vscode.postMessage({type: '...'}).\n" +
"Found: {{code}}",
},
schema: [],
},
defaultOptions: [],
create(context) {
// Check if current file is grpc-client-base.ts (exception case)
const filename = context.filename
const isGrpcClientBase = path.basename(filename) === "grpc-client-base.ts"
return {
// Detect vscode.postMessage calls
"CallExpression[callee.type='MemberExpression']"(node) {
// Skip if this is grpc-client-base.ts
if (isGrpcClientBase) {
return
}
const callee = node.callee
// Check for vscode.postMessage pattern
if (
callee.object &&
callee.object.type === "Identifier" &&
callee.object.name === "vscode" &&
callee.property &&
callee.property.name === "postMessage"
) {
const sourceCode = context.sourceCode
const callText = sourceCode.getText(node).trim()
context.report({
node,
messageId: "useGrpcClient",
data: {
code: callText,
},
})
}
},
}
},
})
+19 -1
View File
@@ -2,5 +2,23 @@ repositories
results/evals.db
diff-edits/cases/
diff-edits/results/
# Environment variables
.env
# backwards compatible
diff_editing/test_cases/
diff_editing/test_outputs/
diff_editing/test_outputs/
*.db
*.db-wal
*.db-shm
.cache
# Python bytecode cache
*__pycache__/
diff-edits/cases.zip
+193
View File
@@ -17,6 +17,7 @@ The evaluation system consists of two main components:
1. **Test Server**: Enhanced HTTP server in `src/services/test/TestServer.ts` that provides detailed task results
2. **CLI Tool**: Command-line interface in `evals/cli/` for orchestrating evaluations
3. **Diff Edit Benchmark**: Separate command using the CLI tool that runs a comprehensive diff editing benchmark suite on real world cases, along with a streamlit dashboard displaying the results. For more details, see the [Diff Edit Benchmark README](./diff-edits/README.md). Make sure you add a `evals/diff-edits/cases` folder with all the conversation jsons.
## Directory Structure
@@ -40,6 +41,12 @@ cline-repo/
│ │ │ └── utils/ # Utility functions
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── diff-edits/ # Diff editing evaluation suite
│ │ ├── cases/ # Test case JSON files
│ │ ├── results/ # Evaluation results
│ │ ├── diff-apply/ # Diff application logic
│ │ ├── parsing/ # Assistant message parsing
│ │ └── prompts/ # System prompts
│ ├── repositories/ # Cloned benchmark repositories
│ │ ├── exercism/ # Modified Exercism (from pashpashpash/evals)
│ │ ├── swe-bench/ # SWE-Bench repository
@@ -148,6 +155,192 @@ Freelance-style programming tasks from the SWELancer benchmark.
Multi-file software engineering tasks from the Multi-SWE-Bench repository.
## Diff Edit Evaluations
The Cline Evaluation System includes a specialized suite for evaluating how well models can make precise edits to files using the `replace_in_file` tool.
### Overview
Diff edit evaluations test a model's ability to:
1. Understand file content and identify specific sections to modify
2. Generate correct SEARCH/REPLACE blocks for targeted edits
3. Successfully apply changes without introducing errors
### Directory Structure
```
diff-edits/
├── cases/ # Test case JSON files
├── results/ # Evaluation results
├── ClineWrapper.ts # Wrapper for model interaction
├── TestRunner.ts # Main test execution logic
├── types.ts # Type definitions
├── diff-apply/ # Diff application logic
├── parsing/ # Assistant message parsing
└── prompts/ # System prompts
```
### Creating Test Cases
Test cases are defined as JSON files in the `diff-edits/cases/` directory. Each test case should include:
```json
{
"test_id": "example_test_1",
"messages": [
{
"role": "user",
"text": "Please fix the bug in this code...",
"images": []
},
{
"role": "assistant",
"text": "I'll help you fix that bug..."
}
],
"file_contents": "// Original file content here\nfunction example() {\n // Code with bug\n}",
"file_path": "src/example.js",
"system_prompt_details": {
"mcp_string": "",
"cwd_value": "/path/to/working/directory",
"browser_use": false,
"width": 900,
"height": 600,
"os_value": "macOS",
"shell_value": "/bin/zsh",
"home_value": "/Users/username",
"user_custom_instructions": ""
},
"original_diff_edit_tool_call_message": ""
}
```
### Running Diff Edit Evaluations
#### Single Model Evaluation
```bash
cd evals/cli
node dist/index.js run-diff-eval --model-ids "anthropic/claude-3-5-sonnet-20241022"
```
#### Multi-Model Evaluation
Compare multiple models in a single evaluation run:
```bash
# Compare Claude and Grok models
node dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,x-ai/grok-beta" \
--max-cases 10 \
--valid-attempts-per-case 3 \
--verbose
# Compare multiple Claude variants
node dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,anthropic/claude-3-5-haiku-20241022,anthropic/claude-3-opus-20240229" \
--max-cases 5 \
--valid-attempts-per-case 2 \
--parallel
```
#### Options
- `--model-ids`: Comma-separated list of model IDs to evaluate (required)
- `--system-prompt-name`: System prompt to use (default: "basicSystemPrompt")
- `--valid-attempts-per-case`: Number of attempts per test case per model (default: 1)
- `--max-cases`: Maximum number of test cases to run (default: all available)
- `--parsing-function`: Function to parse assistant messages (default: "parseAssistantMessageV2")
- `--diff-edit-function`: Function to apply diffs (default: "constructNewFileContentV2")
- `--test-path`: Path to test cases (default: diff-edits/cases)
- `--thinking-budget`: Tokens allocated for thinking (default: 0)
- `--parallel`: Run tests in parallel (flag)
- `--replay`: Use pre-recorded LLM output (flag)
- `--verbose`: Enable detailed logging (flag)
#### Examples
```bash
# Quick test with 2 models, 4 cases, 2 attempts each
node dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,x-ai/grok-beta" \
--max-cases 4 \
--valid-attempts-per-case 2 \
--verbose
# Comprehensive evaluation with parallel execution
node dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,anthropic/claude-3-5-haiku-20241022" \
--system-prompt-name claude4SystemPrompt \
--valid-attempts-per-case 5 \
--max-cases 20 \
--parallel \
--verbose
```
### Database Storage & Analytics
All evaluation results are automatically stored in a SQLite database (`diff-edits/evals.db`) for advanced analytics and comparison. The database includes:
- **System Prompts**: Versioned system prompt content with hashing for deduplication
- **Processing Functions**: Versioned parsing and diff-edit function configurations
- **Files**: Original and edited file content with content-based hashing
- **Runs**: Evaluation run metadata and configuration
- **Cases**: Individual test case information with context tokens
- **Results**: Detailed results with timing, cost, and success metrics
### Interactive Dashboard
Launch the Streamlit dashboard to visualize and analyze evaluation results:
```bash
cd diff-edits/dashboard
streamlit run app.py
```
The dashboard provides:
- **Model Performance Comparison**: Side-by-side comparison of success rates, latency, and costs
- **Interactive Charts**: Success rate trends, latency vs cost analysis, and performance metrics
- **Detailed Drill-Down**: Individual result analysis with file content viewing
- **Run Selection**: Browse and compare different evaluation runs
- **Real-time Updates**: Automatically refreshes with new evaluation data
#### Dashboard Features
1. **Hero Section**: Overview of current run with key metrics
2. **Model Cards**: Performance cards with grades and detailed metrics
3. **Comparison Charts**: Interactive Plotly charts for visual analysis
4. **Result Explorer**: Detailed view of individual test results including:
- Original and edited file content
- Raw model output
- Parsed tool calls
- Timing and cost metrics
- Error analysis
#### Quick Start Dashboard
```bash
# Run a quick evaluation
node cli/dist/index.js run-diff-eval \
--model-ids "anthropic/claude-3-5-sonnet-20241022,x-ai/grok-beta" \
--max-cases 4 \
--valid-attempts-per-case 2 \
--verbose
# Launch dashboard to view results
cd diff-edits/dashboard && streamlit run app.py
```
### Legacy Results
For backward compatibility, results are also saved as JSON files in the `diff-edits/results/` directory. The JSON results include:
- Success/failure status
- Extracted tool calls
- Diff edit content
- Token usage and cost metrics
## Metrics
The evaluation system collects the following metrics:
-2456
View File
File diff suppressed because it is too large Load Diff
-39
View File
@@ -1,39 +0,0 @@
{
"name": "cline-evaluation-cli",
"version": "0.1.0",
"description": "CLI tool for orchestrating Cline evaluations across multiple benchmarks",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cline",
"evaluation",
"benchmark"
],
"author": "",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^11.10.0",
"chalk": "^4.1.2",
"commander": "^9.4.1",
"execa": "^5.1.1",
"node-fetch": "^2.7.0",
"ora": "^5.4.1",
"sqlite": "^4.1.2",
"uuid": "^9.0.0",
"yargs": "^17.6.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.3",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.12",
"@types/uuid": "^9.0.0",
"@types/yargs": "^17.0.19",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
}
}
+23 -8
View File
@@ -3,9 +3,9 @@ import chalk from "chalk"
import path from "path"
interface RunDiffEvalOptions {
modelId: string
modelIds: string
systemPromptName: string
numberOfRuns: number
validAttemptsPerCase: number
parsingFunction: string
diffEditFunction: string
thinkingBudget: number
@@ -14,22 +14,25 @@ interface RunDiffEvalOptions {
testPath: string
outputPath: string
replay: boolean
replayRunId?: string
diffApplyFile?: string
maxCases?: number
}
export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
console.log(chalk.blue("Starting diff editing evaluation..."))
// Resolve the path to the TestRunner.ts script relative to the current file
const scriptPath = path.resolve(__dirname, "../../../diff_editing/TestRunner.ts")
const scriptPath = path.resolve(__dirname, "../../../diff-edits/TestRunner.ts")
// Construct the arguments array for the execa call
const args = [
"--model-id",
options.modelId,
"--model-ids",
options.modelIds,
"--system-prompt-name",
options.systemPromptName,
"--number-of-runs",
String(options.numberOfRuns),
"--valid-attempts-per-case",
String(options.validAttemptsPerCase),
"--parsing-function",
options.parsingFunction,
"--diff-edit-function",
@@ -55,16 +58,28 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
args.push("--replay")
}
if (options.replayRunId) {
args.push("--replay-run-id", options.replayRunId)
}
if (options.diffApplyFile) {
args.push("--diff-apply-file", options.diffApplyFile)
}
if (options.verbose) {
args.push("--verbose")
}
if (options.maxCases) {
args.push("--max-cases", String(options.maxCases))
}
try {
console.log(chalk.gray(`Executing: npx tsx ${scriptPath} ${args.join(" ")}`))
// Execute the script as a child process
// We use 'inherit' to stream the stdout/stderr directly to the user's terminal
const subprocess = execa("npx", ["tsx", scriptPath, ...args], {
const subprocess = execa("npx", ["tsx", "--tsconfig", path.resolve(__dirname, "../../../tsconfig.json"), scriptPath, ...args], {
stdio: "inherit",
})
+7 -4
View File
@@ -84,22 +84,25 @@ program
.description("Run the diff editing evaluation suite")
.option("--test-path <path>", "Path to the directory containing test case JSON files")
.option("--output-path <path>", "Path to the directory to save the test output JSON files")
.option("--model-id <model_id>", "The model ID to use for the test")
.option("--model-ids <model_ids>", "Comma-separated list of model IDs to test")
.option("--system-prompt-name <name>", "The name of the system prompt to use", "basicSystemPrompt")
.option("-n, --number-of-runs <number>", "Number of times to run each test case", "1")
.option("-n, --valid-attempts-per-case <number>", "Number of valid attempts per test case per model (will retry until this many valid attempts are collected)", "1")
.option("--max-cases <number>", "Maximum number of test cases to run (limits total cases loaded)")
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
.option("--diff-edit-function <name>", "The diff editing function to use", "constructNewFileContentV2")
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
.option("--parallel", "Run tests in parallel", false)
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
.option("--replay-run-id <run_id>", "The ID of the run to replay from the database")
.option("--diff-apply-file <filename>", "The name of the diff apply file to use for the replay")
.option("-v, --verbose", "Enable verbose logging", false)
.action(async (options) => {
try {
// The logic here simplifies slightly
const fullOptions = {
...options,
numberOfRuns: parseInt(options.numberOfRuns, 10),
validAttemptsPerCase: parseInt(options.validAttemptsPerCase, 10),
thinkingBudget: parseInt(options.thinkingBudget, 10),
maxCases: options.maxCases ? parseInt(options.maxCases, 10) : undefined,
}
await runDiffEvalHandler(fullOptions)
} catch (error) {
@@ -9,6 +9,7 @@ import {
AssistantMessageContent,
} from "./parsing/parse-assistant-message-06-06-25" // "../../src/core/assistant-message"
import { constructNewFileContent as constructNewFileContentV1, constructNewFileContentV2 } from "./diff-apply/diff-06-06-25"
import { constructNewFileContent as constructNewFileContentV2_1 } from "./diff-apply/diff-06-23-25"
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff" // this defaults to the new v1 when called
type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
@@ -21,6 +22,8 @@ const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
}
const diffEditingFunctions: Record<string, ConstructNewFileContentFn> = {
"diff-06-06-25": constructNewFileContentV2,
"diff-06-23-25": constructNewFileContentV2_1,
constructNewFileContentV1: constructNewFileContentV1,
constructNewFileContentV2: constructNewFileContentV2,
constructNewFileContentV3: constructNewFileContentV3, // position invariant diff
@@ -39,16 +42,22 @@ interface StreamResult {
cacheReadTokens: number
totalCost: number
}
timing?: {
timeToFirstTokenMs: number
timeToFirstEditMs?: number
totalRoundTripMs: number
}
}
/**
* Process the stream and return full response
* Process the stream and return full response with timing data
*/
async function processStream(
handler: OpenRouterHandler,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
): Promise<StreamResult> {
const startTime = Date.now()
const stream = handler.createMessage(systemPrompt, messages)
let assistantMessage = ""
@@ -58,12 +67,21 @@ async function processStream(
let cacheWriteTokens = 0
let cacheReadTokens = 0
let totalCost = 0
// Timing tracking
let timeToFirstTokenMs: number | null = null
let timeToFirstEditMs: number | null = null
for await (const chunk of stream) {
if (!chunk) {
continue
}
// Capture time to first token (any chunk type)
if (timeToFirstTokenMs === null) {
timeToFirstTokenMs = Date.now() - startTime
}
switch (chunk.type) {
case "usage":
inputTokens += chunk.inputTokens
@@ -79,10 +97,25 @@ async function processStream(
break
case "text":
assistantMessage += chunk.text
// Try to detect first tool call by parsing accumulated message
if (timeToFirstEditMs === null) {
try {
const parsed = parseAssistantMessageV2(assistantMessage)
const hasToolCall = parsed.some(block => block.type === "tool_use")
if (hasToolCall) {
timeToFirstEditMs = Date.now() - startTime
}
} catch {
// Parsing failed, continue accumulating
}
}
break
}
}
const totalRoundTripMs = Date.now() - startTime
return {
assistantMessage,
reasoningMessage,
@@ -93,6 +126,11 @@ async function processStream(
cacheReadTokens,
totalCost,
},
timing: {
timeToFirstTokenMs: timeToFirstTokenMs || 0,
timeToFirstEditMs: timeToFirstEditMs || undefined,
totalRoundTripMs,
},
}
}
@@ -116,6 +154,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
diffEditFunction,
thinkingBudgetTokens,
originalDiffEditToolCallMessage,
diffApplyFile,
} = input
const requiredParams = {
@@ -141,7 +180,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
}
const parseAssistantMessage = parsingFunctions[parsingFunction]
const constructNewFileContent = diffEditingFunctions[diffEditFunction]
const constructNewFileContent = diffEditingFunctions[diffApplyFile || diffEditFunction]
if (!parseAssistantMessage || !constructNewFileContent) {
return {
@@ -245,7 +284,22 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
}
// check that we are editing the correct file path
console.log(`Expected file path: "${originalFilePath}"`);
console.log(`Actual file path used: "${diffToolPath}"`);
if (diffToolPath !== originalFilePath) {
console.log(`❌ File path mismatch detected!`);
// Enhanced logging:
if (streamResult?.assistantMessage) {
console.log(` Full model output (assistantMessage):`);
console.log(` -----------------------------------------`);
console.log(` ${streamResult.assistantMessage}`);
console.log(` -----------------------------------------`);
}
if (toolCall) {
console.log(` Parsed tool call that caused mismatch:`);
console.log(` ${JSON.stringify(toolCall, null, 2)}`);
console.log(` -----------------------------------------`);
}
return {
success: false,
streamResult: streamResult,
+84
View File
@@ -0,0 +1,84 @@
# A Note on Cline's Diff Evaluation Setup
Hey there, this note explains what we're doing with Cline's diff evaluation (evals) system. It's all about checking how well various AI models (which users connect to Cline via their own API keys), prompts, and diffing tools can handle file changes.
## What We're Trying to Figure Out
The main idea here is to figure out which AI models (configured by users) are best at making `replace_in_file` tool calls that work correctly. This helps us understand model capabilities and also speeds up our own experiments with prompts and diffing algorithms to make Cline better over time. We want to know a few key things.
First, can the model create diffs, which are just sets of SEARCH and REPLACE blocks, that apply cleanly to a file? This is what we call `diffEditSuccess`.
Second, how do different LLMs, like Claude or Grok, stack up against each other when they try to make these diff edits? We use a standard set of real-world test cases for this.
Third, do different system prompts, say our `basicSystemPrompt` versus the `claude4SystemPrompt`, change how well a model does at diff editing?
Fourth, we're also looking at different ways to apply the diffs themselves. We have a few algorithms like `constructNewFileContentV1`, `V2`, and `V3`, and we want to see which ones are more robust when fed model-generated diffs.
Fifth, we track how fast the model starts making an edit. The `timeToFirstEditMs` metric gives us a hint about how quickly a user would see changes happening in their editor.
And finally, we keep an eye on how many tokens are used and what it costs for each model and each try. This helps us compare how efficient they are.
Right now, these evals are mostly about whether the diff *applies* correctly. That means, do the SEARCH blocks find a match, and can the REPLACE blocks be put in without an error? We're not yet deeply analyzing if the change is valid code or matches what the user *wanted* semantically. That's a problem for another day, and will require a lot more scaffolding.
## How We Run These Tests
Two prerequisites:
1. Make sure you have an `evals/.env` file with `OPENROUTER_API_KEY=<your-openrouter-key>`
2. Make sure you add a `evals/diff-edits/cases` folder with all the conversation jsons prior to running this.
Our testing strategy is based on replaying situations from actual user sessions where diff edits were tried.
It starts with our test cases. Each one is a JSON file in `./cases` that has the conversation history that led to a diff edit, the original file content and its path, and the info needed to rebuild the system prompt from that original session.
Then, for every test run, we set up a specific configuration. This includes which LLM we're testing, which system prompt it gets, which function we use to parse the model's raw output, and which function we use to actually apply the diff. Here's the command I've been using:
```bash
npm run diff-eval -- --model-ids "anthropic/claude-3-5-sonnet-20241022,x-ai/grok-3-beta" --max-cases 4 --valid-attempts-per-case 2 --verbose --parallel
```
This will build the eval script, run it, and then open the streamlit dashboard to show the results.
The `TestRunner.ts` script is the main coordinator. For each test case and setup, `ClineWrapper.ts` takes over and sends the conversation and system prompt to the LLM. We then watch the model's response as it streams in and parse it to find any tool calls.
We're specifically looking for the model to make a single `replace_in_file` tool call. Multiple edits in one tool call are allowed, and recorded (in case you want to filter results by number of edits in a single tool call and compare success rate for that slice across different models/system prompts/etc). If it does, and it's for the correct file, we grab the diff content it produced. Then, the chosen diff application algorithm tries to apply that diff to the original file. We record whether this worked or not as `diffEditSuccess`.
We record a bunch of data for every attempt into a database. This includes details about the model and prompt, token counts, costs, the raw output from the model, the parsed tool calls, whether it succeeded or failed, any error messages, and timing info. For a detailed explanation of the database schema, see [database.md](./database.md).
A big part of this is how we handle "valid attempts," which I'll explain next.
## Keeping it Fair with "Valid Attempts"
LLMs can be unpredictable. If we replay an old scenario, a new model, or even the same model later, might do something completely different than what happened originally. It might call another tool or ask a question instead of trying a diff edit.
Since we really want to test the *diff editing* part, we need a way to make sure we're comparing fairly. That's why we have this idea of "valid attempts."
An attempt is "valid" for this benchmark if the model actually tries to do what we're interested in. This means two things. One, it must call the `replace_in_file` tool. Two, it must target the *same file path* that was targeted in the original recorded conversation for that test case.
If the model does something else, like calling a different tool or picking the wrong file, we don't count that attempt against its diff editing score. Instead, we consider it an "invalid attempt" for *this specific benchmark* and simply re-run that test case with that model. We keep doing this until we've collected a set number of these "valid attempts."
For example, if we ask for 5 valid attempts per test case, the system will keep re-rolling for that case until the model has tried to edit the correct file using the `replace_in_file` tool 5 times. Only then do we look at how many of those 5 valid attempts actually resulted in a successful diff application (`diffEditSuccess`).
This way, if we're comparing two models and one gets a 10% success rate on its valid diff edit attempts, and another gets 90%, we have a much clearer picture of their actual diff-generating capabilities. It avoids muddying the waters with attempts where the model didn't even try to perform the specific action we're evaluating. This approach helps us isolate and measure the diff-editing skill more directly, despite the non-deterministic nature of these models.
## Replays
You can also use the replay argument to replay a previous benchmark run. This is super useful for iterating on our diffing algorithms without having to re-run expensive and time-consuming LLM calls.
When you run an evaluation, every detail is stored in the database—including the raw, unmodified output from the model. The replay feature takes advantage of this by pulling that raw output and feeding it into a *different* diffing algorithm. This lets you isolate the performance of the diffing logic itself. We can see if a new algorithm is better at applying the exact same set of diffs that a model generated in a previous run.
This process is blazingly fast and free, as it completely bypasses the need to make new API calls. It ensures a true apples-to-apples comparison between diffing strategies, since the model's output—the "ground truth" for the evaluation—remains identical.
Heres an example of how you would replay a previous run with a new diffing algorithm:
```shell
cd evals && npm run diff-eval -- --replay-run-id 9902189e-63a8-4210-a4fc-fe59e2eaf2c2 --diff-apply-file diff-06-23-25 --verbose
```
In this command:
- `--replay-run-id` specifies the original run we want to use as our ground truth.
- `--diff-apply-file` tells the script to use the new diffing logic from the `diff-06-23-25.ts` file.
The script will then create a new run in the database that mirrors the original, but with the results of applying the new diffing algorithm. This allows for a direct comparison in the dashboard, helping us quickly see which of our diffing strategies is the most robust.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
[theme]
base="dark"
[browser]
gatherUsageStats = false
[server]
headless = true
+159
View File
@@ -0,0 +1,159 @@
# 🚀 The Sickest Diff Edits Evaluation Dashboard Ever!
A beautiful, modern Streamlit dashboard for visualizing and analyzing diff editing evaluation results with deep drill-down capabilities.
## ✨ Features
### 🎯 **Smart Model Comparison**
- **Latest Run Focus**: Automatically loads and displays your most recent evaluation run
- **Beautiful Performance Cards**: Each model gets a stunning card with performance grades (A+ to C)
- **Best Performer Highlighting**: The top model gets special styling and a trophy 🏆
- **Interactive Charts**: Success rate comparisons and latency vs cost analysis
### 🔍 **Deep Drill-Down Analysis**
- **Individual Result Inspection**: Click any model to see detailed results
- **Side-by-Side File Views**: See original file content with line numbers
- **Parsed Tool Call Analysis**: View exactly what the model tried to do
- **Error Analysis**: Detailed error information for failed attempts
- **Success Metrics**: Line changes, edit counts, and timing breakdowns
### 🎨 **Aesthetic Design**
- **Modern UI**: Custom CSS with Inter font, gradients, and shadows
- **Responsive Layout**: Looks great on any screen size
- **Color-Coded Performance**: Green for excellent, yellow for good, red for poor
- **Smooth Animations**: Hover effects and transitions
- **Professional Styling**: Clean, modern design that looks amazing
### 📊 **Comprehensive Metrics**
- **Success Rates**: Color-coded percentages with performance grades
- **Timing Analysis**: First token, first edit, and round trip times
- **Cost Tracking**: Per-result and total cost analysis
- **Token Metrics**: Context tokens and completion tokens
- **Edit Statistics**: Number of edits, lines added/deleted
## 🚀 Quick Start
1. **Install dependencies**:
```bash
cd diff-edits/dashboard
pip install -r requirements.txt
```
2. **Launch the dashboard**:
```bash
streamlit run app.py
```
Or use the convenient launch script:
```bash
./launch.sh
```
3. **Open your browser** to http://localhost:8501
## 🎯 Dashboard Sections
### **Hero Section**
- Beautiful gradient header with run information
- Key metrics overview (models tested, total results, success rate, cost)
### **Model Performance Cards**
- Each model displayed as a beautiful card
- Large success rate display with color coding
- Performance grade badges (A+, A, B+, B, C+, C)
- Key metrics: latency, cost, results count, first token time
- "Drill Down" button for detailed analysis
### **Performance Analytics**
- Interactive bar chart showing success rates
- Scatter plot of latency vs cost with bubble sizes
- Hover details and zoom capabilities
### **Detailed Analysis (Drill-Down)**
- Model-specific success rate, latency, and cost metrics
- Individual result selector with status icons
- Tabbed interface for different views:
#### 📄 **File & Edits Tab**
- **Side-by-side view**: Original file content with line numbers
- **Edit analysis**: Success/failure status with detailed metrics
- **Error display**: Clear error information for failed attempts
- **Success metrics**: Lines added/deleted, number of edits
- **Parsed tool calls**: JSON view of what the model attempted
#### 🤖 **Raw Output Tab**
- Complete raw model output in a code viewer
- Monospace font for easy reading
#### 🔧 **Parsed Tool Call Tab**
- Pretty-printed JSON of parsed tool calls
- Diff block visualization for replace_in_file calls
- Error handling for malformed JSON
#### 📊 **Metrics Tab**
- Detailed timing metrics (first token, first edit, round trip)
- Token and cost information
- Context size and completion tokens
## 🛠 **Technical Features**
### **Smart Data Loading**
- Automatic latest run detection
- Efficient SQL queries with proper JOINs
- Streamlit caching for performance
- Error handling for missing data
### **Interactive Navigation**
- Session state management for drill-down views
- Back button to return to overview
- Smooth transitions between views
### **Beautiful Styling**
- Custom CSS with Google Fonts (Inter)
- Gradient backgrounds and shadows
- Hover effects and animations
- Color-coded performance indicators
- Professional card-based layout
### **Responsive Design**
- Works on desktop, tablet, and mobile
- Flexible column layouts
- Scalable text and metrics
## 🎨 **Design Philosophy**
This dashboard follows modern design principles:
- **Clarity**: Information is easy to find and understand
- **Beauty**: Visually appealing with professional styling
- **Functionality**: Deep drill-down capabilities for detailed analysis
- **Performance**: Fast loading with efficient data queries
- **Usability**: Intuitive navigation and clear visual hierarchy
## 📊 **Data Visualization**
- **Plotly Charts**: Interactive, professional-looking visualizations
- **Color Coding**: Consistent color scheme for performance levels
- **Performance Badges**: A+ to C grading system
- **Status Icons**: ✅ for success, ❌ for failure
- **Metric Cards**: Clean, card-based metric display
## 🔧 **Customization**
The dashboard is highly customizable:
- **CSS Styling**: Easy to modify colors, fonts, and layouts
- **Performance Grades**: Adjustable thresholds for A/B/C grades
- **Metrics Display**: Add or remove metrics as needed
- **Chart Types**: Easily swap chart types or add new visualizations
## 🚀 **Future Enhancements**
Potential additions:
- **Historical Trends**: Compare performance across multiple runs
- **Export Functionality**: Download results as CSV/PDF
- **Real-time Updates**: Auto-refresh for ongoing evaluations
- **Custom Filters**: Filter by date range, model type, etc.
- **Comparison Mode**: Side-by-side model comparisons
---
**This is the sickest eval dashboard ever!** 🔥 It combines beautiful design with powerful analysis capabilities, making it easy to understand model performance at a glance while providing deep drill-down capabilities for detailed investigation.
+957
View File
@@ -0,0 +1,957 @@
import streamlit as st
import sqlite3
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import numpy as np
from datetime import datetime
import os
import json
import difflib
# import mimetypes # No longer needed here if guess_language_from_filepath handles it
from utils import get_database_connection, guess_language_from_filepath # Import from utils
# Page config
st.set_page_config(
page_title="Diff Edits Evaluation Dashboard",
page_icon="📊",
layout="wide",
initial_sidebar_state="expanded"
)
# Custom CSS for beautiful styling
st.markdown("""
<style>
/* Import Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Azeret+Mono:wght@400;700&display=swap');
/* Global Styles */
.main {
font-family: 'Azeret Mono', monospace;
}
/* Hero Section */
.hero-container {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem;
border-radius: 15px;
margin-bottom: 2rem;
color: white;
text-align: center;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.hero-subtitle {
font-size: 1.2rem;
font-weight: 300;
opacity: 0.9;
}
/* Model Performance Cards */
.model-card {
background: white;
border-radius: 15px;
padding: 1.5rem;
margin: 1rem 0;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
border: 1px solid rgba(255,255,255,0.2);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.model-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
}
.model-card.best-performer {
border: 2px solid #00D4AA;
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
}
.model-name {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
color: #1f2937;
}
.success-rate {
font-size: 3rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.success-rate.excellent { color: #10b981; }
.success-rate.good { color: #f59e0b; }
.success-rate.poor { color: #ef4444; }
.metric-row {
display: flex;
justify-content: space-between;
margin: 0.5rem 0;
padding: 0.5rem;
background: rgba(0,0,0,0.02);
border-radius: 8px;
}
.metric-label {
font-weight: 500;
color: #6b7280;
}
.metric-value {
font-weight: 600;
color: #1f2937;
}
/* Performance Badge */
.performance-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-weight: 600;
font-size: 0.875rem;
margin-left: 1rem;
}
.badge-a { background: #10b981; color: white; }
.badge-b { background: #f59e0b; color: white; }
.badge-c { background: #ef4444; color: white; }
/* Comparison Charts */
.chart-container {
background: white;
border-radius: 15px;
padding: 1.5rem;
margin: 1rem 0;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
/* Result Detail Modal */
.result-detail {
background: white;
border-radius: 15px;
padding: 2rem;
margin: 1rem 0;
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
}
.file-viewer {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
line-height: 1.5;
overflow-x: auto;
}
.diff-added {
background-color: #dcfce7;
color: #166534;
}
.diff-removed {
background-color: #fef2f2;
color: #dc2626;
}
.error-display {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
color: #dc2626;
font-family: monospace;
}
/* Sidebar Styling */
.sidebar .sidebar-content {
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
}
/* Custom Metrics */
.custom-metric {
text-align: center;
padding: 1rem;
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
margin: 0.5rem 0;
}
.custom-metric-value {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
}
.custom-metric-label {
font-size: 0.875rem;
color: #6b7280;
font-weight: 500;
margin-top: 0.25rem;
}
</style>
""", unsafe_allow_html=True)
# Enhanced data loading functions
@st.cache_data
def load_all_runs():
"""Load all evaluation runs"""
conn = get_database_connection()
query = """
SELECT run_id, description, created_at, system_prompt_hash
FROM runs
ORDER BY created_at DESC
"""
return pd.read_sql_query(query, conn)
@st.cache_data
def load_run_comparison(run_id):
"""Load a specific run with model comparison data"""
conn = get_database_connection()
# Get the run details
run_query = f"""
SELECT run_id, description, created_at, system_prompt_hash
FROM runs
WHERE run_id = '{run_id}'
"""
run_data = pd.read_sql_query(run_query, conn)
if run_data.empty:
return None, None
# Get model performance for this run
model_perf_query = f"""
SELECT
res.model_id,
COUNT(*) as total_results,
AVG(CASE WHEN res.succeeded THEN 1.0 ELSE 0.0 END) as success_rate,
AVG(res.cost_usd) as avg_cost,
SUM(res.cost_usd) as total_cost,
AVG(res.time_to_first_token_ms) as avg_first_token_ms,
AVG(res.time_to_first_edit_ms) as avg_first_edit_ms,
AVG(res.time_round_trip_ms) as avg_round_trip_ms,
AVG(res.completion_tokens) as avg_completion_tokens,
AVG(res.num_edits) as avg_num_edits,
MIN(res.time_round_trip_ms) as min_round_trip_ms,
MAX(res.time_round_trip_ms) as max_round_trip_ms
FROM results res
JOIN cases c ON res.case_id = c.case_id
WHERE c.run_id = '{run_id}'
AND (res.error_enum NOT IN (1, 6, 7) OR res.error_enum IS NULL) -- Exclude: no_tool_calls, wrong_tool_call, wrong_file_edited
GROUP BY res.model_id
ORDER BY success_rate DESC, avg_round_trip_ms ASC
"""
model_performance = pd.read_sql_query(model_perf_query, conn)
return run_data.iloc[0], model_performance
@st.cache_data
def load_latest_run_comparison():
"""Load the latest run with model comparison data"""
conn = get_database_connection()
# Get the latest run
latest_run_query = """
SELECT run_id, description, created_at, system_prompt_hash
FROM runs
ORDER BY created_at DESC
LIMIT 1
"""
latest_run = pd.read_sql_query(latest_run_query, conn)
if latest_run.empty:
return None, None
return load_run_comparison(latest_run.iloc[0]['run_id'])
@st.cache_data
def load_detailed_results(run_id, model_id=None, valid_only=False):
"""Load detailed results for drill-down analysis"""
conn = get_database_connection()
where_clause = f"WHERE c.run_id = '{run_id}'"
if model_id:
where_clause += f" AND res.model_id = '{model_id}'"
# Option to filter out invalid attempts
if valid_only:
where_clause += " AND (res.error_enum NOT IN (1, 6, 7) OR res.error_enum IS NULL)"
query = f"""
SELECT
res.*,
c.task_id,
c.description as case_description,
c.tokens_in_context,
sp.name as system_prompt_name,
pf.name as processing_functions_name,
orig_f.filepath as original_filepath,
orig_f.content as original_file_content,
edit_f.filepath as edited_filepath,
edit_f.content as edited_file_content
FROM results res
JOIN cases c ON res.case_id = c.case_id
LEFT JOIN system_prompts sp ON c.system_prompt_hash = sp.hash
LEFT JOIN processing_functions pf ON res.processing_functions_hash = pf.hash
LEFT JOIN files orig_f ON c.file_hash = orig_f.hash
LEFT JOIN files edit_f ON res.file_edited_hash = edit_f.hash
{where_clause}
ORDER BY res.created_at DESC
"""
return pd.read_sql_query(query, conn)
def get_performance_grade(success_rate):
"""Get performance grade based on success rate"""
if success_rate >= 0.9:
return "A+", "excellent"
elif success_rate >= 0.8:
return "A", "excellent"
elif success_rate >= 0.7:
return "B+", "good"
elif success_rate >= 0.6:
return "B", "good"
elif success_rate >= 0.5:
return "C+", "good"
else:
return "C", "poor"
def render_hero_section(current_run, model_performance):
"""Render the hero section with key metrics"""
run_title = current_run['description'] if current_run['description'] else f"Run {current_run['run_id'][:8]}..."
st.markdown(f"""
<div class="hero-container">
<div class="hero-title">Diff Edit Evaluation Results</div>
<div class="hero-subtitle">A comprehensive analysis of model performance on code editing tasks.</div>
<div class="hero-subtitle" style="font-size: 0.9rem; margin-top: 10px;">
<strong>Current Run:</strong> {run_title}{current_run['created_at']}
</div>
</div>
""", unsafe_allow_html=True)
# Key metrics row
col1, col2, col3, col4 = st.columns(4)
total_results = model_performance['total_results'].sum()
overall_success = model_performance['success_rate'].mean()
total_cost = model_performance['total_cost'].sum()
avg_latency = model_performance['avg_round_trip_ms'].mean()
with col1:
st.markdown(f"""
<div class="custom-metric">
<div class="custom-metric-value">{len(model_performance)}</div>
<div class="custom-metric-label">Models Tested</div>
</div>
""", unsafe_allow_html=True)
with col2:
st.markdown(f"""
<div class="custom-metric">
<div class="custom-metric-value">{total_results}</div>
<div class="custom-metric-label">Valid Results</div>
</div>
""", unsafe_allow_html=True)
with col3:
success_color = "#10b981" if overall_success > 0.8 else "#f59e0b" if overall_success > 0.6 else "#ef4444"
st.markdown(f"""
<div class="custom-metric">
<div class="custom-metric-value" style="color: {success_color}">{overall_success:.1%}</div>
<div class="custom-metric-label">Avg Success Rate</div>
</div>
""", unsafe_allow_html=True)
with col4:
st.markdown(f"""
<div class="custom-metric">
<div class="custom-metric-value">${total_cost:.3f}</div>
<div class="custom-metric-label">Total Cost</div>
</div>
""", unsafe_allow_html=True)
def render_model_comparison_cards(model_performance):
"""Render beautiful model comparison cards"""
st.markdown("## Model Leaderboard")
# Find best performer
best_model = model_performance.iloc[0]['model_id']
for idx, model in model_performance.iterrows():
is_best = model['model_id'] == best_model
grade, grade_class = get_performance_grade(model['success_rate'])
# Create a container for each model
with st.container():
col1, col2 = st.columns([3, 1])
with col1:
# Use Streamlit's native components instead of raw HTML
if is_best:
st.success(f"**{model['model_id']}** - Best Performer")
else:
st.info(f"**{model['model_id']}**")
# Success rate with color coding
success_rate = model['success_rate']
if success_rate >= 0.8:
st.success(f"**Success Rate:** {success_rate:.1%} ({grade})")
elif success_rate >= 0.6:
st.warning(f"**Success Rate:** {success_rate:.1%} ({grade})")
else:
st.error(f"**Success Rate:** {success_rate:.1%} ({grade})")
# Metrics in columns
metric_col1, metric_col2, metric_col3, metric_col4 = st.columns(4)
with metric_col1:
if pd.notna(model['avg_round_trip_ms']):
st.metric("Avg Latency", f"{model['avg_round_trip_ms']:.0f}ms")
else:
st.metric("Avg Latency", "N/A")
with metric_col2:
if pd.notna(model['avg_cost']):
st.metric("Avg Cost", f"${model['avg_cost']:.4f}")
else:
st.metric("Avg Cost", "N/A")
with metric_col3:
st.metric("Valid Results", f"{model['total_results']}")
with metric_col4:
if pd.notna(model['avg_first_token_ms']):
st.metric("First Token", f"{model['avg_first_token_ms']:.0f}ms")
else:
st.metric("First Token", "N/A")
with col2:
st.write("") # Add some spacing
if st.button(f"Drill Down", key=f"drill_{model['model_id']}", use_container_width=True):
st.session_state.drill_down_model = model['model_id']
st.divider() # Add a divider between models
def render_comparison_charts(model_performance):
"""Render interactive comparison charts"""
st.markdown("## Performance Analysis")
col1, col2 = st.columns(2)
with col1:
# Time to First Edit
fig_first_edit = px.bar(
model_performance,
x='model_id',
y='avg_first_edit_ms',
title="Time to First Edit",
labels={'avg_first_edit_ms': 'Time to First Edit (ms)', 'model_id': 'Model'},
color='avg_first_edit_ms',
color_continuous_scale='bluered',
text='avg_first_edit_ms',
template='plotly_dark'
)
fig_first_edit.update_traces(texttemplate='%{text:.0f}ms', textposition='outside')
fig_first_edit.update_layout(
showlegend=False,
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)',
font=dict(family="Azeret Mono, monospace"),
margin=dict(t=50)
)
st.plotly_chart(fig_first_edit, use_container_width=True)
with col2:
# Latency vs Cost Scatter
fig_scatter = px.scatter(
model_performance,
x='avg_round_trip_ms',
y='avg_cost',
size='total_results',
color='success_rate',
hover_name='model_id',
title="Latency vs Cost Analysis",
labels={
'avg_round_trip_ms': 'Avg Round Trip (ms)',
'avg_cost': 'Avg Cost ($)',
'success_rate': 'Success Rate',
'total_results': 'Valid Results'
},
color_continuous_scale='RdYlGn',
template='plotly_dark'
)
fig_scatter.update_layout(
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)',
font=dict(family="Azeret Mono, monospace")
)
st.plotly_chart(fig_scatter, use_container_width=True)
def render_detailed_analysis(run_id, model_id):
"""Render detailed drill-down analysis"""
st.markdown(f"## Detailed Analysis: {model_id}")
# Load all results (including invalid attempts)
detailed_results = load_detailed_results(run_id, model_id)
# Also load only valid results for metrics
valid_results = load_detailed_results(run_id, model_id, valid_only=True)
if detailed_results.empty:
st.warning("No detailed results found.")
return
# Show total vs valid results
st.info(f"Showing all {len(detailed_results)} results ({len(valid_results)} valid, {len(detailed_results) - len(valid_results)} invalid)")
# Results overview
col1, col2, col3 = st.columns(3)
with col1:
success_count = valid_results['succeeded'].sum()
total_count = len(valid_results)
st.metric("Success Rate", f"{success_count}/{total_count} ({success_count/total_count:.1%} of valid results)")
with col2:
avg_latency = detailed_results['time_round_trip_ms'].mean()
st.metric("Avg Latency", f"{avg_latency:.0f}ms")
with col3:
total_cost = detailed_results['cost_usd'].sum()
st.metric("Total Cost", f"${total_cost:.4f}")
# Interactive results table
st.markdown("### 📋 Individual Results")
# Add result selector with indicators for valid/invalid attempts
result_options = []
for idx, row in detailed_results.iterrows():
# Check if this is a valid result
is_valid = (row['error_enum'] not in [1, 6, 7]) if not pd.isna(row['error_enum']) else True
# Create status indicator
if is_valid:
status = "" if row['succeeded'] else ""
else:
status = "⚠️" # Warning symbol for invalid results
# Add validity indicator to the option text
validity_text = "" if is_valid else " [INVALID RESULT]"
result_options.append(f"{status} {row['task_id']} - {row['time_round_trip_ms']:.0f}ms{validity_text}")
selected_result_idx = st.selectbox(
"Select a result to analyze:",
range(len(result_options)),
format_func=lambda x: result_options[x]
)
if selected_result_idx is not None:
render_result_detail(detailed_results.iloc[selected_result_idx])
def render_result_detail(result):
"""Render detailed view of a single result"""
st.markdown("### 🔬 Result Deep Dive")
# Check if this is a valid result
is_valid = (result['error_enum'] not in [1, 6, 7]) if not pd.isna(result['error_enum']) else True
# Show validity warning if needed
if not is_valid:
st.warning("⚠️ **This is an invalid result** - The model didn't properly call the diff edit tool or edited the wrong file. This result is excluded from success rate calculations.")
# Result metadata
col1, col2, col3, col4 = st.columns(4)
with col1:
status_icon = "" if result['succeeded'] else ""
st.markdown(f"**Status:** {status_icon} {'Success' if result['succeeded'] else 'Failed'}")
with col2:
st.markdown(f"**Task ID:** {result['task_id']}")
with col3:
st.markdown(f"**Round Trip:** {result['time_round_trip_ms']:.0f}ms")
with col4:
st.markdown(f"**Cost:** ${result['cost_usd']:.4f}")
# Tabbed interface for different views
tab1, tab2, tab3, tab4 = st.tabs(["📄 File & Edits", "🤖 Raw Output", "🔧 Parsed Tool Call", "📊 Metrics"])
with tab1:
render_file_and_edits_view(result)
with tab2:
render_raw_output_view(result)
with tab3:
render_parsed_tool_call_view(result)
with tab4:
render_metrics_view(result)
def render_file_and_edits_view(result):
"""Render side-by-side file and edits view"""
st.markdown("#### 📄 File Content & Edit Analysis")
# Check if we have original file content
has_original = not pd.isna(result['original_file_content']) and result['original_file_content']
has_edited = not pd.isna(result['edited_file_content']) and result['edited_file_content']
if not has_original and not has_edited:
st.warning("No file content available for this result.")
return
col1, col2 = st.columns(2)
with col1:
st.markdown("**Original File:**")
if has_original:
filepath = result['original_filepath'] if not pd.isna(result['original_filepath']) else 'Unknown file'
st.markdown(f"📁 `{filepath}`")
# Display full original file content in a scrollable code block
with st.expander("View Original File Content", expanded=True):
# Prepare content for the copy button (needs JS-specific escaping)
raw_content_for_copy = result['original_file_content']
# Escape for JavaScript template literal: backticks, backslashes, newlines
js_escaped_content = raw_content_for_copy.replace('\\', '\\\\') \
.replace('`', '\\`') \
.replace('\r\n', '\\n') \
.replace('\n', '\\n') \
.replace('\r', '\\n')
unique_suffix = str(result.name if hasattr(result, 'name') else result['task_id']).replace('-', '_').replace('.', '_')
button_id = f"copyBtnOriginal_{unique_suffix}"
copy_button_html = f"""
<button id="{button_id}" onclick="copyOriginalToClipboard(`{js_escaped_content}`, '{button_id}')" style="margin-bottom: 10px; padding: 5px 10px; border-radius: 5px; border: 1px solid #ccc; cursor: pointer;">Copy Original File</button>
<script>
if (!window.copyOriginalToClipboard) {{
window.copyOriginalToClipboard = async function(text, buttonId) {{
try {{
await navigator.clipboard.writeText(text);
const button = document.getElementById(buttonId);
button.innerText = 'Copied!';
button.style.backgroundColor = '#d4edda'; // Optional: success feedback
setTimeout(() => {{
button.innerText = 'Copy Original File';
button.style.backgroundColor = '';
}}, 2000);
}} catch (err) {{
console.error('Failed to copy original: ', err);
const button = document.getElementById(buttonId);
button.innerText = 'Copy Failed!';
button.style.backgroundColor = '#f8d7da'; // Optional: error feedback
setTimeout(() => {{
button.innerText = 'Copy Original File';
button.style.backgroundColor = '';
}}, 2000);
}}
}}
}}
</script>
"""
st.components.v1.html(copy_button_html, height=50)
# Prepare content for st.code (needs actual newlines)
content_for_display = result['original_file_content']
# Iteratively replace common escaped newline sequences with actual newlines
# This handles cases like "\\n" -> "\n" and then "\n" (if it was literally "\n")
# Order might matter if there are multiple levels of escaping, but this covers common ones.
content_for_display = content_for_display.replace('\\\\r\\\\n', '\r\n').replace('\\\\n', '\n') # Double escaped
content_for_display = content_for_display.replace('\\r\\n', '\r\n').replace('\\n', '\n') # Single escaped
language = guess_language_from_filepath(filepath)
st.code(content_for_display, language=language, line_numbers=False)
else:
st.warning("Original file content not available")
with col2:
st.markdown("**Edit Analysis:**")
if not result['succeeded']:
# Show error information
st.error("❌ **Edit Failed**")
if not pd.isna(result['error_enum']):
st.markdown(f"**Error Code:** {result['error_enum']}")
else:
# Show successful edit information
st.success("✅ **Edit Successful**")
# Show edit metrics
metric_col1, metric_col2, metric_col3 = st.columns(3)
with metric_col1:
if not pd.isna(result['num_edits']):
st.metric("Edits", int(result['num_edits']))
with metric_col2:
if not pd.isna(result['num_lines_added']):
st.metric("Added", int(result['num_lines_added']))
with metric_col3:
if not pd.isna(result['num_lines_deleted']):
st.metric("Deleted", int(result['num_lines_deleted']))
# Show edited file if available
if has_edited:
st.markdown("**Edited File:**")
with st.expander("View Edited File Content"):
edited_lines = result['edited_file_content'].split('\n')
for i, line in enumerate(edited_lines[:50], 1):
st.text(f"{i:3d} | {line}")
if len(edited_lines) > 50:
st.text(f"... ({len(edited_lines) - 50} more lines)")
# Show parsed tool call if available
if not pd.isna(result['parsed_tool_call_json']):
with st.expander("View Parsed Tool Call"):
try:
parsed_call = json.loads(result['parsed_tool_call_json'])
st.json(parsed_call)
except:
st.text(result['parsed_tool_call_json'])
def render_raw_output_view(result):
"""Render raw model output"""
st.markdown("#### 🤖 Raw Model Output")
if pd.isna(result['raw_model_output']) or not result['raw_model_output']:
st.warning("No raw output available for this result.")
return
st.markdown("""
<div class="file-viewer">
""", unsafe_allow_html=True)
st.text(result['raw_model_output'])
st.markdown("</div>", unsafe_allow_html=True)
def render_parsed_tool_call_view(result):
"""Render parsed tool call analysis"""
st.markdown("#### 🔧 Parsed Tool Call Analysis")
if pd.isna(result['parsed_tool_call_json']) or not result['parsed_tool_call_json']:
st.warning("No parsed tool call available for this result.")
return
try:
parsed_call = json.loads(result['parsed_tool_call_json'])
# Pretty print the JSON
st.json(parsed_call)
# If it's a replace_in_file call, show the diff blocks
if isinstance(parsed_call, dict) and 'diff' in parsed_call:
st.markdown("**Diff Blocks:**")
st.code(parsed_call['diff'], language='diff')
except json.JSONDecodeError:
st.markdown("**Raw Parsed Call (Invalid JSON):**")
st.text(result['parsed_tool_call_json'])
def render_metrics_view(result):
"""Render detailed metrics for the result"""
st.markdown("#### 📊 Detailed Metrics")
col1, col2 = st.columns(2)
with col1:
st.markdown("**Timing Metrics:**")
if not pd.isna(result['time_to_first_token_ms']):
st.metric("Time to First Token", f"{result['time_to_first_token_ms']:.0f}ms")
if not pd.isna(result['time_to_first_edit_ms']):
st.metric("Time to First Edit", f"{result['time_to_first_edit_ms']:.0f}ms")
if not pd.isna(result['time_round_trip_ms']):
st.metric("Round Trip Time", f"{result['time_round_trip_ms']:.0f}ms")
with col2:
st.markdown("**Token & Cost Metrics:**")
if not pd.isna(result['completion_tokens']):
st.metric("Completion Tokens", int(result['completion_tokens']))
if not pd.isna(result['cost_usd']):
st.metric("Cost", f"${result['cost_usd']:.4f}")
if not pd.isna(result['tokens_in_context']):
st.metric("Context Tokens", int(result['tokens_in_context']))
def guess_language_from_filepath(filepath):
"""Guess the language for syntax highlighting from filepath."""
if not filepath or pd.isna(filepath):
return None
extension_map = {
'.py': 'python',
'.js': 'javascript',
'.ts': 'typescript',
'.java': 'java',
'.cs': 'csharp',
'.cpp': 'cpp',
'.c': 'c',
'.html': 'html',
'.css': 'css',
'.json': 'json',
'.sql': 'sql',
'.md': 'markdown',
'.rb': 'ruby',
'.php': 'php',
'.go': 'go',
'.rs': 'rust',
'.swift': 'swift',
'.kt': 'kotlin',
'.sh': 'bash',
'.yaml': 'yaml',
'.yml': 'yaml',
'.xml': 'xml',
}
_, ext = os.path.splitext(filepath)
def main():
# Add a note about valid attempts
st.sidebar.markdown("""
### Note on Metrics
Success rates are calculated based on **valid results only**.
Invalid results (where the model didn't call the diff edit tool or edited the wrong file) are excluded from calculations.
""")
# Initialize session state
if 'drill_down_model' not in st.session_state:
st.session_state.drill_down_model = None
if 'selected_run_id' not in st.session_state:
st.session_state.selected_run_id = None
# Load all runs for sidebar
all_runs = load_all_runs()
if all_runs.empty:
st.error("No evaluation runs found in the database.")
st.stop()
# Sidebar for run selection
with st.sidebar:
st.markdown("## 📊 Evaluation Runs")
st.markdown("Select a run to analyze:")
# Create run options with nice formatting
run_options = []
run_ids = []
for idx, run in all_runs.iterrows():
# Format the run description nicely
date_str = run['created_at'][:10] # Get just the date part
time_str = run['created_at'][11:16] # Get just the time part
if run['description']:
display_name = f"🚀 {run['description']}"
else:
display_name = f"📅 Run {run['run_id'][:8]}..."
run_options.append(f"{display_name}\n📅 {date_str} {time_str}")
run_ids.append(run['run_id'])
# Default to latest run if no selection
if st.session_state.selected_run_id is None:
default_index = 0 # Latest run is first
st.session_state.selected_run_id = run_ids[0]
else:
try:
default_index = run_ids.index(st.session_state.selected_run_id)
except ValueError:
default_index = 0
st.session_state.selected_run_id = run_ids[0]
selected_run_idx = st.selectbox(
"Choose run:",
range(len(run_options)),
format_func=lambda x: run_options[x],
index=default_index,
key="run_selector"
)
# Update selected run if changed
if run_ids[selected_run_idx] != st.session_state.selected_run_id:
st.session_state.selected_run_id = run_ids[selected_run_idx]
st.session_state.drill_down_model = None # Reset drill down when changing runs
st.rerun()
# Show run details in sidebar
selected_run = all_runs.iloc[selected_run_idx]
st.markdown("---")
st.markdown("### 📋 Run Details")
st.markdown(f"**Run ID:** `{selected_run['run_id'][:12]}...`")
st.markdown(f"**Created:** {selected_run['created_at']}")
if selected_run['description']:
st.markdown(f"**Description:** {selected_run['description']}")
# Load data for selected run
current_run, model_performance = load_run_comparison(st.session_state.selected_run_id)
if current_run is None or model_performance.empty:
st.error("No data found for the selected run.")
st.stop()
# Render main dashboard
render_hero_section(current_run, model_performance)
# Check if we're in drill-down mode
if st.session_state.drill_down_model:
col1, col2 = st.columns([1, 4])
with col1:
if st.button("Back to Overview", use_container_width=True):
st.session_state.drill_down_model = None
st.rerun()
render_detailed_analysis(current_run['run_id'], st.session_state.drill_down_model)
else:
# Success Rate Comparison
fig_success = px.bar(
model_performance,
x='model_id',
y='success_rate',
title="Success Rate by Model",
labels={'success_rate': 'Success Rate', 'model_id': 'Model'},
color='success_rate',
color_continuous_scale='RdYlGn',
text='success_rate',
template='plotly_dark'
)
fig_success.update_traces(texttemplate='%{text:.1%}', textposition='outside')
fig_success.update_layout(
showlegend=False,
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)',
font=dict(family="Azeret Mono, monospace"),
yaxis_range=[0,1], # Set y-axis from 0% to 100%
margin=dict(t=50) # Add top margin to prevent clipping
)
st.plotly_chart(fig_success, use_container_width=True)
render_model_comparison_cards(model_performance)
render_comparison_charts(model_performance)
if __name__ == "__main__":
main()
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Diff Edits Evaluation Dashboard Launcher
echo "🚀 Starting Diff Edits Evaluation Dashboard..."
# Check if we're in the right directory
if [ ! -f "app.py" ]; then
echo "❌ Error: app.py not found. Please run this script from the dashboard directory."
exit 1
fi
# Check if database exists
if [ ! -f "../evals.db" ]; then
echo "⚠️ Warning: Database file ../evals.db not found."
echo " Make sure you've run some evaluations first to populate the database."
echo " You can run: node ../cli/dist/index.js run-diff-eval --model-id anthropic/claude-sonnet-4 --max-cases 1"
echo ""
fi
# Check if requirements are installed
echo "📦 Checking Python dependencies..."
if ! python -c "import streamlit, plotly, pandas" 2>/dev/null; then
echo "📥 Installing required packages..."
pip install -r requirements.txt
fi
echo "🌐 Launching Streamlit dashboard..."
echo " Dashboard will open in your browser at http://localhost:8501"
echo " Press Ctrl+C to stop the dashboard"
echo ""
# Launch Streamlit
streamlit run app.py
@@ -0,0 +1,183 @@
import streamlit as st
import pandas as pd
import json
import os # Need to import os for load_case_raw_data
from utils import get_database_connection, guess_language_from_filepath # Absolute import
st.set_page_config(
page_title="Case Health Inspector",
page_icon="🧑‍⚕️",
layout="wide"
)
st.title("Case Health Inspector")
st.markdown("Identify test cases that are frequently problematic across different models and runs.")
@st.cache_data
def load_problematic_cases_summary():
conn = get_database_connection()
query = """
WITH case_attempts AS (
SELECT
c.task_id,
c.description AS case_description,
f_orig.filepath AS original_filepath, -- Get from files table
r.run_id,
r.model_id,
r.result_id,
(CASE WHEN (r.error_enum NOT IN (1, 6, 7) OR r.error_enum IS NULL) THEN 1 ELSE 0 END) AS is_valid_attempt,
(CASE WHEN (r.error_enum NOT IN (1, 6, 7) OR r.error_enum IS NULL) THEN r.succeeded ELSE NULL END) AS succeeded_on_valid
FROM cases c
JOIN results r ON c.case_id = r.case_id
LEFT JOIN files f_orig ON c.file_hash = f_orig.hash -- Join to get original filepath
),
case_summary AS (
SELECT
task_id,
case_description,
original_filepath, -- This is now f_orig.filepath
COUNT(DISTINCT run_id) AS num_benchmark_runs,
COUNT(result_id) AS total_attempts,
SUM(is_valid_attempt) AS total_valid_attempts,
SUM(succeeded_on_valid) AS total_successful_valid_attempts
FROM case_attempts
GROUP BY task_id, case_description, original_filepath -- original_filepath is f_orig.filepath
)
SELECT
task_id,
case_description,
original_filepath, -- This is f_orig.filepath from case_summary
num_benchmark_runs,
total_attempts,
total_valid_attempts,
CAST(total_valid_attempts AS REAL) * 100.0 / total_attempts AS percent_valid_attempts,
CASE
WHEN total_valid_attempts > 0 THEN CAST(total_successful_valid_attempts AS REAL) * 100.0 / total_valid_attempts
ELSE 0
END AS success_rate_on_valid
FROM case_summary
ORDER BY percent_valid_attempts ASC, success_rate_on_valid ASC;
"""
df = pd.read_sql_query(query, conn)
return df
@st.cache_data
def load_case_raw_data(task_id):
"""Loads the original JSON data for a given task_id."""
# This assumes test cases are stored in ../cases relative to this script's parent (dashboard)
# So, ../../cases from this script's location (pages/02_Bad_Cases.py)
# Correct path from this script (pages/02_Bad_Cases.py) to cases/
# os.path.dirname(__file__) -> pages
# os.path.join(..., '..') -> dashboard
# os.path.join(..., '..', '..') -> diff-edits
# os.path.join(..., '..', '..', 'cases') -> diff-edits/cases
cases_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'cases')
# The task_id is usually the filename without .json
# However, some task_ids might have suffixes or be different.
# We need a robust way to find the file. For now, assume task_id is filename base.
# This might need adjustment if task_id format varies significantly from filename.
# Try direct match first
potential_filename = f"{task_id}.json"
filepath = os.path.join(cases_dir, potential_filename)
if not os.path.exists(filepath):
# If direct match fails, list files and try to find one that starts with task_id
# This is a simple fallback, might need more robust matching if task_ids are complex
try:
for f_name in os.listdir(cases_dir):
if f_name.startswith(task_id) and f_name.endswith(".json"):
filepath = os.path.join(cases_dir, f_name)
break
else: # No break means no file found
return None # File not found
except FileNotFoundError:
return None # Cases directory itself not found
if not os.path.exists(filepath): # Check again after potential find
return None
try:
with open(filepath, 'r') as f:
return json.load(f)
except Exception as e:
st.error(f"Error loading case file {filepath}: {e}")
return None
def render_problematic_cases_page():
summary_df = load_problematic_cases_summary()
if summary_df.empty:
st.warning("No case summary data found. Run some evaluations first.")
return
st.markdown("### Cases Overview")
st.dataframe(summary_df.style.format({
"percent_valid_attempts": "{:.1f}%",
"success_rate_on_valid": "{:.1f}%"
}), use_container_width=True)
st.markdown("---")
st.markdown("### Case Drill Down")
selected_task_id = st.selectbox(
"Select a Case ID (task_id) to inspect:",
options=[""] + summary_df['task_id'].tolist() # Add a blank option
)
if selected_task_id:
case_data = summary_df[summary_df['task_id'] == selected_task_id].iloc[0]
st.subheader(f"Details for Case: {case_data['task_id']}")
st.markdown(f"**Description:** {case_data['case_description']}")
st.markdown(f"**Original Filepath:** `{case_data['original_filepath']}`")
raw_json_data = load_case_raw_data(selected_task_id)
if raw_json_data:
with st.expander("View Raw Case JSON Data", expanded=False):
st.json(raw_json_data)
if 'file_contents' in raw_json_data and raw_json_data['file_contents']:
with st.expander("View Original File Content (from Case JSON)", expanded=True):
# Prepare content for the copy button
raw_content_for_copy = raw_json_data['file_contents']
js_escaped_content = raw_content_for_copy.replace('\\', '\\\\') \
.replace('`', '\\`') \
.replace('\r\n', '\\n') \
.replace('\n', '\\n') \
.replace('\r', '\\n')
button_id = f"copyBtnCase_{selected_task_id.replace('-', '_').replace('.', '_')}"
copy_button_html = f"""
<button id="{button_id}" onclick="copyCaseContentToClipboard(`{js_escaped_content}`, '{button_id}')" style="margin-bottom: 10px; padding: 5px 10px; border-radius: 5px; border: 1px solid #ccc; cursor: pointer;">Copy File Content</button>
<script>
if (!window.copyCaseContentToClipboard) {{
window.copyCaseContentToClipboard = async function(text, buttonId) {{
try {{
await navigator.clipboard.writeText(text);
const button = document.getElementById(buttonId);
button.innerText = 'Copied!';
setTimeout(() => {{ button.innerText = 'Copy File Content'; }}, 2000);
}} catch (err) {{ console.error('Failed to copy: ', err); const button = document.getElementById(buttonId); button.innerText = 'Copy Failed!'; setTimeout(() => {{ button.innerText = 'Copy File Content'; }}, 2000); }}
}}
}}
</script>
"""
st.components.v1.html(copy_button_html, height=50)
# Prepare content for st.code
content_for_display = raw_json_data['file_contents']
content_for_display = content_for_display.replace('\\\\r\\\\n', '\r\n').replace('\\\\n', '\n')
content_for_display = content_for_display.replace('\\r\\n', '\r\n').replace('\\n', '\n')
language = guess_language_from_filepath(case_data['original_filepath'])
st.code(content_for_display, language=language, line_numbers=False)
else:
st.warning("Original file content not found in case JSON.")
else:
st.error(f"Could not load raw JSON data for case: {selected_task_id}")
# Placeholder for more detailed stats (per-model performance on this case, error breakdown)
st.markdown("*(Further per-model statistics and error breakdowns for this case can be added here.)*")
if __name__ == "__main__":
render_problematic_cases_page()
@@ -0,0 +1,4 @@
streamlit>=1.28.0
plotly>=5.17.0
pandas>=2.0.0
numpy>=1.24.0
+51
View File
@@ -0,0 +1,51 @@
import streamlit as st
import sqlite3
import pandas as pd
import os
@st.cache_resource
def get_database_connection():
# Assuming the script is run from the dashboard directory,
# evals.db is two levels up from there.
# __file__ is utils.py, its dirname is dashboard.
# os.path.dirname(__file__) -> dashboard/
# os.path.join(..., '..') -> diff-edits/
# os.path.join(..., '..', 'evals.db') -> diff-edits/evals.db
db_path = os.path.join(os.path.dirname(__file__), '..', 'evals.db')
if not os.path.exists(db_path):
st.error(f"Database not found. Expected at: {os.path.abspath(db_path)}")
st.stop()
return sqlite3.connect(db_path, check_same_thread=False)
def guess_language_from_filepath(filepath):
"""Guess the language for syntax highlighting from filepath."""
if not filepath or pd.isna(filepath):
return None
extension_map = {
'.py': 'python',
'.js': 'javascript',
'.ts': 'typescript',
'.java': 'java',
'.cs': 'csharp',
'.cpp': 'cpp',
'.c': 'c',
'.html': 'html',
'.css': 'css',
'.json': 'json',
'.sql': 'sql',
'.md': 'markdown',
'.rb': 'ruby',
'.php': 'php',
'.go': 'go',
'.rs': 'rust',
'.swift': 'swift',
'.kt': 'kotlin',
'.sh': 'bash',
'.yaml': 'yaml',
'.yml': 'yaml',
'.xml': 'xml',
}
_, ext = os.path.splitext(str(filepath)) # Ensure filepath is string
return extension_map.get(ext.lower(), None)
+96
View File
@@ -0,0 +1,96 @@
# Diff Edit Evaluation Database Schema
This document provides an overview of the SQLite database schema used for the diff edit evaluation suite. The database is designed to capture every aspect of the evaluation runs in a structured way, allowing for detailed, multi-dimensional analysis and ensuring full reproducibility of our findings.
## Data Model Overview
The database is composed of several interconnected tables that work together to provide a comprehensive picture of each evaluation. The core of the model revolves around `runs`, `cases`, and `results`.
### `runs`
A `run` represents a single, top-level execution of the evaluation script (e.g., one invocation of `npm run diff-eval`). It serves as the main container for a complete benchmark session.
- **Purpose**: To group all the results from a single benchmark execution, allowing for high-level comparison between different runs over time.
- **Key Columns**:
- `run_id`: A unique identifier for the entire run.
- `description`: A human-readable summary of the run's configuration (e.g., which models were tested, how many cases, etc.).
- `system_prompt_hash`: A foreign key that links this run to the specific system prompt that was used, ensuring we can track performance changes based on prompt modifications.
### `cases`
A `case` represents a single test scenario that is presented to a model. It corresponds to one of the JSON files in the `cases/` directory and links that static definition to a specific benchmark `run`.
- **Purpose**: To track the individual test scenarios within a given run.
- **Key Columns**:
- `case_id`: A unique identifier for the case *within* a specific run.
- `run_id`: A foreign key linking back to the parent `run`.
- `task_id`: The original, persistent identifier for the test case (typically from the JSON filename).
- `file_hash`: A foreign key linking to the original, un-edited file content for this case.
### `results`
This is the most granular and important table in the database. A `result` represents the outcome of a single attempt by a specific model on a specific case.
- **Purpose**: To store the detailed outcome of every single model attempt, providing the raw data for all quantitative and qualitative analysis.
- **Key Columns**:
- `result_id`: The primary key for the result.
- `run_id`, `case_id`, `model_id`, `processing_functions_hash`: A set of foreign keys that precisely situate this result within the context of a specific run, case, model, and set of helper functions.
- `succeeded`: A boolean indicating if the generated diff was applied successfully.
- `error_enum`: A numeric code representing the specific type of error if the attempt failed (e.g., `1` for `no_tool_calls`, `7` for `wrong_file_edited`).
- `num_edits`, `num_lines_deleted`, `num_lines_added`: Quantitative metrics about the structure of the generated diff.
- `time_to_first_token_ms`, `time_to_first_edit_ms`, `time_round_trip_ms`: High-precision timing data to measure model latency.
- `cost_usd`, `completion_tokens`: Cost and token usage metrics for efficiency analysis.
- `raw_model_output`, `file_edited_hash`, `parsed_tool_call_json`: The rich, qualitative data. This includes the model's full, raw response and the parsed tool calls, which are invaluable for debugging and understanding the model's reasoning.
---
## Supporting Tables
The following tables store versioned, deduplicated content to ensure data integrity and efficiency.
### `system_prompts`
- **Purpose**: Stores the versioned content of the system prompts used in evaluations.
- **Key Columns**:
- `hash`: A unique hash of the prompt's content, which acts as the primary key. This prevents duplicate storage of the same prompt.
- `name`: A human-readable name for the prompt (e.g., `basicSystemPrompt`, `claude4SystemPrompt`).
- `content`: The full text of the system prompt.
### `processing_functions`
- **Purpose**: Stores the versioned combinations of parsing and diff-editing functions.
- **Key Columns**:
- `hash`: A unique hash of the function combination name.
- `name`: A human-readable name (e.g., `parseV2-diffV2`).
- `parsing_function`: The name of the function used to parse the model's output.
- `diff_edit_function`: The name of the function used to apply the diff.
### `files`
- **Purpose**: Stores the content of all files involved in the tests, including the original source files and the diffs generated by the models.
- **Key Columns**:
- `hash`: A content-based hash of the file, ensuring that identical files are only stored once.
- `filepath`: The original path of the file.
- `content`: The full content of the file.
## The Bigger Picture
This relational schema provides a powerful foundation for sophisticated analysis. It moves beyond simple pass/fail metrics and allows us to explore the nuanced interactions between models, prompts, and the code they operate on. With this database, we can answer critical questions like:
- "How does prompt engineering affect not just success rate, but also latency and cost?"
- "Are certain models more prone to specific types of errors (e.g., hallucinating file paths vs. failing to call a tool)?"
- "Which of our internal diffing algorithms is the most robust against a wide range of model-generated edits?"
Ultimately, this data model enables us to move from simply *measuring* performance to truly *understanding* it, providing the insights needed to build more capable and reliable AI engineering systems.
---
## Viewing the Full Schema
To see the most up-to-date and detailed schema for the database, you can use the `sqlite3` command-line tool. From the `evals/diff-edits` directory, run the following command:
```bash
sqlite3 evals.db .schema
```
This will print the complete `CREATE TABLE` statements for all tables in the database, providing a definitive reference for the database structure.
+135
View File
@@ -0,0 +1,135 @@
import Database from 'better-sqlite3';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
export class DatabaseClient {
private static instance: DatabaseClient;
private db: Database.Database;
private dbPath: string;
private constructor() {
// Get database path from environment or use default
this.dbPath = process.env.DIFF_EVALS_DB_PATH || path.join(__dirname, '../evals.db');
// Ensure directory exists
const dbDir = path.dirname(this.dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// Initialize database connection
this.db = new Database(this.dbPath);
// Enable WAL mode for concurrent access
this.db.pragma('journal_mode = WAL');
// Enable foreign key constraints
this.db.pragma('foreign_keys = ON');
// Initialize schema if needed
this.initializeSchema();
}
static getInstance(): DatabaseClient {
if (!DatabaseClient.instance) {
DatabaseClient.instance = new DatabaseClient();
}
return DatabaseClient.instance;
}
private initializeSchema(): void {
// Check if tables exist by trying to query one of them
try {
this.db.prepare('SELECT COUNT(*) FROM system_prompts LIMIT 1').get();
// If we get here, tables exist
return;
} catch (error) {
// Tables don't exist, create them
console.log('Initializing database schema...');
this.createTables();
}
}
private createTables(): void {
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
// Execute the entire schema as one block
this.db.transaction(() => {
this.db.exec(schema);
})();
console.log('Database schema initialized successfully');
}
getDatabase(): Database.Database {
return this.db;
}
getDatabasePath(): string {
return this.dbPath;
}
// Utility method to generate SHA-256 hash
static generateHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex');
}
// Utility method to generate UUID-like ID
static generateId(): string {
return crypto.randomUUID();
}
// Transaction wrapper
transaction<T>(fn: () => T): T {
return this.db.transaction(fn)();
}
// Close database connection (for cleanup)
close(): void {
if (this.db) {
this.db.close();
}
}
// Get database info
getInfo(): { path: string; size: number; tables: string[] } {
const stats = fs.statSync(this.dbPath);
const tables = this.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all()
.map((row: any) => row.name);
return {
path: this.dbPath,
size: stats.size,
tables
};
}
// Vacuum database (cleanup and optimize)
vacuum(): void {
this.db.exec('VACUUM');
}
// Get database statistics
getStats(): { [tableName: string]: number } {
const tables = ['system_prompts', 'processing_functions', 'files', 'runs', 'cases', 'results'];
const stats: { [tableName: string]: number } = {};
for (const table of tables) {
try {
const result = this.db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get() as { count: number };
stats[table] = result.count;
} catch (error) {
stats[table] = 0;
}
}
return stats;
}
}
// Export singleton instance getter
export const getDatabase = () => DatabaseClient.getInstance();
+23
View File
@@ -0,0 +1,23 @@
// Main database module exports
export { DatabaseClient, getDatabase } from './client';
export * from './types';
export * from './operations';
export * from './queries';
// Re-export commonly used functions for convenience
export {
upsertSystemPrompt,
upsertProcessingFunctions,
upsertFile,
createBenchmarkRun,
createCase,
insertResult,
getRunStats
} from './operations';
export {
getSuccessRatesByModel,
getModelComparisons,
getDatabaseSummary,
getErrorDistribution
} from './queries';
+348
View File
@@ -0,0 +1,348 @@
import { DatabaseClient } from './client';
import {
SystemPrompt,
ProcessingFunctions,
FileRecord,
BenchmarkRun,
Case,
Result,
CreateSystemPromptInput,
CreateProcessingFunctionsInput,
CreateFileInput,
CreateBenchmarkRunInput,
CreateCaseInput,
CreateResultInput
} from './types';
const db = DatabaseClient.getInstance();
// System Prompts Operations
export async function upsertSystemPrompt(input: CreateSystemPromptInput): Promise<string> {
const hash = DatabaseClient.generateHash(input.content);
const stmt = db.getDatabase().prepare(`
INSERT OR IGNORE INTO system_prompts (hash, name, content)
VALUES (?, ?, ?)
`);
stmt.run(hash, input.name, input.content);
return hash;
}
export async function getSystemPromptByHash(hash: string): Promise<SystemPrompt | null> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM system_prompts WHERE hash = ?
`);
const result = stmt.get(hash) as SystemPrompt | undefined;
return result || null;
}
// Processing Functions Operations
export async function upsertProcessingFunctions(input: CreateProcessingFunctionsInput): Promise<string> {
const hash = DatabaseClient.generateHash(input.parsing_function + input.diff_edit_function);
const stmt = db.getDatabase().prepare(`
INSERT OR IGNORE INTO processing_functions (hash, name, parsing_function, diff_edit_function)
VALUES (?, ?, ?, ?)
`);
stmt.run(hash, input.name, input.parsing_function, input.diff_edit_function);
return hash;
}
export async function getProcessingFunctionsByHash(hash: string): Promise<ProcessingFunctions | null> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM processing_functions WHERE hash = ?
`);
const result = stmt.get(hash) as ProcessingFunctions | undefined;
return result || null;
}
// Files Operations
export async function upsertFile(input: CreateFileInput): Promise<string> {
const hash = DatabaseClient.generateHash(input.content);
const stmt = db.getDatabase().prepare(`
INSERT OR IGNORE INTO files (hash, filepath, content, tokens)
VALUES (?, ?, ?, ?)
`);
stmt.run(hash, input.filepath, input.content, input.tokens || null);
return hash;
}
export async function getFileByHash(hash: string): Promise<FileRecord | null> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM files WHERE hash = ?
`);
const result = stmt.get(hash) as FileRecord | undefined;
return result || null;
}
// Benchmark Runs Operations
export async function createBenchmarkRun(input: CreateBenchmarkRunInput): Promise<string> {
const runId = DatabaseClient.generateId();
const stmt = db.getDatabase().prepare(`
INSERT INTO runs (run_id, description, system_prompt_hash)
VALUES (?, ?, ?)
`);
stmt.run(runId, input.description || null, input.system_prompt_hash);
return runId;
}
export async function getBenchmarkRun(runId: string): Promise<BenchmarkRun | null> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM runs WHERE run_id = ?
`);
const result = stmt.get(runId) as BenchmarkRun | undefined;
return result || null;
}
export async function getAllBenchmarkRuns(): Promise<BenchmarkRun[]> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM runs ORDER BY created_at DESC
`);
return stmt.all() as BenchmarkRun[];
}
// Cases Operations
export async function createCase(input: CreateCaseInput): Promise<string> {
const caseId = DatabaseClient.generateId();
const stmt = db.getDatabase().prepare(`
INSERT INTO cases (case_id, run_id, description, system_prompt_hash, task_id, tokens_in_context, file_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
caseId,
input.run_id,
input.description,
input.system_prompt_hash,
input.task_id,
input.tokens_in_context,
input.file_hash || null
);
return caseId;
}
export async function getCasesByRun(runId: string): Promise<Case[]> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM cases WHERE run_id = ? ORDER BY created_at
`);
return stmt.all(runId) as Case[];
}
export async function getCaseById(caseId: string): Promise<Case | null> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM cases WHERE case_id = ?
`);
const result = stmt.get(caseId) as Case | undefined;
return result || null;
}
// Results Operations
export async function insertResult(input: CreateResultInput): Promise<string> {
const resultId = DatabaseClient.generateId();
const stmt = db.getDatabase().prepare(`
INSERT INTO results (
result_id, run_id, case_id, model_id, processing_functions_hash,
succeeded, error_enum, num_edits, num_lines_deleted, num_lines_added,
time_to_first_token_ms, time_to_first_edit_ms, time_round_trip_ms,
cost_usd, completion_tokens, raw_model_output, file_edited_hash,
parsed_tool_call_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
resultId,
input.run_id,
input.case_id,
input.model_id,
input.processing_functions_hash,
input.succeeded ? 1 : 0, // Convert boolean to integer
input.error_enum || null,
input.num_edits || null,
input.num_lines_deleted || null,
input.num_lines_added || null,
input.time_to_first_token_ms || null,
input.time_to_first_edit_ms || null,
input.time_round_trip_ms || null,
input.cost_usd || null,
input.completion_tokens || null,
input.raw_model_output || null,
input.file_edited_hash || null,
input.parsed_tool_call_json || null
);
return resultId;
}
export async function getResultsByRun(runId: string): Promise<Result[]> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM results WHERE run_id = ? ORDER BY created_at
`);
return stmt.all(runId) as Result[];
}
export async function getResultsByCase(caseId: string): Promise<Result[]> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM results WHERE case_id = ? ORDER BY created_at
`);
return stmt.all(caseId) as Result[];
}
export async function getResultById(resultId: string): Promise<Result | null> {
const stmt = db.getDatabase().prepare(`
SELECT * FROM results WHERE result_id = ?
`);
const result = stmt.get(resultId) as Result | undefined;
return result || null;
}
// Batch operations for performance
export async function insertResultsBatch(inputs: CreateResultInput[]): Promise<string[]> {
const stmt = db.getDatabase().prepare(`
INSERT INTO results (
result_id, run_id, case_id, model_id, processing_functions_hash,
succeeded, error_enum, num_edits, num_lines_deleted, num_lines_added,
time_to_first_token_ms, time_to_first_edit_ms, time_round_trip_ms,
cost_usd, completion_tokens, raw_model_output, file_edited_hash,
parsed_tool_call_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
return db.transaction(() => {
const resultIds: string[] = [];
for (const input of inputs) {
const resultId = DatabaseClient.generateId();
stmt.run(
resultId,
input.run_id,
input.case_id,
input.model_id,
input.processing_functions_hash,
input.succeeded ? 1 : 0, // Convert boolean to integer
input.error_enum || null,
input.num_edits || null,
input.num_lines_deleted || null,
input.num_lines_added || null,
input.time_to_first_token_ms || null,
input.time_to_first_edit_ms || null,
input.time_round_trip_ms || null,
input.cost_usd || null,
input.completion_tokens || null,
input.raw_model_output || null,
input.file_edited_hash || null,
input.parsed_tool_call_json || null
);
resultIds.push(resultId);
}
return resultIds;
});
}
export async function createCasesBatch(inputs: CreateCaseInput[]): Promise<string[]> {
const stmt = db.getDatabase().prepare(`
INSERT INTO cases (case_id, run_id, description, system_prompt_hash, task_id, tokens_in_context)
VALUES (?, ?, ?, ?, ?, ?)
`);
return db.transaction(() => {
const caseIds: string[] = [];
for (const input of inputs) {
const caseId = DatabaseClient.generateId();
stmt.run(
caseId,
input.run_id,
input.description,
input.system_prompt_hash,
input.task_id,
input.tokens_in_context
);
caseIds.push(caseId);
}
return caseIds;
});
}
// Utility functions
export async function getRunStats(runId: string): Promise<{
total_cases: number;
total_results: number;
success_rate: number;
avg_cost: number;
avg_latency: number;
}> {
const stmt = db.getDatabase().prepare(`
SELECT
COUNT(DISTINCT c.case_id) as total_cases,
COUNT(r.result_id) as total_results,
AVG(CASE WHEN r.succeeded THEN 1.0 ELSE 0.0 END) as success_rate,
AVG(r.cost_usd) as avg_cost,
AVG(r.time_round_trip_ms) as avg_latency
FROM cases c
LEFT JOIN results r ON c.case_id = r.case_id
WHERE c.run_id = ?
`);
const result = stmt.get(runId) as any;
return {
total_cases: result.total_cases || 0,
total_results: result.total_results || 0,
success_rate: result.success_rate || 0,
avg_cost: result.avg_cost || 0,
avg_latency: result.avg_latency || 0
};
}
// Count valid attempts for a specific case and model
export async function getValidAttemptCount(caseId: string, modelId: string): Promise<number> {
const stmt = db.getDatabase().prepare(`
SELECT COUNT(*) as count
FROM results
WHERE case_id = ?
AND model_id = ?
AND error_enum NOT IN (1, 6, 7) -- Exclude: no_tool_calls, wrong_tool_call, wrong_file_edited
`);
const result = stmt.get(caseId, modelId) as { count: number };
return result.count;
}
// Get valid results for a specific case and model (for analysis)
export async function getValidResults(caseId: string, modelId: string, limit?: number): Promise<Result[]> {
const limitClause = limit ? `LIMIT ${limit}` : '';
const stmt = db.getDatabase().prepare(`
SELECT * FROM results
WHERE case_id = ?
AND model_id = ?
AND error_enum NOT IN (1, 6, 7) -- Only valid attempts
ORDER BY created_at
${limitClause}
`);
return stmt.all(caseId, modelId) as Result[];
}
+309
View File
@@ -0,0 +1,309 @@
import { DatabaseClient } from './client';
import {
ModelSuccessRate,
ModelLatency,
CostAnalysis,
ErrorDistribution,
FailedCase,
PerformanceTrend,
ModelComparison
} from './types';
const db = DatabaseClient.getInstance();
// Performance analysis queries
export async function getSuccessRatesByModel(): Promise<ModelSuccessRate[]> {
const stmt = db.getDatabase().prepare(`
SELECT
model_id,
COUNT(*) as total_runs,
SUM(CASE WHEN succeeded THEN 1 ELSE 0 END) as successful_runs,
ROUND(AVG(CASE WHEN succeeded THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate
FROM results
WHERE error_enum NOT IN (1, 6, 7) OR error_enum IS NULL -- Exclude: no_tool_calls, wrong_tool_call, wrong_file_edited
GROUP BY model_id
ORDER BY success_rate DESC, total_runs DESC
`);
return stmt.all() as ModelSuccessRate[];
}
export async function getAverageLatencyByModel(): Promise<ModelLatency[]> {
const stmt = db.getDatabase().prepare(`
SELECT
model_id,
ROUND(AVG(time_to_first_token_ms), 2) as avg_time_to_first_token_ms,
ROUND(AVG(time_to_first_edit_ms), 2) as avg_time_to_first_edit_ms,
ROUND(AVG(time_round_trip_ms), 2) as avg_time_round_trip_ms
FROM results
WHERE time_to_first_token_ms IS NOT NULL
GROUP BY model_id
ORDER BY avg_time_round_trip_ms ASC
`);
return stmt.all() as ModelLatency[];
}
export async function getCostAnalysisByRun(): Promise<CostAnalysis[]> {
const stmt = db.getDatabase().prepare(`
SELECT
run_id,
model_id,
ROUND(SUM(cost_usd), 4) as total_cost_usd,
ROUND(AVG(cost_usd), 4) as avg_cost_per_case,
SUM(completion_tokens) as total_completion_tokens
FROM results
WHERE cost_usd IS NOT NULL
GROUP BY run_id, model_id
ORDER BY total_cost_usd DESC
`);
return stmt.all() as CostAnalysis[];
}
// Error analysis queries
export async function getErrorDistribution(): Promise<ErrorDistribution[]> {
const stmt = db.getDatabase().prepare(`
SELECT
error_enum,
COUNT(*) as count,
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM results WHERE succeeded = 0), 2) as percentage
FROM results
WHERE succeeded = 0 AND error_enum IS NOT NULL
GROUP BY error_enum
ORDER BY count DESC
`);
return stmt.all() as ErrorDistribution[];
}
export async function getFailedCasesByError(errorEnum?: number): Promise<FailedCase[]> {
let query = `
SELECT
r.case_id,
r.model_id,
r.error_enum,
c.description,
r.raw_model_output
FROM results r
JOIN cases c ON r.case_id = c.case_id
WHERE r.succeeded = 0
`;
const params: any[] = [];
if (errorEnum !== undefined) {
query += ` AND r.error_enum = ?`;
params.push(errorEnum);
}
query += ` ORDER BY r.created_at DESC LIMIT 100`;
const stmt = db.getDatabase().prepare(query);
return stmt.all(...params) as FailedCase[];
}
// Trend analysis queries
export async function getPerformanceTrends(days: number = 30): Promise<PerformanceTrend[]> {
const stmt = db.getDatabase().prepare(`
SELECT
DATE(r.created_at) as date,
r.model_id,
ROUND(AVG(CASE WHEN r.succeeded THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate,
ROUND(AVG(r.time_round_trip_ms), 2) as avg_latency_ms,
ROUND(AVG(r.cost_usd), 4) as avg_cost_usd
FROM results r
WHERE r.created_at >= datetime('now', '-' || ? || ' days')
AND (r.error_enum NOT IN (1, 6, 7) OR r.error_enum IS NULL) -- Exclude: no_tool_calls, wrong_tool_call, wrong_file_edited
GROUP BY DATE(r.created_at), r.model_id
ORDER BY date DESC, model_id
`);
return stmt.all(days) as PerformanceTrend[];
}
export async function getModelComparisons(): Promise<ModelComparison[]> {
const stmt = db.getDatabase().prepare(`
SELECT
model_id,
ROUND(AVG(CASE WHEN succeeded THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate,
ROUND(AVG(time_round_trip_ms), 2) as avg_latency_ms,
ROUND(AVG(cost_usd), 4) as avg_cost_usd,
COUNT(*) as total_runs
FROM results
WHERE error_enum NOT IN (1, 6, 7) OR error_enum IS NULL -- Exclude: no_tool_calls, wrong_tool_call, wrong_file_edited
GROUP BY model_id
HAVING total_runs >= 10
ORDER BY success_rate DESC, avg_latency_ms ASC
`);
return stmt.all() as ModelComparison[];
}
// Advanced analysis queries
export async function getTopPerformingCases(limit: number = 10): Promise<Array<{
case_id: string;
description: string;
success_rate: number;
avg_latency_ms: number;
total_runs: number;
}>> {
const stmt = db.getDatabase().prepare(`
SELECT
c.case_id,
c.description,
ROUND(AVG(CASE WHEN r.succeeded THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate,
ROUND(AVG(r.time_round_trip_ms), 2) as avg_latency_ms,
COUNT(r.result_id) as total_runs
FROM cases c
JOIN results r ON c.case_id = r.case_id
WHERE r.error_enum NOT IN (1, 6, 7) OR r.error_enum IS NULL -- Exclude: no_tool_calls, wrong_tool_call, wrong_file_edited
GROUP BY c.case_id, c.description
HAVING total_runs >= 5
ORDER BY success_rate DESC, avg_latency_ms ASC
LIMIT ?
`);
return stmt.all(limit) as Array<{
case_id: string;
description: string;
success_rate: number;
avg_latency_ms: number;
total_runs: number;
}>;
}
export async function getWorstPerformingCases(limit: number = 10): Promise<Array<{
case_id: string;
description: string;
success_rate: number;
avg_latency_ms: number;
total_runs: number;
}>> {
const stmt = db.getDatabase().prepare(`
SELECT
c.case_id,
c.description,
ROUND(AVG(CASE WHEN r.succeeded THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate,
ROUND(AVG(r.time_round_trip_ms), 2) as avg_latency_ms,
COUNT(r.result_id) as total_runs
FROM cases c
JOIN results r ON c.case_id = r.case_id
WHERE r.error_enum NOT IN (1, 6, 7) OR r.error_enum IS NULL -- Exclude: no_tool_calls, wrong_tool_call, wrong_file_edited
GROUP BY c.case_id, c.description
HAVING total_runs >= 5
ORDER BY success_rate ASC, avg_latency_ms DESC
LIMIT ?
`);
return stmt.all(limit) as Array<{
case_id: string;
description: string;
success_rate: number;
avg_latency_ms: number;
total_runs: number;
}>;
}
export async function getModelPerformanceByTimeOfDay(): Promise<Array<{
model_id: string;
hour: number;
success_rate: number;
avg_latency_ms: number;
total_runs: number;
}>> {
const stmt = db.getDatabase().prepare(`
SELECT
model_id,
CAST(strftime('%H', created_at) AS INTEGER) as hour,
ROUND(AVG(CASE WHEN succeeded THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate,
ROUND(AVG(time_round_trip_ms), 2) as avg_latency_ms,
COUNT(*) as total_runs
FROM results
GROUP BY model_id, hour
HAVING total_runs >= 5
ORDER BY model_id, hour
`);
return stmt.all() as Array<{
model_id: string;
hour: number;
success_rate: number;
avg_latency_ms: number;
total_runs: number;
}>;
}
export async function getRunComparison(runId1: string, runId2: string): Promise<{
run1: { run_id: string; success_rate: number; avg_latency_ms: number; avg_cost_usd: number; total_cases: number };
run2: { run_id: string; success_rate: number; avg_latency_ms: number; avg_cost_usd: number; total_cases: number };
}> {
const stmt = db.getDatabase().prepare(`
SELECT
run_id,
ROUND(AVG(CASE WHEN succeeded THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate,
ROUND(AVG(time_round_trip_ms), 2) as avg_latency_ms,
ROUND(AVG(cost_usd), 4) as avg_cost_usd,
COUNT(DISTINCT case_id) as total_cases
FROM results
WHERE run_id IN (?, ?)
GROUP BY run_id
`);
const results = stmt.all(runId1, runId2) as Array<{
run_id: string;
success_rate: number;
avg_latency_ms: number;
avg_cost_usd: number;
total_cases: number;
}>;
const run1 = results.find(r => r.run_id === runId1);
const run2 = results.find(r => r.run_id === runId2);
if (!run1 || !run2) {
throw new Error('One or both runs not found');
}
return { run1, run2 };
}
// Summary statistics
export async function getDatabaseSummary(): Promise<{
total_runs: number;
total_cases: number;
total_results: number;
valid_results: number;
unique_models: number;
overall_success_rate: number;
date_range: { earliest: string; latest: string };
}> {
const stmt = db.getDatabase().prepare(`
SELECT
(SELECT COUNT(*) FROM runs) as total_runs,
(SELECT COUNT(*) FROM cases) as total_cases,
(SELECT COUNT(*) FROM results) as total_results,
(SELECT COUNT(*) FROM results WHERE error_enum NOT IN (1, 6, 7) OR error_enum IS NULL) as valid_results,
(SELECT COUNT(DISTINCT model_id) FROM results) as unique_models,
(SELECT ROUND(AVG(CASE WHEN succeeded THEN 1.0 ELSE 0.0 END) * 100, 2)
FROM results
WHERE error_enum NOT IN (1, 6, 7) OR error_enum IS NULL) as overall_success_rate,
(SELECT MIN(created_at) FROM results) as earliest,
(SELECT MAX(created_at) FROM results) as latest
FROM results
LIMIT 1
`);
const result = stmt.get() as any;
return {
total_runs: result.total_runs || 0,
total_cases: result.total_cases || 0,
total_results: result.total_results || 0,
valid_results: result.valid_results || 0,
unique_models: result.unique_models || 0,
overall_success_rate: result.overall_success_rate || 0,
date_range: {
earliest: result.earliest || '',
latest: result.latest || ''
}
};
}
+78
View File
@@ -0,0 +1,78 @@
PRAGMA foreign_keys = ON;
CREATE TABLE system_prompts (
hash TEXT PRIMARY KEY,
name TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE processing_functions (
hash TEXT PRIMARY KEY,
name TEXT NOT NULL,
parsing_function TEXT NOT NULL,
diff_edit_function TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE files (
hash TEXT PRIMARY KEY,
filepath TEXT NOT NULL,
content TEXT NOT NULL,
tokens INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE runs (
run_id TEXT PRIMARY KEY,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
description TEXT,
system_prompt_hash TEXT NOT NULL,
FOREIGN KEY (system_prompt_hash) REFERENCES system_prompts(hash)
);
CREATE TABLE cases (
case_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
description TEXT NOT NULL,
system_prompt_hash TEXT NOT NULL,
task_id TEXT NOT NULL,
tokens_in_context INTEGER,
file_hash TEXT,
FOREIGN KEY (run_id) REFERENCES runs(run_id),
FOREIGN KEY (system_prompt_hash) REFERENCES system_prompts(hash),
FOREIGN KEY (file_hash) REFERENCES files(hash)
);
CREATE TABLE results (
result_id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
case_id TEXT NOT NULL,
model_id TEXT NOT NULL,
processing_functions_hash TEXT NOT NULL,
succeeded BOOLEAN NOT NULL,
error_enum INTEGER,
num_edits INTEGER,
num_lines_deleted INTEGER,
num_lines_added INTEGER,
time_to_first_token_ms INTEGER,
time_to_first_edit_ms INTEGER,
time_round_trip_ms INTEGER,
cost_usd REAL,
completion_tokens INTEGER,
raw_model_output TEXT,
file_edited_hash TEXT,
parsed_tool_call_json TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (run_id) REFERENCES runs(run_id),
FOREIGN KEY (case_id) REFERENCES cases(case_id),
FOREIGN KEY (processing_functions_hash) REFERENCES processing_functions(hash)
);
CREATE INDEX idx_results_run_model ON results(run_id, model_id);
CREATE INDEX idx_results_case_model ON results(case_id, model_id);
CREATE INDEX idx_results_success ON results(succeeded);
CREATE INDEX idx_cases_run ON cases(run_id);
CREATE INDEX idx_results_created_at ON results(created_at);
CREATE INDEX idx_runs_created_at ON runs(created_at);
+53
View File
@@ -0,0 +1,53 @@
// Simple test to verify database functionality
import { getDatabase } from './client';
import { upsertSystemPrompt, createBenchmarkRun, getDatabaseSummary } from './index';
async function testDatabase() {
console.log('Testing database functionality...');
try {
// Test database connection
const db = getDatabase();
console.log('✓ Database connection established');
console.log('Database path:', db.getDatabasePath());
// Test database info
const info = db.getInfo();
console.log('✓ Database info:', info);
// Test database stats
const stats = db.getStats();
console.log('✓ Database stats:', stats);
// Test system prompt creation
const systemPromptHash = await upsertSystemPrompt({
name: 'test-prompt',
content: 'This is a test system prompt for database verification.'
});
console.log('✓ System prompt created with hash:', systemPromptHash);
// Test benchmark run creation
const runId = await createBenchmarkRun({
description: 'Test run for database verification',
system_prompt_hash: systemPromptHash
});
console.log('✓ Benchmark run created with ID:', runId);
// Test database summary
const summary = await getDatabaseSummary();
console.log('✓ Database summary:', summary);
console.log('\n🎉 All database tests passed!');
} catch (error) {
console.error('❌ Database test failed:', error);
process.exit(1);
}
}
// Run test if this file is executed directly
if (require.main === module) {
testDatabase();
}
export { testDatabase };
+169
View File
@@ -0,0 +1,169 @@
// Database type definitions for diff-edits evaluation system
export interface SystemPrompt {
hash: string;
name: string;
content: string;
created_at: string;
}
export interface ProcessingFunctions {
hash: string;
name: string;
parsing_function: string;
diff_edit_function: string;
created_at: string;
}
export interface FileRecord {
hash: string;
filepath: string;
content: string;
tokens?: number;
created_at: string;
}
export interface BenchmarkRun {
run_id: string;
created_at: string;
description?: string;
system_prompt_hash: string;
}
export interface Case {
case_id: string
run_id: string
created_at: string
description: string
system_prompt_hash: string
task_id: string
tokens_in_context: number
file_hash?: string
}
export interface Result {
result_id: string;
run_id: string;
case_id: string;
model_id: string;
processing_functions_hash: string;
succeeded: boolean;
error_enum?: number;
num_edits?: number;
num_lines_deleted?: number;
num_lines_added?: number;
time_to_first_token_ms?: number;
time_to_first_edit_ms?: number;
time_round_trip_ms?: number;
cost_usd?: number;
completion_tokens?: number;
raw_model_output?: string;
file_edited_hash?: string;
parsed_tool_call_json?: string;
created_at: string;
}
// Input types for creating records
export interface CreateSystemPromptInput {
name: string;
content: string;
}
export interface CreateProcessingFunctionsInput {
name: string;
parsing_function: string;
diff_edit_function: string;
}
export interface CreateFileInput {
filepath: string;
content: string;
tokens?: number;
}
export interface CreateBenchmarkRunInput {
description?: string;
system_prompt_hash: string;
}
export interface CreateCaseInput {
run_id: string;
description: string;
system_prompt_hash: string;
task_id: string;
tokens_in_context: number;
file_hash?: string;
}
export interface CreateResultInput {
run_id: string;
case_id: string;
model_id: string;
processing_functions_hash: string;
succeeded: boolean;
error_enum?: number;
num_edits?: number;
num_lines_deleted?: number;
num_lines_added?: number;
time_to_first_token_ms?: number;
time_to_first_edit_ms?: number;
time_round_trip_ms?: number;
cost_usd?: number;
completion_tokens?: number;
raw_model_output?: string;
file_edited_hash?: string;
parsed_tool_call_json?: string;
}
// Analysis result types
export interface ModelSuccessRate {
model_id: string;
total_runs: number;
successful_runs: number;
success_rate: number;
}
export interface ModelLatency {
model_id: string;
avg_time_to_first_token_ms: number;
avg_time_to_first_edit_ms: number;
avg_time_round_trip_ms: number;
}
export interface CostAnalysis {
run_id: string;
model_id: string;
total_cost_usd: number;
avg_cost_per_case: number;
total_completion_tokens: number;
}
export interface ErrorDistribution {
error_enum: number;
count: number;
percentage: number;
}
export interface FailedCase {
case_id: string;
model_id: string;
error_enum: number;
description: string;
raw_model_output?: string;
}
export interface PerformanceTrend {
date: string;
model_id: string;
success_rate: number;
avg_latency_ms: number;
avg_cost_usd: number;
}
export interface ModelComparison {
model_id: string;
success_rate: number;
avg_latency_ms: number;
avg_cost_usd: number;
total_runs: number;
}
@@ -0,0 +1,827 @@
const SEARCH_BLOCK_START = "------- SEARCH"
const SEARCH_BLOCK_END = "======="
const REPLACE_BLOCK_END = "+++++++ REPLACE"
const SEARCH_BLOCK_CHAR = "-"
const REPLACE_BLOCK_CHAR = "+"
const LEGACY_SEARCH_BLOCK_CHAR = "<"
const LEGACY_REPLACE_BLOCK_CHAR = ">"
// Replace the exact string constants with flexible regex patterns
const SEARCH_BLOCK_START_REGEX = /^[-]{3,} SEARCH$/
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE$/
const LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH$/
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE$/
// Helper functions to check if a line matches the flexible patterns
function isSearchBlockStart(line: string): boolean {
return SEARCH_BLOCK_START_REGEX.test(line) || LEGACY_SEARCH_BLOCK_START_REGEX.test(line)
}
function isSearchBlockEnd(line: string): boolean {
return SEARCH_BLOCK_END_REGEX.test(line)
}
function isReplaceBlockEnd(line: string): boolean {
return REPLACE_BLOCK_END_REGEX.test(line) || LEGACY_REPLACE_BLOCK_END_REGEX.test(line)
}
/**
* Attempts a line-trimmed fallback match for the given search content in the original content.
* It tries to match `searchContent` lines against a block of lines in `originalContent` starting
* from `lastProcessedIndex`. Lines are matched by trimming leading/trailing whitespace and ensuring
* they are identical afterwards.
*
* Returns [matchIndexStart, matchIndexEnd] if found, or false if not found.
*/
function lineTrimmedFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
// Split both contents into lines
const originalLines = originalContent.split("\n")
const searchLines = searchContent.split("\n")
// Trim trailing empty line if exists (from the trailing \n in searchContent)
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
// Find the line number where startIndex falls
let startLineNum = 0
let currentIndex = 0
while (currentIndex < startIndex && startLineNum < originalLines.length) {
currentIndex += originalLines[startLineNum].length + 1 // +1 for \n
startLineNum++
}
// For each possible starting position in original content
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
let matches = true
// Try to match all search lines from this position
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
// If we found a match, calculate the exact character positions
if (matches) {
// Find start character index
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1 // +1 for \n
}
// Find end character index
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length + 1 // +1 for \n
}
return [matchStartIndex, matchEndIndex]
}
}
return false
}
/**
* Attempts to match blocks of code by using the first and last lines as anchors.
* This is a third-tier fallback strategy that helps match blocks where we can identify
* the correct location by matching the beginning and end, even if the exact content
* differs slightly.
*
* The matching strategy:
* 1. Only attempts to match blocks of 3 or more lines to avoid false positives
* 2. Extracts from the search content:
* - First line as the "start anchor"
* - Last line as the "end anchor"
* 3. For each position in the original content:
* - Checks if the next line matches the start anchor
* - If it does, jumps ahead by the search block size
* - Checks if that line matches the end anchor
* - All comparisons are done after trimming whitespace
*
* This approach is particularly useful for matching blocks of code where:
* - The exact content might have minor differences
* - The beginning and end of the block are distinctive enough to serve as anchors
* - The overall structure (number of lines) remains the same
*
* @param originalContent - The full content of the original file
* @param searchContent - The content we're trying to find in the original file
* @param startIndex - The character index in originalContent where to start searching
* @returns A tuple of [startIndex, endIndex] if a match is found, false otherwise
*/
function blockAnchorFallbackMatch(originalContent: string, searchContent: string, startIndex: number): [number, number] | false {
const originalLines = originalContent.split("\n")
const searchLines = searchContent.split("\n")
// Only use this approach for blocks of 3+ lines
if (searchLines.length < 3) {
return false
}
// Trim trailing empty line if exists
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
// Find the line number where startIndex falls
let startLineNum = 0
let currentIndex = 0
while (currentIndex < startIndex && startLineNum < originalLines.length) {
currentIndex += originalLines[startLineNum].length + 1
startLineNum++
}
// Look for matching start and end anchors
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
// Check if first line matches
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Check if last line matches at the expected position
if (originalLines[i + searchBlockSize - 1].trim() !== lastLineSearch) {
continue
}
// Calculate exact character positions
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchBlockSize; k++) {
matchEndIndex += originalLines[i + k].length + 1
}
return [matchStartIndex, matchEndIndex]
}
return false
}
/**
* This function reconstructs the file content by applying a streamed diff (in a
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
* to handle both incremental updates and the final resulting file after all chunks have
* been processed.
*
* The diff format is a custom structure that uses three markers to define changes:
*
* ------- SEARCH
* [Exact content to find in the original file]
* =======
* [Content to replace with]
* +++++++ REPLACE
*
* Behavior and Assumptions:
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
* file content is produced.
*
* 2. Matching Strategy (in order of attempt):
* a. Exact Match: First attempts to find the exact SEARCH block text in the original file
* b. Line-Trimmed Match: Falls back to line-by-line comparison ignoring leading/trailing whitespace
* c. Block Anchor Match: For blocks of 3+ lines, tries to match using first/last lines as anchors
* If all matching strategies fail, an error is thrown.
*
* 3. Empty SEARCH Section:
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
* (pure insertion).
* - If SEARCH is empty and the original file is not empty, this indicates a complete
* file replacement (the entire original content is considered matched and replaced).
*
* 4. Applying Changes:
* - Before encountering the "=======" marker, lines are accumulated as search content.
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
* file is replaced with the accumulated replacement lines, and the position in the original
* file is advanced.
*
* 5. Incremental Output:
* - As soon as the match location is found and we are in the REPLACE section, each new
* replacement line is appended to the result so that partial updates can be viewed
* incrementally.
*
* 6. Partial Markers:
* - If the final line of the chunk looks like it might be part of a marker but is not one
* of the known markers, it is removed. This prevents incomplete or partial markers
* from corrupting the output.
*
* 7. Finalization:
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
* content after the last replaced section is appended to the result.
* - Trailing newlines are not forcibly added. The code tries to output exactly what is specified.
*
* Errors:
* - If the search block cannot be matched using any of the available matching strategies,
* an error is thrown.
*/
export async function constructNewFileContent(
diffContent: string,
originalContent: string,
isFinal: boolean,
version: "v1" | "v2" = "v1",
): Promise<string> {
const constructor = constructNewFileContentVersionMapping[version]
if (!constructor) {
throw new Error(`Invalid version '${version}' for file content constructor`)
}
return constructor(diffContent, originalContent, isFinal)
}
const constructNewFileContentVersionMapping: Record<
string,
(diffContent: string, originalContent: string, isFinal: boolean) => Promise<string>
> = {
v1: constructNewFileContentV1,
v2: constructNewFileContentV2,
} as const
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
let result = ""
let lastProcessedIndex = 0
let currentSearchContent = ""
let currentReplaceContent = ""
let inSearch = false
let inReplace = false
let searchMatchIndex = -1
let searchEndIndex = -1
// Track all replacements to handle out-of-order edits
let replacements: Array<{ start: number; end: number; content: string }> = []
let pendingOutOfOrderReplacement = false
let lines = diffContent.split("\n")
// If the last line looks like a partial marker but isn't recognized,
// remove it because it might be incomplete.
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
lastLine.startsWith("=") ||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
!isSearchBlockStart(lastLine) &&
!isSearchBlockEnd(lastLine) &&
!isReplaceBlockEnd(lastLine)
) {
lines.pop()
}
for (const line of lines) {
if (isSearchBlockStart(line)) {
inSearch = true
currentSearchContent = ""
currentReplaceContent = ""
continue
}
if (isSearchBlockEnd(line)) {
inSearch = false
inReplace = true
// Remove trailing linebreak for adding the === marker
// if (currentSearchContent.endsWith("\r\n")) {
// currentSearchContent = currentSearchContent.slice(0, -2)
// } else if (currentSearchContent.endsWith("\n")) {
// currentSearchContent = currentSearchContent.slice(0, -1)
// }
if (!currentSearchContent) {
// Empty search block
if (originalContent.length === 0) {
// New file scenario: nothing to match, just start inserting
searchMatchIndex = 0
searchEndIndex = 0
} else {
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
throw new Error(
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
"Please ensure your SEARCH marker follows the correct format:\n" +
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
)
}
} else {
// Add check for inefficient full-file search
// if (currentSearchContent.trim() === originalContent.trim()) {
// throw new Error(
// "The SEARCH block contains the entire file content. Please either:\n" +
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
// "2. Make focused changes to specific parts of the file that need modification.",
// )
// }
// Exact search match scenario
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
if (exactIndex !== -1) {
searchMatchIndex = exactIndex
searchEndIndex = exactIndex + currentSearchContent.length
} else {
// Attempt fallback line-trimmed matching
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
if (lineMatch) {
;[searchMatchIndex, searchEndIndex] = lineMatch
} else {
// Try block anchor fallback for larger blocks
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
if (blockMatch) {
;[searchMatchIndex, searchEndIndex] = blockMatch
} else {
// Last resort: search the entire file from the beginning
const fullFileIndex = originalContent.indexOf(currentSearchContent, 0)
if (fullFileIndex !== -1) {
// Found in the file - could be out of order
searchMatchIndex = fullFileIndex
searchEndIndex = fullFileIndex + currentSearchContent.length
if (searchMatchIndex < lastProcessedIndex) {
pendingOutOfOrderReplacement = true
}
} else {
throw new Error(
`The SEARCH block:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
)
}
}
}
}
}
// Check if this is an out-of-order replacement
if (searchMatchIndex < lastProcessedIndex) {
pendingOutOfOrderReplacement = true
}
// For in-order replacements, output everything up to the match location
if (!pendingOutOfOrderReplacement) {
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
}
continue
}
if (isReplaceBlockEnd(line)) {
// Finished one replace block
// Store this replacement
replacements.push({
start: searchMatchIndex,
end: searchEndIndex,
content: currentReplaceContent,
})
// If this was an in-order replacement, advance lastProcessedIndex
if (!pendingOutOfOrderReplacement) {
lastProcessedIndex = searchEndIndex
}
// Reset for next block
inSearch = false
inReplace = false
currentSearchContent = ""
currentReplaceContent = ""
searchMatchIndex = -1
searchEndIndex = -1
pendingOutOfOrderReplacement = false
continue
}
// Accumulate content for search or replace
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
if (inSearch) {
currentSearchContent += line + "\n"
} else if (inReplace) {
currentReplaceContent += line + "\n"
// Only output replacement lines immediately for in-order replacements
if (searchMatchIndex !== -1 && !pendingOutOfOrderReplacement) {
result += line + "\n"
}
}
}
// If this is the final chunk, we need to apply all replacements and build the final result
if (isFinal) {
// Handle the case where we're still in replace mode when processing ends
// and this is the final chunk - treat it as if we encountered the REPLACE marker
if (inReplace && searchMatchIndex !== -1) {
// Store this replacement
replacements.push({
start: searchMatchIndex,
end: searchEndIndex,
content: currentReplaceContent,
})
// If this was an in-order replacement, advance lastProcessedIndex
if (!pendingOutOfOrderReplacement) {
lastProcessedIndex = searchEndIndex
}
// Reset state
inSearch = false
inReplace = false
currentSearchContent = ""
currentReplaceContent = ""
searchMatchIndex = -1
searchEndIndex = -1
pendingOutOfOrderReplacement = false
}
// end of handling missing replace marker
// Sort replacements by start position
replacements.sort((a, b) => a.start - b.start)
// Rebuild the entire result by applying all replacements
result = ""
let currentPos = 0
for (const replacement of replacements) {
// Add original content up to this replacement
result += originalContent.slice(currentPos, replacement.start)
// Add the replacement content
result += replacement.content
// Move position to after the replaced section
currentPos = replacement.end
}
// Add any remaining original content
result += originalContent.slice(currentPos)
}
return result
}
enum ProcessingState {
Idle = 0,
StateSearch = 1 << 0,
StateReplace = 1 << 1,
}
class NewFileContentConstructor {
private originalContent: string
private isFinal: boolean
private state: number
private pendingNonStandardLines: string[]
private result: string
private lastProcessedIndex: number
private currentSearchContent: string
private currentReplaceContent: string
private searchMatchIndex: number
private searchEndIndex: number
constructor(originalContent: string, isFinal: boolean) {
this.originalContent = originalContent
this.isFinal = isFinal
this.pendingNonStandardLines = []
this.result = ""
this.lastProcessedIndex = 0
this.state = ProcessingState.Idle
this.currentSearchContent = ""
this.currentReplaceContent = ""
this.searchMatchIndex = -1
this.searchEndIndex = -1
}
private resetForNextBlock() {
// Reset for next block
this.state = ProcessingState.Idle
this.currentSearchContent = ""
this.currentReplaceContent = ""
this.searchMatchIndex = -1
this.searchEndIndex = -1
}
private findLastMatchingLineIndex(regx: RegExp, lineLimit: number) {
for (let i = lineLimit; i > 0; ) {
i--
if (this.pendingNonStandardLines[i].match(regx)) {
return i
}
}
return -1
}
private updateProcessingState(newState: ProcessingState) {
const isValidTransition =
(this.state === ProcessingState.Idle && newState === ProcessingState.StateSearch) ||
(this.state === ProcessingState.StateSearch && newState === ProcessingState.StateReplace)
if (!isValidTransition) {
throw new Error(
`Invalid state transition.\n` +
"Valid transitions are:\n" +
"- Idle → StateSearch\n" +
"- StateSearch → StateReplace",
)
}
this.state |= newState
}
private isStateActive(state: ProcessingState): boolean {
return (this.state & state) === state
}
private activateReplaceState() {
this.updateProcessingState(ProcessingState.StateReplace)
}
private activateSearchState() {
this.updateProcessingState(ProcessingState.StateSearch)
this.currentSearchContent = ""
this.currentReplaceContent = ""
}
private isSearchingActive(): boolean {
return this.isStateActive(ProcessingState.StateSearch)
}
private isReplacingActive(): boolean {
return this.isStateActive(ProcessingState.StateReplace)
}
private hasPendingNonStandardLines(pendingNonStandardLineLimit: number): boolean {
return this.pendingNonStandardLines.length - pendingNonStandardLineLimit < this.pendingNonStandardLines.length
}
public processLine(line: string) {
this.internalProcessLine(line, true, this.pendingNonStandardLines.length)
}
public getResult() {
// If this is the final chunk, append any remaining original content
if (this.isFinal && this.lastProcessedIndex < this.originalContent.length) {
this.result += this.originalContent.slice(this.lastProcessedIndex)
}
if (this.isFinal && this.state !== ProcessingState.Idle) {
throw new Error("File processing incomplete - SEARCH/REPLACE operations still active during finalization")
}
return this.result
}
private internalProcessLine(
line: string,
canWritependingNonStandardLines: boolean,
pendingNonStandardLineLimit: number,
): number {
let removeLineCount = 0
if (isSearchBlockStart(line)) {
removeLineCount = this.trimPendingNonStandardTrailingEmptyLines(pendingNonStandardLineLimit)
if (removeLineCount > 0) {
pendingNonStandardLineLimit = pendingNonStandardLineLimit - removeLineCount
}
if (this.hasPendingNonStandardLines(pendingNonStandardLineLimit)) {
this.tryFixSearchReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateSearchState()
} else if (isSearchBlockEnd(line)) {
// 校验非标内容
if (!this.isSearchingActive()) {
this.tryFixSearchBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.activateReplaceState()
this.beforeReplace()
} else if (isReplaceBlockEnd(line)) {
if (!this.isReplacingActive()) {
this.tryFixReplaceBlock(pendingNonStandardLineLimit)
canWritependingNonStandardLines && (this.pendingNonStandardLines.length = 0)
}
this.lastProcessedIndex = this.searchEndIndex
this.resetForNextBlock()
} else {
// Accumulate content for search or replace
// (currentReplaceContent is not being used for anything right now since we directly append to result.)
// (We artificially add a linebreak since we split on \n at the beginning. In order to not include a trailing linebreak in the final search/result blocks we need to remove it before using them. This allows for partial line matches to be correctly identified.)
// NOTE: search/replace blocks must be arranged in the order they appear in the file due to how we build the content using lastProcessedIndex. We also cannot strip the trailing newline since for non-partial lines it would remove the linebreak from the original content. (If we remove end linebreak from search, then we'd also have to remove it from replace but we can't know if it's a partial line or not since the model may be using the line break to indicate the end of the block rather than as part of the search content.) We require the model to output full lines in order for our fallbacks to work as well.
if (this.isReplacingActive()) {
this.currentReplaceContent += line + "\n"
// Output replacement lines immediately if we know the insertion point
if (this.searchMatchIndex !== -1) {
this.result += line + "\n"
}
} else if (this.isSearchingActive()) {
this.currentSearchContent += line + "\n"
} else {
let appendToPendingNonStandardLines = canWritependingNonStandardLines
if (appendToPendingNonStandardLines) {
// 处理非标内容
this.pendingNonStandardLines.push(line)
}
}
}
return removeLineCount
}
private beforeReplace() {
// Remove trailing linebreak for adding the === marker
// if (currentSearchContent.endsWith("\r\n")) {
// currentSearchContent = currentSearchContent.slice(0, -2)
// } else if (currentSearchContent.endsWith("\n")) {
// currentSearchContent = currentSearchContent.slice(0, -1)
// }
if (!this.currentSearchContent) {
// Empty search block
if (this.originalContent.length === 0) {
// New file scenario: nothing to match, just start inserting
this.searchMatchIndex = 0
this.searchEndIndex = 0
} else {
// Complete file replacement scenario: treat the entire file as matched
this.searchMatchIndex = 0
this.searchEndIndex = this.originalContent.length
}
} else {
// Add check for inefficient full-file search
// if (currentSearchContent.trim() === originalContent.trim()) {
// throw new Error(
// "The SEARCH block contains the entire file content. Please either:\n" +
// "1. Use an empty SEARCH block to replace the entire file, or\n" +
// "2. Make focused changes to specific parts of the file that need modification.",
// )
// }
// Exact search match scenario
const exactIndex = this.originalContent.indexOf(this.currentSearchContent, this.lastProcessedIndex)
if (exactIndex !== -1) {
this.searchMatchIndex = exactIndex
this.searchEndIndex = exactIndex + this.currentSearchContent.length
} else {
// Attempt fallback line-trimmed matching
const lineMatch = lineTrimmedFallbackMatch(
this.originalContent,
this.currentSearchContent,
this.lastProcessedIndex,
)
if (lineMatch) {
;[this.searchMatchIndex, this.searchEndIndex] = lineMatch
} else {
// Try block anchor fallback for larger blocks
const blockMatch = blockAnchorFallbackMatch(
this.originalContent,
this.currentSearchContent,
this.lastProcessedIndex,
)
if (blockMatch) {
;[this.searchMatchIndex, this.searchEndIndex] = blockMatch
} else {
throw new Error(
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
)
}
}
}
}
if (this.searchMatchIndex < this.lastProcessedIndex) {
throw new Error(
`The SEARCH block:\n${this.currentSearchContent.trimEnd()}\n...matched an incorrect content in the file.`,
)
}
// Output everything up to the match location
this.result += this.originalContent.slice(this.lastProcessedIndex, this.searchMatchIndex)
}
private tryFixSearchBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error("Invalid SEARCH/REPLACE block structure - no lines available to process")
}
let searchTagRegexp = /^([-]{3,}|[<]{3,}) SEARCH$/
const searchTagIndex = this.findLastMatchingLineIndex(searchTagRegexp, lineLimit)
if (searchTagIndex !== -1) {
let fixLines = this.pendingNonStandardLines.slice(searchTagIndex, lineLimit)
fixLines[0] = SEARCH_BLOCK_START
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, searchTagIndex)
}
} else {
throw new Error(
`Invalid REPLACE marker detected - could not find matching SEARCH block starting from line ${searchTagIndex + 1}`,
)
}
return removeLineCount
}
private tryFixReplaceBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error()
}
let replaceBeginTagRegexp = /^[=]{3,}$/
const replaceBeginTagIndex = this.findLastMatchingLineIndex(replaceBeginTagRegexp, lineLimit)
if (replaceBeginTagIndex !== -1) {
// // 校验非标内容
// if (!this.isSearchingActive()) {
// removeLineCount += this.tryFixSearchBlock(replaceBeginTagIndex)
// }
let fixLines = this.pendingNonStandardLines.slice(replaceBeginTagIndex - removeLineCount, lineLimit - removeLineCount)
fixLines[0] = SEARCH_BLOCK_END
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, replaceBeginTagIndex - removeLineCount)
}
} else {
throw new Error(`Malformed REPLACE block - missing valid separator after line ${replaceBeginTagIndex + 1}`)
}
return removeLineCount
}
private tryFixSearchReplaceBlock(lineLimit: number): number {
let removeLineCount = 0
if (lineLimit < 0) {
lineLimit = this.pendingNonStandardLines.length
}
if (!lineLimit) {
throw new Error()
}
let replaceEndTagRegexp = /^([+]{3,}|[>]{3,}) REPLACE$/
const replaceEndTagIndex = this.findLastMatchingLineIndex(replaceEndTagRegexp, lineLimit)
const likeReplaceEndTag = replaceEndTagIndex === lineLimit - 1
if (likeReplaceEndTag) {
// // 校验非标内容
// if (!this.isReplacingActive()) {
// removeLineCount += this.tryFixReplaceBlock(replaceEndTagIndex)
// }
let fixLines = this.pendingNonStandardLines.slice(replaceEndTagIndex - removeLineCount, lineLimit - removeLineCount)
fixLines[fixLines.length - 1] = REPLACE_BLOCK_END
for (const line of fixLines) {
removeLineCount += this.internalProcessLine(line, false, replaceEndTagIndex - removeLineCount)
}
} else {
throw new Error("Malformed SEARCH/REPLACE block structure: Missing valid closing REPLACE marker")
}
return removeLineCount
}
/**
* Removes trailing empty lines from the pendingNonStandardLines array
* @param lineLimit - The index to start checking from (exclusive).
* Removes empty lines from lineLimit-1 backwards.
* @returns The number of empty lines removed
*/
private trimPendingNonStandardTrailingEmptyLines(lineLimit: number): number {
let removedCount = 0
let i = Math.min(lineLimit, this.pendingNonStandardLines.length) - 1
while (i >= 0 && this.pendingNonStandardLines[i].trim() === "") {
this.pendingNonStandardLines.pop()
removedCount++
i--
}
return removedCount
}
}
export async function constructNewFileContentV2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
let newFileContentConstructor = new NewFileContentConstructor(originalContent, isFinal)
let lines = diffContent.split("\n")
// If the last line looks like a partial marker but isn't recognized,
// remove it because it might be incomplete.
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith(SEARCH_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_SEARCH_BLOCK_CHAR) ||
lastLine.startsWith("=") ||
lastLine.startsWith(REPLACE_BLOCK_CHAR) ||
lastLine.startsWith(LEGACY_REPLACE_BLOCK_CHAR)) &&
lastLine !== SEARCH_BLOCK_START &&
lastLine !== SEARCH_BLOCK_END &&
lastLine !== REPLACE_BLOCK_END
) {
lines.pop()
}
for (const line of lines) {
newFileContentConstructor.processLine(line)
}
let result = newFileContentConstructor.getResult()
return result
}
@@ -0,0 +1,98 @@
import axios from "axios";
import path from "path";
import fs from "fs/promises";
// Minimal type for what we need from OpenRouter model info in evals
export interface EvalOpenRouterModelInfo {
id: string;
contextWindow: number;
inputPrice?: number; // Price per million tokens
outputPrice?: number; // Price per million tokens
// Add any other fields if they become necessary for evals
}
function logHelper(isVerbose: boolean, message: string) {
if (isVerbose) {
console.log(`[OpenRouterModelsHelper] ${message}`);
}
}
/**
* Ensures the cache directory exists within evals and returns its path
*/
async function ensureEvalCacheDirectoryExists(): Promise<string> {
// Cache directory within evals, e.g., evals/.cache/
const cacheDir = path.join(__dirname, "..", ".cache");
await fs.mkdir(cacheDir, { recursive: true });
return cacheDir;
}
/**
* Fetches, parses, and caches OpenRouter model data.
* Tries to read from a local cache first.
* @param isVerbose Enable verbose logging
* @returns A record of model IDs to their info.
*/
export async function loadOpenRouterModelData(isVerbose: boolean = false): Promise<Record<string, EvalOpenRouterModelInfo>> {
const cacheDir = await ensureEvalCacheDirectoryExists();
const cacheFilePath = path.join(cacheDir, "openRouterModels.json");
let models: Record<string, EvalOpenRouterModelInfo> = {};
try {
const stats = await fs.stat(cacheFilePath).catch(() => null);
// Use cache if less than 24 hours old
if (stats && (Date.now() - stats.mtimeMs < 24 * 60 * 60 * 1000)) {
logHelper(isVerbose, "Using cached OpenRouter model data.");
const fileContents = await fs.readFile(cacheFilePath, "utf8");
models = JSON.parse(fileContents);
if (Object.keys(models).length > 0) {
return models;
}
logHelper(isVerbose, "Cache was empty or invalid, fetching fresh data.");
} else if (stats) {
logHelper(isVerbose, "Cached OpenRouter model data is stale, fetching fresh data.");
} else {
logHelper(isVerbose, "No cached OpenRouter model data found, fetching fresh data.");
}
} catch (e) {
logHelper(isVerbose, `Error accessing cache, fetching fresh data: ${e}`);
}
try {
const response = await axios.get("https://openrouter.ai/api/v1/models");
if (response.data?.data) {
const rawModels = response.data.data;
const parsedModels: Record<string, EvalOpenRouterModelInfo> = {};
const parsePrice = (price: any) => price ? parseFloat(price) * 1_000_000 : undefined;
for (const rawModel of rawModels) {
parsedModels[rawModel.id] = {
id: rawModel.id,
contextWindow: rawModel.context_length ?? 0,
inputPrice: parsePrice(rawModel.pricing?.prompt),
outputPrice: parsePrice(rawModel.pricing?.completion),
};
}
await fs.writeFile(cacheFilePath, JSON.stringify(parsedModels, null, 2));
logHelper(isVerbose, `Fetched and cached ${Object.keys(parsedModels).length} OpenRouter models.`);
return parsedModels;
} else {
logHelper(isVerbose, "Invalid response structure from OpenRouter API.");
}
} catch (error) {
logHelper(isVerbose, `Error fetching OpenRouter models: ${error}. Attempting to use stale cache if available.`);
// Attempt to read stale cache as a last resort if fetching failed
try {
const fileContents = await fs.readFile(cacheFilePath, "utf8");
models = JSON.parse(fileContents);
if (Object.keys(models).length > 0) {
logHelper(isVerbose, "Successfully loaded stale cache after fetch failure.");
return models;
}
} catch (cacheError) {
logHelper(isVerbose, `Failed to read stale cache: ${cacheError}. Proceeding without OpenRouter model data.`);
}
}
// Return empty if all attempts fail, so the caller can decide how to handle it
return {};
}
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
# Get the directory of this script to make paths robust
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# The 'evals' directory is the parent of the script's directory
EVALS_DIR=$(dirname "$SCRIPT_DIR")
# Navigate to the evals directory to ensure npm commands run correctly
cd "$EVALS_DIR"
# Re-install dependencies and build the CLI
echo "Ensuring dependencies are up to date and building CLI..."
npm install && npm run build:cli
# Check if the build was successful before proceeding
if [ $? -ne 0 ]; then
echo "CLI build failed. Aborting evaluation."
exit 1
fi
# Run the evaluation script, passing all arguments from the command line
echo "Running evaluation..."
node ./cli/dist/index.js run-diff-eval "$@"
# Check the exit code of the evaluation script
if [ $? -eq 0 ]; then
# If the script succeeded, open the dashboard in the background
echo "Evaluation complete. Starting dashboard..."
(cd "$SCRIPT_DIR/dashboard" && streamlit run app.py &)
else
# If the script failed, print an error message and exit
echo "Evaluation failed. Dashboard will not be started."
exit 1
fi
@@ -33,6 +33,7 @@ export interface TestConfig {
diff_edit_function: string
thinking_tokens_budget: number
replay: boolean
diff_apply_file?: string
}
export interface SystemPromptDetails {
@@ -61,7 +62,22 @@ export type ConstructSystemPromptFn = (
export interface TestResult {
success: boolean
streamResult?: any
streamResult?: {
assistantMessage: string
reasoningMessage: string
usage: {
inputTokens: number
outputTokens: number
cacheWriteTokens: number
cacheReadTokens: number
totalCost: number
}
timing?: {
timeToFirstTokenMs: number
timeToFirstEditMs?: number
totalRoundTripMs: number
}
}
diffEdit?: string
toolCalls?: ExtractedToolCall[]
diffEditSuccess?: boolean
@@ -85,4 +101,5 @@ export interface TestInput {
diffEditFunction: string
thinkingBudgetTokens: number
originalDiffEditToolCallMessage?: string
diffApplyFile?: string
}
-391
View File
@@ -1,391 +0,0 @@
import { runSingleEvaluation, TestInput, TestResult } from "./ClineWrapper"
import { basicSystemPrompt } from "./prompts/basicSystemPrompt-06-06-25"
import { claude4SystemPrompt } from "./prompts/claude4SystemPrompt-06-06-25"
import { formatResponse } from "./helpers"
import { Anthropic } from "@anthropic-ai/sdk"
import * as fs from "fs"
import * as path from "path"
import { Command } from "commander"
import { InputMessage, ProcessedTestCase, TestCase, TestConfig, SystemPromptDetails, ConstructSystemPromptFn } from "./types"
function log(isVerbose: boolean, message: string) {
if (isVerbose) {
console.log(message)
}
}
const systemPromptGeneratorLookup: Record<string, ConstructSystemPromptFn> = {
basicSystemPrompt: basicSystemPrompt,
claude4SystemPrompt: claude4SystemPrompt,
}
type TestResultSet = { [test_id: string]: (TestResult & { test_id?: string })[] }
class NodeTestRunner {
private apiKey: string | undefined
constructor(isReplay: boolean) {
if (!isReplay) {
this.apiKey = process.env.OPENROUTER_API_KEY
if (!this.apiKey) {
throw new Error("OPENROUTER_API_KEY environment variable not set for a non-replay run.")
}
}
}
/**
* convert our messages array into a properly formatted Anthropic messages array
*/
transformMessages(messages: InputMessage[]): Anthropic.Messages.MessageParam[] {
return messages.map((msg) => {
// Use TextBlockParam here for constructing the input message
const content: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
if (msg.text) {
// This object now correctly matches the TextBlockParam type
content.push({ type: "text", text: msg.text })
}
if (msg.images && Array.isArray(msg.images)) {
const imageBlocks = formatResponse.imageBlocks(msg.images)
content.push(...imageBlocks)
}
return {
role: msg.role,
content: content,
}
})
}
/**
* Generate the system prompt on the fly
*/
constructSystemPrompt(systemPromptDetails: SystemPromptDetails, systemPromptName: string) {
const systemPromptGenerator = systemPromptGeneratorLookup[systemPromptName]
const { cwd_value, browser_use, width, height, os_value, shell_value, home_value, mcp_string, user_custom_instructions } =
systemPromptDetails
const systemPrompt = systemPromptGenerator(
cwd_value,
browser_use,
width,
height,
os_value,
shell_value,
home_value,
mcp_string,
user_custom_instructions,
)
return systemPrompt
}
/**
* Loads our test cases from a directory of json files
*/
loadTestCases(testDirectoryPath: string): TestCase[] {
const testCasesArray: TestCase[] = []
const dirents = fs.readdirSync(testDirectoryPath, { withFileTypes: true })
for (const dirent of dirents) {
if (dirent.isFile() && dirent.name.endsWith(".json")) {
const testFilePath = path.join(testDirectoryPath, dirent.name)
const fileContent = fs.readFileSync(testFilePath, "utf8")
const testCase: TestCase = JSON.parse(fileContent)
// Use the filename (without extension) as the test_id if not provided
if (!testCase.test_id) {
testCase.test_id = path.parse(dirent.name).name
}
testCasesArray.push(testCase)
}
}
return testCasesArray
}
/**
* Saves the test results to the specified output directory.
*/
saveTestResults(results: TestResultSet, outputPath: string) {
// Ensure output directory exists
if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true })
}
// Write each test result to its own file
for (const testId in results) {
const outputFilePath = path.join(outputPath, `${testId}.json`)
const testResult = results[testId]
fs.writeFileSync(outputFilePath, JSON.stringify(testResult, null, 2))
}
}
/**
* Run a single test example
*/
async runSingleTest(testCase: ProcessedTestCase, testConfig: TestConfig): Promise<TestResult> {
if (testConfig.replay && !testCase.original_diff_edit_tool_call_message) {
return {
success: false,
error: "missing_original_diff_edit_tool_call_message",
errorString: `Test case ${testCase.test_id} is missing 'original_diff_edit_tool_call_message' for replay.`,
}
}
const customSystemPrompt = this.constructSystemPrompt(testCase.system_prompt_details, testConfig.system_prompt_name)
// messages don't include system prompt and are everything up to the first replace_in_file tool call which results in a diff edit error
const input: TestInput = {
apiKey: this.apiKey,
systemPrompt: customSystemPrompt,
messages: testCase.messages,
modelId: testConfig.model_id,
originalFile: testCase.file_contents,
originalFilePath: testCase.file_path,
parsingFunction: testConfig.parsing_function,
diffEditFunction: testConfig.diff_edit_function,
thinkingBudgetTokens: testConfig.thinking_tokens_budget,
originalDiffEditToolCallMessage: testConfig.replay ? testCase.original_diff_edit_tool_call_message : undefined,
}
return await runSingleEvaluation(input)
}
/**
* Runs all the text examples synchonously
*/
async runAllTests(testCases: ProcessedTestCase[], testConfig: TestConfig, isVerbose: boolean): Promise<TestResultSet> {
const results: TestResultSet = {}
for (const testCase of testCases) {
results[testCase.test_id] = []
log(isVerbose, `-Running test: ${testCase.test_id}`)
for (let i = 0; i < testConfig.number_of_runs; i++) {
const result = await this.runSingleTest(testCase, testConfig)
results[testCase.test_id].push(result)
}
}
return results
}
/**
* Runs all of the text examples asynchronously, with concurrency limit
*/
async runAllTestsParallel(
testCases: ProcessedTestCase[],
testConfig: TestConfig,
isVerbose: boolean,
maxConcurrency: number = 20,
): Promise<TestResultSet> {
const results: TestResultSet = {}
testCases.forEach((tc) => {
results[tc.test_id] = []
})
// Create a flat list of all individual runs we need to execute
const allRuns = testCases.flatMap((testCase) =>
Array(testConfig.number_of_runs)
.fill(null)
.map(() => testCase),
)
for (let i = 0; i < allRuns.length; i += maxConcurrency) {
const batch = allRuns.slice(i, i + maxConcurrency)
const batchPromises = batch.map((testCase) =>
this.runSingleTest(testCase, testConfig).then((result) => ({
...result,
test_id: testCase.test_id,
})),
)
const batchResults = await Promise.all(batchPromises)
// Calculate the total cost for this batch
const batchCost = batchResults.reduce((total, result) => {
return total + (result.streamResult?.usage?.totalCost || 0)
}, 0)
// Populate the results dictionary
for (const result of batchResults) {
if (result.test_id) {
results[result.test_id].push(result)
}
}
const batchNumber = i / maxConcurrency + 1
const totalBatches = Math.ceil(allRuns.length / maxConcurrency)
log(isVerbose, `-Completed batch ${batchNumber} of ${totalBatches}... (Batch Cost: $${batchCost.toFixed(6)})`)
}
return results
}
/**
* Print output of the tests
*/
printSummary(results: TestResultSet, isVerbose: boolean) {
let totalRuns = 0
let totalPasses = 0
let totalInputTokens = 0
let totalOutputTokens = 0
let totalCost = 0
let runsWithUsageData = 0
let totalDiffEditSuccesses = 0
let totalRunsWithToolCalls = 0
const testCaseIds = Object.keys(results)
log(isVerbose, "\n=== TEST SUMMARY ===")
for (const testId of testCaseIds) {
const testResults = results[testId]
const passedCount = testResults.filter((r) => r.success && r.diffEditSuccess).length
const runCount = testResults.length
totalRuns += runCount
totalPasses += passedCount
const runsWithToolCalls = testResults.filter((r) => r.success === true).length
const diffEditSuccesses = passedCount
totalRunsWithToolCalls += runsWithToolCalls
totalDiffEditSuccesses += diffEditSuccesses
// Accumulate token and cost data
for (const result of testResults) {
if (result.streamResult?.usage) {
totalInputTokens += result.streamResult.usage.inputTokens
totalOutputTokens += result.streamResult.usage.outputTokens
totalCost += result.streamResult.usage.totalCost
runsWithUsageData++
}
}
log(isVerbose, `\n--- Test Case: ${testId} ---`)
log(isVerbose, ` Runs: ${runCount}`)
log(isVerbose, ` Passed: ${passedCount}`)
log(isVerbose, ` Success Rate: ${runCount > 0 ? ((passedCount / runCount) * 100).toFixed(1) : "N/A"}%`)
}
log(isVerbose, "\n\n=== OVERALL SUMMARY ===")
log(isVerbose, `Total Test Cases: ${testCaseIds.length}`)
log(isVerbose, `Total Runs Executed: ${totalRuns}`)
log(isVerbose, `Overall Passed: ${totalPasses}`)
log(isVerbose, `Overall Failed: ${totalRuns - totalPasses}`)
log(isVerbose, `Overall Success Rate: ${totalRuns > 0 ? ((totalPasses / totalRuns) * 100).toFixed(1) : "N/A"}%`)
log(isVerbose, "\n\n=== OVERALL DIFF EDIT SUCCESS RATE ===")
if (totalRunsWithToolCalls > 0) {
const diffSuccessRate = (totalDiffEditSuccesses / totalRunsWithToolCalls) * 100
log(isVerbose, `Total Runs with Successful Tool Calls: ${totalRunsWithToolCalls}`)
log(isVerbose, `Total Runs with Successful Diff Edits: ${totalDiffEditSuccesses}`)
log(isVerbose, `Diff Edit Success Rate: ${diffSuccessRate.toFixed(1)}%`)
} else {
log(isVerbose, "No successful tool calls to analyze for diff edit success.")
}
log(isVerbose, "\n\n=== TOKEN & COST ANALYSIS ===")
if (runsWithUsageData > 0) {
log(isVerbose, `Total Input Tokens: ${totalInputTokens.toLocaleString()}`)
log(isVerbose, `Total Output Tokens: ${totalOutputTokens.toLocaleString()}`)
log(isVerbose, `Total Cost: $${totalCost.toFixed(6)}`)
log(isVerbose, "---")
log(
isVerbose,
`Avg Input Tokens / Run: ${(totalInputTokens / runsWithUsageData).toLocaleString(undefined, {
maximumFractionDigits: 0,
})}`,
)
log(
isVerbose,
`Avg Output Tokens / Run: ${(totalOutputTokens / runsWithUsageData).toLocaleString(undefined, {
maximumFractionDigits: 0,
})}`,
)
log(isVerbose, `Avg Cost / Run: $${(totalCost / runsWithUsageData).toFixed(6)}`)
} else {
log(isVerbose, "No usage data available to analyze.")
}
}
}
async function main() {
const program = new Command()
const defaultTestPath = path.join(__dirname, "test_cases")
const defaultOutputPath = path.join(__dirname, "test_outputs")
program
.name("TestRunner")
.description("Run evaluation tests for diff editing")
.version("1.0.0")
.option("--test-path <path>", "Path to the directory containing test case JSON files", defaultTestPath)
.option("--output-path <path>", "Path to the directory to save the test output JSON files", defaultOutputPath)
.option("--model-id <model_id>", "The model ID to use for the test")
.option("--system-prompt-name <name>", "The name of the system prompt to use", "basicSystemPrompt")
.option("-n, --number-of-runs <number>", "Number of times to run each test case", "1")
.option("--parsing-function <name>", "The parsing function to use", "parseAssistantMessageV2")
.option("--diff-edit-function <name>", "The diff editing function to use", "constructNewFileContentV2")
.option("--thinking-budget <tokens>", "Set the thinking tokens budget", "0")
.option("--parallel", "Run tests in parallel", false)
.option("--replay", "Run evaluation from a pre-recorded LLM output, skipping the API call", false)
.option("-v, --verbose", "Enable verbose logging", false)
program.parse(process.argv)
const options = program.opts()
const isVerbose = options.verbose
const testPath = options.testPath
const outputPath = options.outputPath
const testConfig: TestConfig = {
model_id: options.modelId,
system_prompt_name: options.systemPromptName,
number_of_runs: parseInt(options.numberOfRuns, 10),
parsing_function: options.parsingFunction,
diff_edit_function: options.diffEditFunction,
thinking_tokens_budget: parseInt(options.thinkingBudget, 10),
replay: options.replay,
}
try {
const startTime = Date.now()
const runner = new NodeTestRunner(testConfig.replay)
const testCases = runner.loadTestCases(testPath)
const processedTestCases: ProcessedTestCase[] = testCases.map((tc) => ({
...tc,
messages: runner.transformMessages(tc.messages),
}))
log(isVerbose, `-Loaded ${testCases.length} test cases.`)
log(isVerbose, `-Executing ${testConfig.number_of_runs} run(s) per test case.`)
if (testConfig.replay) {
log(isVerbose, `-Running in REPLAY mode. No API calls will be made.`)
}
log(isVerbose, "Starting tests...\n")
const results = options.parallel
? await runner.runAllTestsParallel(processedTestCases, testConfig, isVerbose)
: await runner.runAllTests(processedTestCases, testConfig, isVerbose)
runner.printSummary(results, isVerbose)
const endTime = Date.now()
const durationSeconds = ((endTime - startTime) / 1000).toFixed(2)
log(isVerbose, `\n-Total execution time: ${durationSeconds} seconds`)
runner.saveTestResults(results, outputPath)
} catch (error) {
console.error("\nError running tests:", error)
process.exit(1)
}
}
if (require.main === module) {
main()
}
+2500
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "cline-evals",
"version": "0.1.0",
"description": "Evaluation scripts and tools for Cline",
"main": "cli/dist/index.js",
"scripts": {
"build:cli": "cd cli && tsc",
"start:cli": "cd cli && node dist/index.js",
"dev:cli": "cd cli && ts-node src/index.ts",
"diff-eval": "./diff-edits/run_and_open_dashboard.sh",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"cline",
"evaluation",
"benchmark",
"diff-edits"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.8.2",
"better-sqlite3": "^11.10.0",
"chalk": "^4.1.2",
"dotenv": "^16.5.0",
"commander": "^9.4.1",
"execa": "^5.1.1",
"node-fetch": "^2.7.0",
"ora": "^5.4.1",
"sqlite": "^4.1.2",
"tiktoken": "^1.0.21",
"uuid": "^9.0.0",
"yargs": "^17.6.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.3",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.12",
"@types/uuid": "^9.0.0",
"@types/yargs": "^17.0.19",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".."
}
}
+374 -67
View File
@@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "3.17.14",
"version": "3.17.16",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.17.14",
"version": "3.17.16",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
@@ -109,6 +109,7 @@
"prettier": "^3.3.3",
"protoc-gen-ts": "^0.8.7",
"proxyquire": "^2.1.3",
"rimraf": "^6.0.1",
"should": "^13.2.3",
"sinon": "^19.0.2",
"ts-node": "^10.9.2",
@@ -3701,6 +3702,29 @@
}
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -4025,6 +4049,17 @@
"node": ">= 6.0.0"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -4035,6 +4070,28 @@
"node": ">=8"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -4075,6 +4132,36 @@
"semver": "bin/semver.js"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@mdx-js/mdx": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.0.tgz",
@@ -13169,6 +13256,69 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/flat-cache/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/flat-cache/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/flat-cache/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/flat-cache/node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/flatted": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz",
@@ -13206,12 +13356,12 @@
}
},
"node_modules/foreground-child": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.0",
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
@@ -20778,66 +20928,106 @@
"license": "MIT"
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
"dev": true,
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
"glob": "^11.0.0",
"package-json-from-dist": "^1.0.0"
},
"bin": {
"rimraf": "bin.js"
"rimraf": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"dev": true,
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.0.3",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "*"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "*"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf/node_modules/path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/router": {
@@ -26670,6 +26860,21 @@
"dev": true,
"requires": {}
},
"@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true
},
"@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"requires": {
"@isaacs/balanced-match": "^4.0.1"
}
},
"@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -26909,12 +27114,36 @@
"debug": "4"
}
},
"brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -26941,6 +27170,24 @@
"dev": true
}
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"requires": {
"glob": "^7.1.3"
}
}
}
},
@@ -33440,6 +33687,50 @@
"flatted": "^3.2.9",
"keyv": "^4.5.3",
"rimraf": "^3.0.2"
},
"dependencies": {
"brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"dev": true,
"requires": {
"glob": "^7.1.3"
}
}
}
},
"flatted": {
@@ -33463,11 +33754,11 @@
}
},
"foreground-child": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
"integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"requires": {
"cross-spawn": "^7.0.0",
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
}
},
@@ -38623,45 +38914,61 @@
"dev": true
},
"rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
"integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==",
"dev": true,
"requires": {
"glob": "^7.1.3"
"glob": "^11.0.0",
"package-json-from-dist": "^1.0.0"
},
"dependencies": {
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"glob": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz",
"integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.0.3",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"@isaacs/cliui": "^8.0.2"
}
},
"lru-cache": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
"integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==",
"dev": true
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "10.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz",
"integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
"@isaacs/brace-expansion": "^5.0.0"
}
},
"path-scurry": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz",
"integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==",
"dev": true,
"requires": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
}
}
}
+3 -1
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.17.14",
"version": "3.17.16",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -332,6 +332,7 @@
"package": "npm run check-types && npm run build:webview && npm run lint && node esbuild.js --production",
"protos": "node proto/build-proto.js && node scripts/generate-server-setup.mjs && node scripts/generate-host-bridge-client.mjs",
"postprotos": "prettier src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --log-level warn",
"clean": "rimraf dist dist-standalone webview-ui/build src/generated",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"pretest": "npm run compile-tests && npm run compile && npm run compile-standalone && npm run lint",
@@ -396,6 +397,7 @@
"prettier": "^3.3.3",
"protoc-gen-ts": "^0.8.7",
"proxyquire": "^2.1.3",
"rimraf": "^6.0.1",
"should": "^13.2.3",
"sinon": "^19.0.2",
"ts-node": "^10.9.2",
+54
View File
@@ -19,4 +19,58 @@ service AccountService {
// Subscribe to auth callback events (when authentication tokens are received)
rpc subscribeToAuthCallback(EmptyRequest) returns (stream String);
// Handles authentication state changes from the Firebase context.
// Updates the user info in global state and returns the updated value.
rpc authStateChanged(AuthStateChangedRequest) returns (AuthStateChanged);
// Fetches all user credits data (balance, usage transactions, payment transactions)
rpc fetchUserCreditsData(EmptyRequest) returns (UserCreditsData);
}
message AuthStateChangedRequest {
Metadata metadata = 1;
UserInfo user = 2;
}
message AuthStateChanged {
optional UserInfo user = 1;
}
message UserInfo {
optional string display_name = 1;
optional string email = 2;
optional string photo_url = 3;
}
// Response containing all user credits data
message UserCreditsData {
UserCreditsBalance balance = 1;
repeated UsageTransaction usage_transactions = 2;
repeated PaymentTransaction payment_transactions = 3;
}
// User's current credit balance
message UserCreditsBalance {
double current_balance = 1;
}
// Usage transaction record
message UsageTransaction {
string spent_at = 1;
string creator_id = 2;
double credits = 3;
string model_provider = 4;
string model = 5;
int32 prompt_tokens = 6;
int32 completion_tokens = 7;
int32 total_tokens = 8;
}
// Payment transaction record
message PaymentTransaction {
string paid_at = 1;
string creator_id = 2;
int32 amount_cents = 3;
double credits = 4;
}
-3
View File
@@ -29,9 +29,6 @@ service FileService {
// Search git commits in the workspace
rpc searchCommits(StringRequest) returns (GitCommits);
// Select images from the file system and return as data URLs
rpc selectImages(EmptyRequest) returns (StringArray);
// Select images and other files from the file system and returns as data URLs & paths respectively
rpc selectFiles(BooleanRequest) returns (StringArrays);
+3 -1
View File
@@ -123,6 +123,7 @@ enum ApiProvider {
CEREBRAS = 23;
SAPAICORE = 24;
CLAUDE_CODE = 25;
GEMINI_CLI = 26;
}
// Model info for OpenAI-compatible models
@@ -236,4 +237,5 @@ message ModelsApiConfiguration {
optional string sap_ai_core_token_url = 71;
optional string sap_ai_core_base_url = 72;
optional string claude_code_path = 73;
}
optional string gemini_cli_path = 74;
}
+12
View File
@@ -17,6 +17,7 @@ service StateService {
rpc togglePlanActMode(TogglePlanActModeRequest) returns (Boolean);
rpc updateAutoApprovalSettings(AutoApprovalSettingsRequest) returns (Empty);
rpc updateSettings(UpdateSettingsRequest) returns (Empty);
rpc updateTelemetrySetting(TelemetrySettingRequest) returns (Empty);
}
message State {
@@ -88,6 +89,17 @@ message AutoApprovalSettingsRequest {
repeated string favorites = 7;
}
enum TelemetrySettingEnum {
UNSET = 0;
ENABLED = 1;
DISABLED = 2;
}
message TelemetrySettingRequest {
Metadata metadata = 1;
TelemetrySettingEnum setting = 2;
}
// Message for updating settings
message UpdateSettingsRequest {
Metadata metadata = 1;
+7
View File
@@ -35,6 +35,8 @@ service TaskService {
rpc taskCompletionViewChanges(Int64Request) returns (Empty);
// Executes a quick win task with command and title
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
// Deletes all task history
rpc deleteAllTaskHistory(BooleanRequest) returns (DeleteAllTaskHistoryCount);
}
// Request message for creating a new task
@@ -116,3 +118,8 @@ message ExecuteQuickWinRequest {
string command = 2;
string title = 3;
}
// Results returned when deleting all task history
message DeleteAllTaskHistoryCount {
int32 tasks_deleted = 1;
}
+6
View File
@@ -262,4 +262,10 @@ service UiService {
// Subscribe to webview visibility change events
rpc subscribeToDidBecomeVisible(EmptyRequest) returns (stream Empty);
// Returns the HTML for the webview index page. This is only used by external clients, not by the vscode webview.
rpc getWebviewHtml(EmptyRequest) returns (String);
// Opens a URL in the default browser
rpc openUrl(StringRequest) returns (Empty);
}
+2 -1
View File
@@ -13,7 +13,8 @@ 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
grep -E '\.[a-z][^.]+$' | # remove types (last part of identifier should be lowercase)
sort | uniq > $SDK_DEST
sort | uniq -c | sort -n | # Count occurrences
cat > $SDK_DEST
}
echo Wrote uses of the vscode SDK to $(realpath $SDK_DEST)
+18 -7
View File
@@ -56,13 +56,24 @@ archive.glob("**/*", {
// Add the whole cline directory under "extension"
archive.directory(process.cwd(), "extension", (entry) => {
// Skip certain directories
if (
entry.name.startsWith(BUILD_DIR + "/") ||
entry.name.startsWith("node_modules/") || // node_modules nearly 1GB.
entry.name.startsWith("webview-ui/node_modules/") || // node_modules nearly 1GB.
entry.name.match(/(^|\/)\./) // exclude dot directories
) {
// Skip certain directories.
const exclude = [
BUILD_DIR + "/",
"node_modules/", // node_modules nearly 1GB.
"webview-ui/node_modules/", // node_modules nearly 1GB.
]
// These node modules are used at runtime as assets, they need to be included.
const include = ["node_modules/@vscode/", "webview-ui/node_modules/katex"]
const name = entry.name
if (include.some((prefix) => name.startsWith(prefix))) {
return entry
}
if (exclude.some((prefix) => name.startsWith(prefix))) {
return false
}
if (name.match(/(^|\/)\./)) {
// exclude dot directories
return false
}
return entry
+28 -2
View File
@@ -27,6 +27,7 @@ import { SambanovaHandler } from "./providers/sambanova"
import { CerebrasHandler } from "./providers/cerebras"
import { SapAiCoreHandler } from "./providers/sapaicore"
import { ClaudeCodeHandler } from "./providers/claude-code"
import { GeminiCliHandler } from "./providers/gemini-cli"
export interface ApiHandler {
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
@@ -38,8 +39,7 @@ export interface SingleCompletionHandler {
completePrompt(prompt: string): Promise<string>
}
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
const { apiProvider, ...options } = configuration
function createHandlerForProvider(apiProvider: string | undefined, options: any): ApiHandler {
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler(options)
@@ -93,7 +93,33 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new SapAiCoreHandler(options)
case "claude-code":
return new ClaudeCodeHandler(options)
case "gemini-cli":
return new GeminiCliHandler(options)
default:
return new AnthropicHandler(options)
}
}
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
const { apiProvider, ...options } = configuration
// Validate thinking budget tokens against model's maxTokens to prevent API errors
// wrapped in a try-catch for safety, but this should never throw
try {
if (options.thinkingBudgetTokens && options.thinkingBudgetTokens > 0) {
const handler = createHandlerForProvider(apiProvider, options)
const modelInfo = handler.getModel().info
if (modelInfo.maxTokens && options.thinkingBudgetTokens > modelInfo.maxTokens) {
const clippedValue = modelInfo.maxTokens - 1
options.thinkingBudgetTokens = clippedValue
} else {
return handler // don't rebuild unless its necessary
}
}
} catch (error) {
console.error("buildApiHandler error:", error)
}
return createHandlerForProvider(apiProvider, options)
}
+62 -77
View File
@@ -4,7 +4,7 @@ import { type ApiHandler } from ".."
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
import { withRetry } from "../retry"
import { runClaudeCode } from "@/integrations/claude-code/run"
import { ClaudeCodeMessage } from "@/integrations/claude-code/types"
import { filterMessagesForClaudeCode } from "@/integrations/claude-code/message-filter"
export class ClaudeCodeHandler implements ApiHandler {
private options: ApiHandlerOptions
@@ -19,39 +19,16 @@ export class ClaudeCodeHandler implements ApiHandler {
maxDelay: 15000,
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// Filter out image blocks since Claude Code doesn't support them
const filteredMessages = filterMessagesForClaudeCode(messages)
const claudeProcess = runClaudeCode({
systemPrompt,
messages,
messages: filteredMessages,
path: this.options.claudeCodePath,
modelId: this.getModel().id,
})
const dataQueue: string[] = []
let processError = null
let errorOutput = ""
let exitCode: number | null = null
claudeProcess.stdout.on("data", (data) => {
const output = data.toString()
const lines = output.split("\n").filter((line: string) => line.trim() !== "")
for (const line of lines) {
dataQueue.push(line)
}
})
claudeProcess.stderr.on("data", (data) => {
errorOutput += data.toString()
})
claudeProcess.on("close", (code) => {
exitCode = code
})
claudeProcess.on("error", (error) => {
processError = error
})
// Usage is included with assistant messages,
// but cost is included in the result chunk
let usage: ApiStreamUsageChunk = {
@@ -62,61 +39,75 @@ export class ClaudeCodeHandler implements ApiHandler {
cacheWriteTokens: 0,
}
while (exitCode !== 0 || dataQueue.length > 0) {
if (dataQueue.length === 0) {
await new Promise((resolve) => setImmediate(resolve))
}
let isPaidUsage = true
if (exitCode !== null && exitCode !== 0) {
throw new Error(
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput.trim()}` : ""}`,
)
}
const data = dataQueue.shift()
if (!data) {
continue
}
const chunk = this.attemptParseChunk(data)
if (!chunk) {
for await (const chunk of claudeProcess) {
if (typeof chunk === "string") {
yield {
type: "text",
text: data || "",
text: chunk,
}
continue
}
if (chunk.type === "system" && chunk.subtype === "init") {
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
isPaidUsage = chunk.apiKeySource !== "none"
continue
}
if (chunk.type === "assistant" && "message" in chunk) {
const message = chunk.message
if (message.stop_reason !== null && message.stop_reason !== "tool_use") {
const errorMessage = message.content[0]?.text || `Claude Code stopped with reason: ${message.stop_reason}`
if (message.stop_reason !== null) {
const content = "text" in message.content[0] ? message.content[0] : undefined
if (errorMessage.includes("Invalid model name")) {
throw new Error(
errorMessage +
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
)
const isError = content && content.text.startsWith(`API Error`)
if (isError) {
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
const errorMessageStart = content.text.indexOf("{")
const errorMessage = content.text.slice(errorMessageStart)
const error = this.attemptParse(errorMessage)
if (!error) {
throw new Error(content.text)
}
if (error.error.message.includes("Invalid model name")) {
throw new Error(
content.text +
`\n\nAPI keys and subscription plans allow different models. Make sure the selected model is included in your plan.`,
)
}
throw new Error(errorMessage)
}
throw new Error(errorMessage)
}
for (const content of message.content) {
if (content.type === "text") {
yield {
type: "text",
text: content.text,
}
} else {
console.warn("Unsupported content type:", content.type)
switch (content.type) {
case "text":
yield {
type: "text",
text: content.text,
}
break
case "thinking":
yield {
type: "reasoning",
reasoning: content.thinking || "",
}
break
case "redacted_thinking":
yield {
type: "reasoning",
reasoning: "[Redacted thinking block]",
}
break
case "tool_use":
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
break
}
}
@@ -129,14 +120,18 @@ export class ClaudeCodeHandler implements ApiHandler {
}
if (chunk.type === "result" && "result" in chunk) {
usage.totalCost = chunk.cost_usd || 0
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
yield usage
}
}
}
if (processError) {
throw processError
}
private attemptParse(str: string) {
try {
return JSON.parse(str)
} catch (err) {
return null
}
}
@@ -152,14 +147,4 @@ export class ClaudeCodeHandler implements ApiHandler {
info: claudeCodeModels[claudeCodeDefaultModelId],
}
}
// TOOD: Validate instead of parsing
private attemptParseChunk(data: string): ClaudeCodeMessage | null {
try {
return JSON.parse(data)
} catch (error) {
console.error("Error parsing chunk:", error)
return null
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { geminiDefaultModelId, GeminiModelId, geminiModels, type ApiHandlerOptions } from "@/shared/api"
import { type ApiHandler } from ".."
import { type ApiStream } from "../transform/stream"
import { withRetry } from "../retry"
import { runGeminiCli } from "@/integrations/gemini-cli/run"
import { convertAnthropicToGeminiCliFormat } from "@/integrations/gemini-cli/message-converter"
export class GeminiCliHandler implements ApiHandler {
private options: ApiHandlerOptions
constructor(options: ApiHandlerOptions) {
this.options = options
}
@withRetry({
maxRetries: 4,
baseDelay: 2000,
maxDelay: 15000,
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
// Convert messages to Gemini CLI format
const prompt = convertAnthropicToGeminiCliFormat(systemPrompt, messages)
console.log("[GeminiCliHandler] Starting Gemini CLI with prompt length:", prompt.length)
console.log("[GeminiCliHandler] Model:", this.getModel().id)
console.log("[GeminiCliHandler] Path:", this.options.geminiCliPath || "gemini")
const geminiProcess = runGeminiCli({
prompt,
path: this.options.geminiCliPath,
modelId: this.getModel().id,
})
let totalText = ""
let hasYieldedContent = false
for await (const chunk of geminiProcess) {
if (typeof chunk === "string") {
totalText += chunk
hasYieldedContent = true
console.log("[GeminiCliHandler] Received chunk:", chunk.substring(0, 100) + "...")
yield {
type: "text",
text: chunk,
}
} else if (chunk.type === "error") {
console.error("[GeminiCliHandler] Error from Gemini CLI:", chunk.message)
throw new Error(chunk.message)
}
}
// If no content was yielded, something went wrong
if (!hasYieldedContent) {
throw new Error("Gemini CLI did not return any content")
}
// Since Gemini CLI doesn't provide token usage in non-interactive mode,
// we'll estimate based on the text length
const estimatedTokens = Math.ceil(totalText.length / 4)
yield {
type: "usage",
inputTokens: Math.ceil(prompt.length / 4),
outputTokens: estimatedTokens,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: undefined, // Cost calculation would require actual token counts
}
}
getModel() {
const modelId = this.options.apiModelId
if (modelId && modelId in geminiModels) {
const id = modelId as GeminiModelId
return { id, info: geminiModels[id] }
}
return {
id: geminiDefaultModelId,
info: geminiModels[geminiDefaultModelId],
}
}
}
+9 -2
View File
@@ -101,14 +101,21 @@ export class LiteLlmHandler implements ApiHandler {
return message
})
const stream = await this.client.chat.completions.create({
const requestPayload: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & {
metadata?: { cline_task_id: string }
} = {
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
messages: [enhancedSystemMessage, ...enhancedMessages],
temperature,
stream: true,
stream_options: { include_usage: true },
...(thinkingConfig && { thinking: thinkingConfig }), // Add thinking configuration when applicable
})
...(this.options.taskId && {
metadata: { cline_task_id: this.options.taskId },
}),
}
const stream = await this.client.chat.completions.create(requestPayload)
const inputCost = (await this.calculateCost(1e6, 0)) || 0
const outputCost = (await this.calculateCost(0, 1e6)) || 0
@@ -0,0 +1,23 @@
import { AuthStateChangedRequest, AuthStateChanged } from "@shared/proto/account"
import type { Controller } from "../index"
import { updateGlobalState } from "../../storage/state"
/**
* Handles authentication state changes from the Firebase context.
* Updates the user info in global state and returns the updated value.
* @param controller The controller instance
* @param request The auth state change request
* @returns The updated user info
*/
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthStateChanged> {
try {
// Store the user info directly in global state
await updateGlobalState(controller.context, "userInfo", request.user)
// Return the same user info
return AuthStateChanged.create({ user: request.user })
} catch (error) {
console.error(`Failed to update auth state: ${error}`)
throw error
}
}
@@ -0,0 +1,34 @@
import type { Controller } from "../index"
import type { EmptyRequest } from "@shared/proto/common"
import { UserCreditsData } from "@shared/proto/account"
/**
* Handles fetching all user credits data (balance, usage, payments)
* @param controller The controller instance
* @param request Empty request
* @returns User credits data response
*/
export async function fetchUserCreditsData(controller: Controller, request: EmptyRequest): Promise<UserCreditsData> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Call the individual RPC variants in parallel
const [balance, usageTransactions, paymentTransactions] = await Promise.all([
controller.accountService.fetchBalanceRPC(),
controller.accountService.fetchUsageTransactionsRPC(),
controller.accountService.fetchPaymentTransactionsRPC(),
])
// Since generated types match exactly, no conversion needed!
return UserCreditsData.create({
balance: balance ? { currentBalance: balance.currentBalance } : { currentBalance: 0 },
usageTransactions: usageTransactions || [],
paymentTransactions: paymentTransactions || [],
})
} catch (error) {
console.error(`Failed to fetch user credits data: ${error}`)
throw error
}
}
-21
View File
@@ -1,21 +0,0 @@
import { Controller } from ".."
import { EmptyRequest, StringArray } from "@shared/proto/common"
import { selectImages as selectImagesIntegration } from "@integrations/misc/process-images"
import { FileMethodHandler } from "./index"
/**
* Prompts the user to select images from the file system and returns them as data URLs
* @param controller The controller instance
* @param request Empty request, no parameters needed
* @returns Array of image data URLs
*/
export const selectImages: FileMethodHandler = async (controller: Controller, request: EmptyRequest): Promise<StringArray> => {
try {
const images = await selectImagesIntegration()
return StringArray.create({ values: images })
} catch (error) {
console.error("Error selecting images:", error)
// Return empty array on error
return StringArray.create({ values: [] })
}
}
+30 -163
View File
@@ -16,10 +16,11 @@ import { McpHub } from "@services/mcp/McpHub"
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
import { ApiProvider, ModelInfo } from "@shared/api"
import { ChatContent } from "@shared/ChatContent"
import { ChatSettings } from "@shared/ChatSettings"
import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings"
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { McpMarketplaceCatalog } from "@shared/mcp"
import { UserInfo } from "@shared/UserInfo"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { WebviewMessage } from "@shared/WebviewMessage"
import { fileExistsAtPath } from "@utils/fs"
@@ -43,6 +44,8 @@ import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
import { sendAuthCallbackEvent } from "./account/subscribeToAuthCallback"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
import { sendRelinquishControlEvent } from "./ui/subscribeToRelinquishControl"
import { handleTaskServiceRequest } from "./task"
import { BooleanRequest } from "@shared/proto/common"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -51,10 +54,11 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
*/
export class Controller {
readonly id: string = uuidv4()
readonly id: string
private postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined
private disposables: vscode.Disposable[] = []
private mode: "plan" | "act" = "plan" // In-memory plan/act mode state
task?: Task
workspaceTracker: WorkspaceTracker
mcpHub: McpHub
@@ -65,7 +69,9 @@ export class Controller {
readonly context: vscode.ExtensionContext,
private readonly outputChannel: vscode.OutputChannel,
postMessage: (message: ExtensionMessage) => Thenable<boolean> | undefined,
id: string,
) {
this.id = id
this.outputChannel.appendLine("ClineProvider instantiated")
this.postMessage = postMessage
@@ -122,7 +128,7 @@ export class Controller {
}
}
async setUserInfo(info?: { displayName: string | null; email: string | null; photoURL: string | null }) {
async setUserInfo(info?: UserInfo) {
await updateGlobalState(this.context, "userInfo", info)
}
@@ -132,7 +138,7 @@ export class Controller {
apiConfiguration,
autoApprovalSettings,
browserSettings,
chatSettings,
chatSettings: storedChatSettings,
shellIntegrationTimeout,
terminalReuseEnabled,
terminalOutputLineLimit,
@@ -142,6 +148,12 @@ export class Controller {
taskHistory,
} = await getAllExtensionState(this.context)
// Reconstruct ChatSettings with in-memory mode and stored preferences
const chatSettings: ChatSettings = {
...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort)
mode: this.mode, // Use in-memory mode (override any stored mode)
}
const NEW_USER_TASK_COUNT_THRESHOLD = 10
// Check if the user has completed enough tasks to no longer be considered a "new user"
@@ -202,48 +214,10 @@ export class Controller {
*/
async handleWebviewMessage(message: WebviewMessage) {
switch (message.type) {
case "authStateChanged":
await this.setUserInfo(message.user || undefined)
await this.postStateToWebview()
break
case "fetchUserCreditsData": {
await this.fetchUserCreditsData()
break
}
case "fetchMcpMarketplace": {
await this.fetchMcpMarketplace(message.bool)
break
}
// telemetry
case "telemetrySetting": {
if (message.telemetrySetting) {
await this.updateTelemetrySetting(message.telemetrySetting)
}
await this.postStateToWebview()
break
}
case "clearAllTaskHistory": {
const answer = await vscode.window.showWarningMessage(
"What would you like to delete?",
{ modal: true },
"Delete All Except Favorites",
"Delete Everything",
"Cancel",
)
if (answer === "Delete All Except Favorites") {
await this.deleteNonFavoriteTaskHistory()
await this.postStateToWebview()
} else if (answer === "Delete Everything") {
await this.deleteAllTaskHistory()
await this.postStateToWebview()
}
sendRelinquishControlEvent()
break
}
case "grpc_request": {
if (message.grpc_request) {
await handleGrpcRequest(this, message.grpc_request)
@@ -271,6 +245,9 @@ export class Controller {
async togglePlanActModeWithChatSettings(chatSettings: ChatSettings, chatContent?: ChatContent): Promise<boolean> {
const didSwitchToActMode = chatSettings.mode === "act"
// Store mode in-memory only
this.mode = chatSettings.mode
// Capture mode switch telemetry | Capture regardless of if we know the taskId
telemetryService.captureModeSwitch(this.task?.taskId ?? "0", chatSettings.mode)
@@ -440,7 +417,9 @@ export class Controller {
}
}
await updateWorkspaceState(this.context, "chatSettings", chatSettings)
// Save only non-mode properties to workspace storage
const { mode, ...persistentChatSettings }: { mode: string } & StoredChatSettings = chatSettings
await updateWorkspaceState(this.context, "chatSettings", persistentChatSettings)
await this.postStateToWebview()
if (this.task) {
@@ -494,20 +473,6 @@ export class Controller {
}
}
// Account
async fetchUserCreditsData() {
try {
await Promise.all([
this.accountService?.fetchBalance(),
this.accountService?.fetchUsageTransactions(),
this.accountService?.fetchPaymentTransactions(),
])
} catch (error) {
console.error("Failed to fetch user credits data:", error)
}
}
// Auth
public async validateAuthState(state: string | null): Promise<boolean> {
@@ -850,110 +815,6 @@ export class Controller {
await downloadTask(historyItem.ts, apiConversationHistory)
}
async deleteAllTaskHistory() {
await this.clearTask()
await updateGlobalState(this.context, "taskHistory", undefined)
try {
// Remove all contents of tasks directory
const taskDirPath = path.join(this.context.globalStorageUri.fsPath, "tasks")
if (await fileExistsAtPath(taskDirPath)) {
await fs.rm(taskDirPath, { recursive: true, force: true })
}
// Remove checkpoints directory contents
const checkpointsDirPath = path.join(this.context.globalStorageUri.fsPath, "checkpoints")
if (await fileExistsAtPath(checkpointsDirPath)) {
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
}
} catch (error) {
vscode.window.showErrorMessage(
`Encountered error while deleting task history, there may be some files left behind. Error: ${error instanceof Error ? error.message : String(error)}`,
)
}
// 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 deleteTaskWithId(id: string) {
console.info("deleteTaskWithId: ", id)
try {
if (id === this.task?.taskId) {
await this.clearTask()
console.debug("cleared task")
}
const {
taskDirPath,
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
} = await this.getTaskWithId(id)
const legacyMessagesFilePath = path.join(taskDirPath, "claude_messages.json")
const updatedTaskHistory = await this.deleteTaskFromState(id)
// Delete the task files
for (const filePath of [
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
legacyMessagesFilePath,
]) {
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
await fs.unlink(filePath)
}
}
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
if (updatedTaskHistory.length === 0) {
await this.deleteAllTaskHistory()
}
} catch (error) {
console.debug(`Error deleting task:`, error)
}
await this.postStateToWebview()
}
async deleteTaskFromState(id: string) {
// Remove the task from history
const taskHistory = ((await getGlobalState(this.context, "taskHistory")) as HistoryItem[] | undefined) || []
@@ -968,7 +829,7 @@ export class Controller {
async postStateToWebview() {
const state = await this.getStateToPostToWebview()
await sendStateUpdate(state)
await sendStateUpdate(this.id, state)
}
async getStateToPostToWebview(): Promise<ExtensionState> {
@@ -978,7 +839,7 @@ export class Controller {
taskHistory,
autoApprovalSettings,
browserSettings,
chatSettings,
chatSettings: storedChatSettings,
userInfo,
mcpMarketplaceEnabled,
mcpRichDisplayEnabled,
@@ -995,6 +856,12 @@ export class Controller {
terminalOutputLineLimit,
} = await getAllExtensionState(this.context)
// Reconstruct ChatSettings with in-memory mode and stored preferences
const chatSettings: ChatSettings = {
...storedChatSettings, // Spread stored preferences (preferredLanguage, openAIReasoningEffort)
mode: this.mode, // Use in-memory mode (override any stored mode)
}
const localClineRulesToggles =
((await getWorkspaceState(this.context, "localClineRulesToggles")) as ClineRulesToggles) || {}
@@ -21,9 +21,15 @@ export async function updateApiConfigurationProto(
throw new Error("API configuration is required")
}
console.log("[updateApiConfigurationProto] Received request with provider:", request.apiConfiguration.apiProvider)
console.log("[updateApiConfigurationProto] Full proto config:", request.apiConfiguration)
// Convert proto ApiConfiguration to application ApiConfiguration
const appApiConfiguration = convertProtoToApiConfiguration(request.apiConfiguration)
console.log("[updateApiConfigurationProto] Converted to app config with provider:", appApiConfiguration.apiProvider)
console.log("[updateApiConfigurationProto] Full app config:", appApiConfiguration)
// Update the API configuration in storage
await updateApiConfiguration(controller.context, appApiConfiguration)
+32 -29
View File
@@ -3,8 +3,8 @@ import { Controller } from "../index"
import { EmptyRequest } from "../../../shared/proto/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
// Keep track of active state subscriptions
const activeStateSubscriptions = new Set<StreamingResponseHandler>()
// Keep track of active state subscriptions by controller ID
const activeStateSubscriptions = new Map<string, StreamingResponseHandler>()
/**
* Subscribe to state updates
@@ -19,23 +19,25 @@ export async function subscribeToState(
responseStream: StreamingResponseHandler,
requestId?: string,
): Promise<void> {
const controllerId = controller.id
// Send the initial state
const initialState = await controller.getStateToPostToWebview()
const initialStateJson = JSON.stringify(initialState)
console.log("[DEBUG] set up state subscription")
console.log(`[DEBUG] set up state subscription for controller ${controllerId}`)
await responseStream({
stateJson: initialStateJson,
})
// Add this subscription to the active subscriptions
activeStateSubscriptions.add(responseStream)
// Add this subscription to the active subscriptions with the controller ID
activeStateSubscriptions.set(controllerId, responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeStateSubscriptions.delete(responseStream)
console.log("[DEBUG] Cleaned up state subscription")
activeStateSubscriptions.delete(controllerId)
console.log(`[DEBUG] Cleaned up state subscription for controller ${controllerId}`)
}
// Register the cleanup function with the request registry if we have a requestId
@@ -45,30 +47,31 @@ export async function subscribeToState(
}
/**
* Send a state update to all active subscribers
* Send a state update to a specific controller's subscription
* @param controllerId The ID of the controller to send the state to
* @param state The state to send
*/
export async function sendStateUpdate(state: any): Promise<void> {
const stateJson = JSON.stringify(state)
export async function sendStateUpdate(controllerId: string, state: any): Promise<void> {
// Get the subscription for this specific controller
const responseStream = activeStateSubscriptions.get(controllerId)
// Send the update to all active subscribers
const promises = Array.from(activeStateSubscriptions).map(async (responseStream) => {
try {
// The issue might be that we're not properly formatting the response
// Let's ensure we're sending a properly formatted State message
await responseStream(
{
stateJson,
},
false, // Not the last message
)
console.log("[DEBUG] sending followup state", stateJson.length, "chars")
} catch (error) {
console.error("Error sending state update:", error)
// Remove the subscription if there was an error
activeStateSubscriptions.delete(responseStream)
}
})
if (!responseStream) {
console.log(`[DEBUG] No active state subscription for controller ${controllerId}`)
return
}
await Promise.all(promises)
try {
const stateJson = JSON.stringify(state)
await responseStream(
{
stateJson,
},
false, // Not the last message
)
console.log(`[DEBUG] sending followup state to controller ${controllerId}`, stateJson.length, "chars")
} catch (error) {
console.error(`Error sending state update to controller ${controllerId}:`, error)
// Remove the subscription if there was an error
activeStateSubscriptions.delete(controllerId)
}
}
+1 -1
View File
@@ -58,7 +58,7 @@ export async function updateSettings(controller: Controller, request: UpdateSett
// Update chat settings
if (request.chatSettings) {
const chatSettings = convertProtoChatSettingsToChatSettings(request.chatSettings)
await controller.context.globalState.update("chatSettings", chatSettings)
await controller.context.workspaceState.update("chatSettings", chatSettings)
if (controller.task) {
controller.task.chatSettings = chatSettings
}
@@ -0,0 +1,16 @@
import { Controller } from ".."
import { Empty } from "../../../shared/proto/common"
import { TelemetrySettingRequest } from "../../../shared/proto/state"
import { convertProtoTelemetrySettingToDomain } from "../../../shared/proto-conversions/state/telemetry-setting-conversion"
/**
* Updates the telemetry setting
* @param controller The controller instance
* @param request The telemetry setting request
* @returns Empty response
*/
export async function updateTelemetrySetting(controller: Controller, request: TelemetrySettingRequest): Promise<Empty> {
const telemetrySetting = convertProtoTelemetrySettingToDomain(request.setting)
await controller.updateTelemetrySetting(telemetrySetting)
return Empty.create()
}
@@ -0,0 +1,114 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { DeleteAllTaskHistoryCount } from "../../../shared/proto/task"
import { BooleanRequest } from "../../../shared/proto/common"
import { getGlobalState, updateGlobalState } from "../../storage/state"
import { fileExistsAtPath } from "../../../utils/fs"
import vscode from "vscode"
/**
* Deletes all task history, with an option to preserve favorites
* @param controller The controller instance
* @param request Request with option to preserve favorites
* @returns Results with count of deleted tasks
*/
export async function deleteAllTaskHistory(controller: Controller, request: BooleanRequest): Promise<DeleteAllTaskHistoryCount> {
try {
// Clear current task first
await controller.clearTask()
// Get existing task history
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
const totalTasks = taskHistory.length
// If preserving favorites, filter out non-favorites
if (request.value) {
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
// If there are favorited tasks, update state
if (favoritedTasks.length > 0) {
await updateGlobalState(controller.context, "taskHistory", favoritedTasks)
// Delete non-favorited task directories
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 DeleteAllTaskHistoryCount.create({
tasksDeleted: totalTasks - favoritedTasks.length,
})
} else {
return DeleteAllTaskHistoryCount.create({
tasksDeleted: 0,
})
}
}
// Delete everything (not preserving favorites)
await updateGlobalState(controller.context, "taskHistory", undefined)
try {
// Remove all contents of tasks directory
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
if (await fileExistsAtPath(taskDirPath)) {
await fs.rm(taskDirPath, { recursive: true, force: true })
}
// Remove checkpoints directory contents
const checkpointsDirPath = path.join(controller.context.globalStorageUri.fsPath, "checkpoints")
if (await fileExistsAtPath(checkpointsDirPath)) {
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
}
} catch (error) {
vscode.window.showErrorMessage(
`Encountered error while deleting task history, there may be some files left behind. Error: ${error instanceof Error ? error.message : String(error)}`,
)
}
// Update webview
try {
await controller.postStateToWebview()
} catch (webviewErr) {
console.error("Error posting to webview:", webviewErr)
}
return DeleteAllTaskHistoryCount.create({
tasksDeleted: totalTasks,
})
} catch (error) {
console.error("Error in deleteAllTaskHistory:", 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)) {
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 })
}
}
}
} catch (error) {
console.error("Error cleaning up task files:", error)
}
return true
}
+70 -2
View File
@@ -1,6 +1,9 @@
import path from "path"
import fs from "fs/promises"
import { Controller } from ".."
import { Empty, StringArrayRequest } from "../../../shared/proto/common"
import { Empty, StringArrayRequest, BooleanRequest } from "../../../shared/proto/common"
import { TaskMethodHandler } from "./index"
import { fileExistsAtPath } from "../../../utils/fs"
/**
* Deletes tasks with the specified IDs
@@ -17,7 +20,72 @@ export const deleteTasksWithIds: TaskMethodHandler = async (
throw new Error("Missing task IDs")
}
await Promise.all(request.value.map((value) => controller.deleteTaskWithId(value)))
for (const id of request.value) {
await deleteTaskWithId(controller, id)
}
return Empty.create()
}
/**
* Deletes a single task with the specified ID
* @param controller The controller instance
* @param id The task ID to delete
*/
async function deleteTaskWithId(controller: Controller, id: string): Promise<void> {
console.info("deleteTaskWithId: ", id)
try {
// Clear current task if it matches the ID being deleted
if (id === controller.task?.taskId) {
await controller.clearTask()
console.debug("cleared task")
}
// Get task file paths
const { taskDirPath, apiConversationHistoryFilePath, uiMessagesFilePath, contextHistoryFilePath, taskMetadataFilePath } =
await controller.getTaskWithId(id)
// Remove task from state
const updatedTaskHistory = await controller.deleteTaskFromState(id)
// Delete the task files
for (const filePath of [
apiConversationHistoryFilePath,
uiMessagesFilePath,
contextHistoryFilePath,
taskMetadataFilePath,
]) {
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
await fs.unlink(filePath)
}
}
// Remove empty task directory
try {
await fs.rmdir(taskDirPath) // succeeds if the dir is empty
} catch (error) {
console.debug("Could not remove task directory (may not be empty):", error)
}
// If no tasks remain, clean up everything
if (updatedTaskHistory.length === 0) {
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
const checkpointsDirPath = path.join(controller.context.globalStorageUri.fsPath, "checkpoints")
if (await fileExistsAtPath(taskDirPath)) {
await fs.rm(taskDirPath, { recursive: true, force: true })
}
if (await fileExistsAtPath(checkpointsDirPath)) {
await fs.rm(checkpointsDirPath, { recursive: true, force: true })
}
}
} catch (error) {
console.debug(`Error deleting task ${id}:`, error)
throw error // Re-throw to let caller handle the error
}
// Update webview state
await controller.postStateToWebview()
}
+16
View File
@@ -0,0 +1,16 @@
import type { Controller } from "../index"
import { EmptyRequest, Empty, String } from "@shared/proto/common"
import * as hostProviders from "@hosts/host-providers"
import { WebviewProviderType } from "@/shared/webview/types"
/**
* Initialize webview when it launches
* @param controller The controller instance
* @param request The empty request
* @returns Empty response
*/
export async function getWebviewHtml(_controller: Controller, _: EmptyRequest): Promise<String> {
const webviewProvider = hostProviders.createWebviewProvider(WebviewProviderType.SIDEBAR)
return Promise.resolve(String.create({ value: webviewProvider.getHtmlContent() }))
}
+20
View File
@@ -0,0 +1,20 @@
import type { Controller } from "../index"
import type { StringRequest } from "../../../shared/proto/common"
import { Empty } from "../../../shared/proto/common"
import { openUrlInBrowser } from "../../../utils/github-url-utils"
/**
* Opens a URL in the default browser
* @param controller The controller instance
* @param request The URL to open
* @returns Empty response
*/
export async function openUrl(controller: Controller, request: StringRequest): Promise<Empty> {
try {
await openUrlInBrowser(request.value)
return Empty.create({})
} catch (error) {
console.error(`Failed to open URL: ${error}`)
throw error
}
}
@@ -1,33 +1,33 @@
import { Controller } from "../index"
import { Empty } from "@shared/proto/common"
import { WebviewProviderType, WebviewProviderTypeRequest } from "@shared/proto/ui"
import { EmptyRequest } from "@shared/proto/common"
import { StreamingResponseHandler, getRequestRegistry } from "../grpc-handler"
// Track subscriptions with their provider type
const mcpButtonClickedSubscriptions = new Map<StreamingResponseHandler, WebviewProviderType>()
// Keep track of active mcpButtonClicked subscriptions by controller ID
const activeMcpButtonClickedSubscriptions = new Map<string, StreamingResponseHandler>()
/**
* Subscribe to mcpButtonClicked events
* @param controller The controller instance
* @param request The webview provider type request
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToMcpButtonClicked(
_controller: Controller,
request: WebviewProviderTypeRequest,
controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler,
requestId?: string,
): Promise<void> {
const providerType = request.providerType
console.log(`[DEBUG] set up mcpButtonClicked subscription for ${WebviewProviderType[providerType]} webview`)
const controllerId = controller.id
console.log(`[DEBUG] set up mcpButtonClicked subscription for controller ${controllerId}`)
// Store the subscription with its provider type
mcpButtonClickedSubscriptions.set(responseStream, providerType)
// Add this subscription to the active subscriptions with the controller ID
activeMcpButtonClickedSubscriptions.set(controllerId, responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
mcpButtonClickedSubscriptions.delete(responseStream)
activeMcpButtonClickedSubscriptions.delete(controllerId)
}
// Register the cleanup function with the request registry if we have a requestId
@@ -37,26 +37,27 @@ export async function subscribeToMcpButtonClicked(
}
/**
* Send a mcpButtonClicked event to active subscribers based on webview type
* @param webviewType The type of webview that triggered the event (SIDEBAR or TAB)
* Send a mcpButtonClicked event to a specific controller's subscription
* @param controllerId The ID of the controller to send the event to
*/
export async function sendMcpButtonClickedEvent(webviewType?: WebviewProviderType): Promise<void> {
const event = Empty.create({})
export async function sendMcpButtonClickedEvent(controllerId: string): Promise<void> {
// Get the subscription for this specific controller
const responseStream = activeMcpButtonClickedSubscriptions.get(controllerId)
// Process all subscriptions, filtering based on the source
const promises = Array.from(mcpButtonClickedSubscriptions.entries()).map(async ([responseStream, providerType]) => {
// Only send to subscribers of the same type as the event source
if (webviewType !== providerType) {
return // Skip subscribers of different types
}
if (!responseStream) {
console.error(`[DEBUG] No active subscription for controller ${controllerId}`)
return
}
try {
await responseStream(event, false)
} catch (error) {
console.error(`Error sending mcpButtonClicked event to ${WebviewProviderType[providerType]}:`, error)
mcpButtonClickedSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
try {
const event = Empty.create({})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error(`Error sending mcpButtonClicked event to controller ${controllerId}:`, error)
// Remove the subscription if there was an error
activeMcpButtonClickedSubscriptions.delete(controllerId)
}
}
@@ -128,7 +128,10 @@ ${
const config = JSON.parse(server.config)
return (
`## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` +
`## ${server.name}` +
(config.command
? ` (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)`
: "") +
(tools ? `\n\n### Available Tools\n${tools}` : "") +
(templates ? `\n\n### Resource Templates\n${templates}` : "") +
(resources ? `\n\n### Direct Resources\n${resources}` : "")
+4 -1
View File
@@ -470,7 +470,10 @@ ${
const config = JSON.parse(server.config)
return (
`## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` +
`## ${server.name}` +
(config.command
? ` (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)`
: "") +
(tools ? `\n\n### Available Tools\n${tools}` : "") +
(templates ? `\n\n### Resource Templates\n${templates}` : "") +
(resources ? `\n\n### Direct Resources\n${resources}` : "")
+4 -1
View File
@@ -466,7 +466,10 @@ ${
const config = JSON.parse(server.config)
return (
`## ${server.name} (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)` +
`## ${server.name}` +
(config.command
? ` (\`${config.command}${config.args && Array.isArray(config.args) ? ` ${config.args.join(" ")}` : ""}\`)`
: "") +
(tools ? `\n\n### Available Tools\n${tools}` : "") +
(templates ? `\n\n### Resource Templates\n${templates}` : "") +
(resources ? `\n\n### Direct Resources\n${resources}` : "")
+24 -2
View File
@@ -7,7 +7,7 @@ import { ApiConfiguration, ApiProvider, BedrockModelId, ModelInfo } from "@share
import { HistoryItem } from "@shared/HistoryItem"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import { BrowserSettings } from "@shared/BrowserSettings"
import { ChatSettings } from "@shared/ChatSettings"
import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings"
import { TelemetrySetting } from "@shared/TelemetrySetting"
import { UserInfo } from "@shared/UserInfo"
import { ClineRulesToggles } from "@shared/cline-rules"
@@ -173,6 +173,28 @@ export async function migrateCustomInstructionsToGlobalRules(context: vscode.Ext
}
}
export async function cleanupModeFromWorkspaceStorage(context: vscode.ExtensionContext) {
try {
// Get current chatSettings from workspace storage
const chatSettings = (await getWorkspaceState(context, "chatSettings")) as any
if (chatSettings && typeof chatSettings === "object" && "mode" in chatSettings) {
console.log("Cleaning up mode from workspace storage...")
// Remove mode property from chatSettings
const { mode, ...cleanedChatSettings } = chatSettings
// Save cleaned chatSettings back to workspace storage
await updateWorkspaceState(context, "chatSettings", cleanedChatSettings)
console.log("Successfully removed mode from workspace storage chatSettings")
}
} catch (error) {
console.error("Failed to cleanup mode from workspace storage:", error)
// Continue execution - migration failure shouldn't break extension startup
}
}
export async function getAllExtensionState(context: vscode.ExtensionContext) {
const [
isNewUser,
@@ -360,7 +382,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
previousModeSapAiCoreResourceGroup,
previousModeSapAiCoreModelId,
] = await Promise.all([
getWorkspaceState(context, "chatSettings") as Promise<ChatSettings | undefined>,
getWorkspaceState(context, "chatSettings") as Promise<StoredChatSettings | undefined>,
getWorkspaceState(context, "apiProvider") as Promise<ApiProvider | undefined>,
getWorkspaceState(context, "apiModelId") as Promise<string | undefined>,
getWorkspaceState(context, "thinkingBudgetTokens") as Promise<number | undefined>,
+3
View File
@@ -34,6 +34,9 @@ export class TaskState {
isAwaitingPlanResponse = false
didRespondToPlanAskBySwitchingMode = false
// Context and history
conversationHistoryDeletedRange?: [number, number]
// Tool execution flags
didRejectTool = false
didAlreadyUseTool = false
File diff suppressed because it is too large Load Diff
+52 -2373
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -11,13 +11,14 @@ import * as path from "path"
import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
import { HistoryItem } from "@/shared/HistoryItem"
import Anthropic from "@anthropic-ai/sdk"
import { TaskState } from "./TaskState"
interface MessageStateHandlerParams {
context: vscode.ExtensionContext
taskId: string
conversationHistoryDeletedRange?: [number, number]
taskIsFavorited?: boolean
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
taskState: TaskState
}
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -25,17 +26,17 @@ const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath
export class MessageStateHandler {
private apiConversationHistory: Anthropic.MessageParam[] = []
private clineMessages: ClineMessage[] = []
private conversationHistoryDeletedRange: [number, number] | undefined
private taskIsFavorited: boolean
private checkpointTracker: CheckpointTracker | undefined
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
private context: vscode.ExtensionContext
private taskId: string
private taskState: TaskState
constructor(params: MessageStateHandlerParams) {
this.context = params.context
this.taskId = params.taskId
this.conversationHistoryDeletedRange = params.conversationHistoryDeletedRange
this.taskState = params.taskState
this.taskIsFavorited = params.taskIsFavorited ?? false
this.updateTaskHistory = params.updateTaskHistory
}
@@ -95,7 +96,7 @@ export class MessageStateHandler {
size: taskDirSize,
shadowGitConfigWorkTree: await this.checkpointTracker?.getShadowGitConfigWorkTree(),
cwdOnTaskInitialization: cwd,
conversationHistoryDeletedRange: this.conversationHistoryDeletedRange,
conversationHistoryDeletedRange: this.taskState.conversationHistoryDeletedRange,
isFavorited: this.taskIsFavorited,
})
} catch (error) {
@@ -117,7 +118,7 @@ export class MessageStateHandler {
// these values allow us to reconstruct the conversation history at the time this cline message was created
// it's important that apiConversationHistory is initialized before we add cline messages
message.conversationHistoryIndex = this.apiConversationHistory.length - 1 // NOTE: this is the index of the last added message which is the user message, and once the clinemessages have been presented we update the apiconversationhistory with the completed assistant message. This means when resetting to a message, we need to +1 this index to get the correct assistant message that this tool use corresponds to
message.conversationHistoryDeletedRange = this.conversationHistoryDeletedRange
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
this.clineMessages.push(message)
await this.saveClineMessagesAndUpdateHistory()
}
@@ -15,14 +15,6 @@ https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/c
export class VscodeWebviewProvider extends WebviewProvider implements vscode.WebviewViewProvider {
public webview?: vscode.WebviewView | vscode.WebviewPanel
public static create(
context: vscode.ExtensionContext,
outputChannel: vscode.OutputChannel,
providerType: WebviewProviderType,
) {
return new VscodeWebviewProvider(context, outputChannel, providerType)
}
constructor(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel, providerType: WebviewProviderType) {
super(context, outputChannel, providerType)
}
+15 -2
View File
@@ -28,7 +28,7 @@ export abstract class WebviewProvider {
WebviewProvider.activeInstances.add(this)
this.clientId = uuidv4()
WebviewProvider.clientIdMap.set(this, this.clientId)
this.controller = new Controller(context, outputChannel, (message) => this.postMessageToWebview(message))
this.controller = new Controller(context, outputChannel, (message) => this.postMessageToWebview(message), this.clientId)
}
// Add a method to get the client ID
@@ -58,6 +58,19 @@ export abstract class WebviewProvider {
return findLast(Array.from(this.activeInstances), (instance) => instance.isVisible() === true)
}
public static getActiveInstance(): WebviewProvider | undefined {
return Array.from(this.activeInstances).find((instance) => {
if (
instance.getWebview() &&
instance.getWebview().viewType === "claude-dev.TabPanelProvider" &&
"active" in instance.getWebview()
) {
return instance.getWebview().active === true
}
return false
})
}
public static getAllInstances(): WebviewProvider[] {
return Array.from(this.activeInstances)
}
@@ -187,7 +200,7 @@ export abstract class WebviewProvider {
<link href="${codiconsUri}" rel="stylesheet" />
<link href="${katexCssUri}" rel="stylesheet" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
connect-src https://*.posthog.com https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
connect-src https://*.posthog.com https://*.cline.bot https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com;
font-src ${this.getCspSource()} data:;
style-src ${this.getCspSource()} 'unsafe-inline';
img-src ${this.getCspSource()} https: data:;
+37 -12
View File
@@ -22,13 +22,18 @@ import { WebviewProviderType as WebviewProviderTypeEnum } from "@shared/proto/ui
import { WebviewProviderType } from "./shared/webview/types"
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendAccountButtonClickedEvent } from "./core/controller/ui/subscribeToAccountButtonClicked"
import { migratePlanActGlobalToWorkspaceStorage, migrateCustomInstructionsToGlobalRules } from "./core/storage/state"
import {
migratePlanActGlobalToWorkspaceStorage,
migrateCustomInstructionsToGlobalRules,
cleanupModeFromWorkspaceStorage,
} from "./core/storage/state"
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
import * as hostProviders from "@hosts/host-providers"
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
import { VscodeWebviewProvider } from "./core/webview/VscodeWebviewProvider"
import { ExtensionContext } from "vscode"
/*
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
@@ -51,7 +56,7 @@ export async function activate(context: vscode.ExtensionContext) {
Logger.initialize(outputChannel)
Logger.log("Cline extension activated")
maybeSetupHostProviders()
maybeSetupHostProviders(context)
// Migrate global storage values to workspace storage (one-time cleanup)
await migratePlanActGlobalToWorkspaceStorage(context)
@@ -59,13 +64,16 @@ export async function activate(context: vscode.ExtensionContext) {
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
// Clean up mode from workspace storage (one-time cleanup)
await cleanupModeFromWorkspaceStorage(context)
// Clean up orphaned file context warnings (startup cleanup)
await FileContextTracker.cleanupOrphanedWarnings(context)
// Version checking for autoupdate notification
const currentVersion = context.extension.packageJSON.version
const previousVersion = context.globalState.get<string>("clineVersion")
const sidebarWebview = hostProviders.createWebviewProvider(context, outputChannel, WebviewProviderType.SIDEBAR)
const sidebarWebview = hostProviders.createWebviewProvider(WebviewProviderType.SIDEBAR)
// Initialize test mode and add disposables to context
context.subscriptions.push(...initializeTestMode(context, sidebarWebview))
@@ -141,12 +149,26 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("cline.mcpButtonClicked", (webview: any) => {
console.log("[DEBUG] mcpButtonClicked", webview)
// Pass the webview type to the event sender
const isSidebar = !webview
const webviewType = isSidebar ? WebviewProviderTypeEnum.SIDEBAR : WebviewProviderTypeEnum.TAB
// Will send to appropriate subscribers based on the source webview type
sendMcpButtonClickedEvent(webviewType)
const activeInstance = WebviewProvider.getActiveInstance()
const isSidebar = !webview
if (isSidebar) {
const sidebarInstance = WebviewProvider.getSidebarInstance()
const sidebarInstanceId = sidebarInstance?.getClientId()
if (sidebarInstanceId) {
sendMcpButtonClickedEvent(sidebarInstanceId)
} else {
console.error("[DEBUG] No sidebar instance found, cannot send MCP button event")
}
} else {
const activeInstanceId = activeInstance?.getClientId()
if (activeInstanceId) {
sendMcpButtonClickedEvent(activeInstanceId)
} else {
console.error("[DEBUG] No active instance found, cannot send MCP button event")
}
}
}),
)
@@ -154,7 +176,7 @@ export async function activate(context: vscode.ExtensionContext) {
Logger.log("Opening Cline in new tab")
// (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event)
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
const tabWebview = hostProviders.createWebviewProvider(context, outputChannel, WebviewProviderType.TAB)
const tabWebview = hostProviders.createWebviewProvider(WebviewProviderType.TAB)
//const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
@@ -628,7 +650,7 @@ export async function activate(context: vscode.ExtensionContext) {
} else {
// Create a temporary controller just for this operation
const outputChannel = vscode.window.createOutputChannel("Cline Commit Generator")
const tempController = new Controller(context, outputChannel, () => Promise.resolve(true))
const tempController = new Controller(context, outputChannel, () => Promise.resolve(true), uuidv4())
await tempController.generateGitCommitMessage()
outputChannel.dispose()
@@ -639,10 +661,13 @@ export async function activate(context: vscode.ExtensionContext) {
return createClineAPI(outputChannel, sidebarWebview.controller)
}
function maybeSetupHostProviders() {
function maybeSetupHostProviders(context: ExtensionContext) {
if (!hostProviders.isSetup) {
console.log("Setting up vscode host providers...")
hostProviders.initializeHostProviders(VscodeWebviewProvider.create, vscodeHostBridgeClient)
const createWebview = function (type: WebviewProviderType) {
return new VscodeWebviewProvider(context, outputChannel, type)
}
hostProviders.initializeHostProviders(createWebview, vscodeHostBridgeClient)
}
}

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