mirror of
https://github.com/cline/cline.git
synced 2026-09-09 15:02:23 +08:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba54aaee4 | ||
|
|
80ba1cf173 | ||
|
|
8cd0561911 | ||
|
|
e1e523a35d | ||
|
|
dd45d2aed5 | ||
|
|
fe4cc95ae9 | ||
|
|
9fbf94e469 | ||
|
|
af3d05d590 | ||
|
|
6fdf5c88ac | ||
|
|
ec7311f8d5 | ||
|
|
61a4e79450 | ||
|
|
243c94b535 | ||
|
|
ca87b6099b | ||
|
|
de0ddcfb72 | ||
|
|
4d8676e5ec | ||
|
|
3e3590a937 | ||
|
|
373bfa61f2 | ||
|
|
d52fcc1383 | ||
|
|
86993af2c7 | ||
|
|
add572cc12 | ||
|
|
307b92862b | ||
|
|
020ae3006e | ||
|
|
c6d91be721 | ||
|
|
4d2fec787e | ||
|
|
c8b05cdf9c | ||
|
|
9e69576fb0 | ||
|
|
e6760ed6cc | ||
|
|
27a531c86a | ||
|
|
09c773bd5f | ||
|
|
037a781a6d | ||
|
|
968b46a179 | ||
|
|
5a777f28b6 | ||
|
|
fe9e5bff74 | ||
|
|
e65605535a | ||
|
|
3d100d835a | ||
|
|
b9d3814355 | ||
|
|
12fbc48629 | ||
|
|
d2f1e0cde0 | ||
|
|
efe2388e4a | ||
|
|
247552fcb5 | ||
|
|
656e3276c6 | ||
|
|
0e29f05e28 | ||
|
|
985cb51c39 | ||
|
|
abccde0e2a | ||
|
|
ca984609ca | ||
|
|
6690d392cd | ||
|
|
40244f09fe | ||
|
|
9563a71c8a | ||
|
|
c13e749eed | ||
|
|
8c3fd8ba55 | ||
|
|
8bfa7daa28 | ||
|
|
7cd4be7a68 | ||
|
|
ab9f1a0785 | ||
|
|
68b84f3df4 | ||
|
|
16da0f1e06 | ||
|
|
e898bd8825 | ||
|
|
b7b0e96cc7 | ||
|
|
f00c5f4ecc | ||
|
|
2709ccefcd | ||
|
|
c8f0324536 | ||
|
|
937cebc7de | ||
|
|
d4d5a49e67 | ||
|
|
01d3afe0c5 | ||
|
|
7bdab5613b | ||
|
|
d79722bec0 | ||
|
|
06af720d85 | ||
|
|
b80ef25a33 | ||
|
|
1996543eb0 | ||
|
|
d9dfd57da5 | ||
|
|
890148407a | ||
|
|
8d6a948478 | ||
|
|
98e7fac400 | ||
|
|
090bddbcea | ||
|
|
5b41cf7af4 | ||
|
|
a2f86bde9b | ||
|
|
176f591ba3 | ||
|
|
6f5ff1b407 | ||
|
|
ae076e2506 | ||
|
|
046d674b85 | ||
|
|
e41e80aaf5 | ||
|
|
14ed98ae87 | ||
|
|
f8667f8a9d | ||
|
|
077561fb71 | ||
|
|
97fd1daec6 | ||
|
|
5c6e1d2ce9 | ||
|
|
ec4d515d28 | ||
|
|
59efda3e22 | ||
|
|
936923d334 | ||
|
|
0a57ec3b7d | ||
|
|
670f3a1d62 | ||
|
|
8104f18f5a | ||
|
|
4de7790fa1 | ||
|
|
adfb5a2b6e | ||
|
|
d55a23448d | ||
|
|
1dd164f482 | ||
|
|
50b43c0559 | ||
|
|
943c52f0b3 | ||
|
|
b84084936b | ||
|
|
014910deb9 | ||
|
|
a30cefa595 | ||
|
|
620f402f36 | ||
|
|
22ff68565b | ||
|
|
3e1565da59 | ||
|
|
f8a284c6fe | ||
|
|
39718cc521 | ||
|
|
e945a45102 | ||
|
|
e2f9c38902 | ||
|
|
447a6ba4d5 | ||
|
|
3f914f5092 | ||
|
|
b26997abc0 | ||
|
|
560c79b885 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Refactor chat view into multiple modular files
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Add Claude Sonnet 4 and Opus 4 model in SAP AI Core provider.
|
||||
@@ -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
|
||||
|
||||
Here’s 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,392 @@
|
||||
# General writing guide
|
||||
|
||||
# How I want you to write
|
||||
|
||||
I'm gonna write something technical.
|
||||
|
||||
It's often less about the nitty-gritty details of the tech stuff and more about learning something new or getting a solution handed to me on a silver platter.
|
||||
|
||||
Look, when I read, I want something out of it. So when I write, I gotta remember that my readers want something too. This whole piece? It's about cluing in anyone who writes for me, or wants me to write for them, on how I see this whole writing product thing.
|
||||
|
||||
I'm gonna lay out a checklist of stuff I'd like to have. It'll make the whole writing gig a bit smoother, you know?
|
||||
|
||||
## Crafting Compelling Titles
|
||||
|
||||
I often come across titles like "How to do X with Y,Z technology." These don't excite me because X or Y are usually unfamiliar unless they're already well-known. Its rarely the dream to use X unless X is the dream.
|
||||
|
||||
My dream isn’t to use instructor, its to do something valueble with the data it extracts
|
||||
|
||||
An effective title should:
|
||||
|
||||
- Evoke an emotional response
|
||||
- Highlight someone's goal
|
||||
- Offer a dream or aspiration
|
||||
- Challenge or comment on a belief
|
||||
- Address someone's problems
|
||||
|
||||
I believe it's more impactful to write about specific problems. If this approach works, you can replicate it across various scenarios rather than staying too general.
|
||||
|
||||
- Time management for everyone can be a 15$ ebook
|
||||
- Time management for executives is a 2000$ workshop
|
||||
|
||||
Aim for titles that answer questions you think everyone is asking, or address thoughts people have but can't quite articulate.
|
||||
|
||||
Instead of "How I do something" or "How to do something," frame it from the reader's perspective with "How you can do something." This makes the title more engaging. Just make sure the difference is advisory if the content is subjective. “How I made a million dollars” might be more reasonable than “How to make a million dollars” since you are the subject and the goal might be to share your story in hopes of helping others.
|
||||
|
||||
This approach ultimately trains the reader to have a stronger emotional connection to your content.
|
||||
|
||||
- "How I do X"
|
||||
- "How You Can do X"
|
||||
|
||||
Between these two titles, it's obvious which one resonates more emotionally.
|
||||
|
||||
You can take it further by adding specific conditions. For instance, you could target a particular audience or set a timeframe:
|
||||
|
||||
- How to set up Braintrust
|
||||
- How to set up Braintrust in 5 minutes
|
||||
|
||||
## NO adjectiives
|
||||
|
||||
I want you to almost always avoid adjectives and try to use evidence instead. Instead of saying "production ready," you can write something like "scaling this to 100 servers or 1 million documents per second." Numbers like that will tell you exactly what the specificity of your product is. If you have to use adjectives rather than evidence, you are probably making something up.
|
||||
|
||||
There's no reason to say something like "blazingly fast" unless those things are already known phrases.
|
||||
|
||||
Instead, say "200 times faster" or "30% faster." A 30% improvement in recommendation system speed is insane.
|
||||
|
||||
There's a 200 times performance improvement because we went from one programming language to another. It's just something that's a little bit more expected and understandable.
|
||||
|
||||
Another test that I really like using recently is tracking whether or not the statements you make can be:
|
||||
|
||||
- Visualized
|
||||
- Proven false
|
||||
- Said only by you
|
||||
|
||||
If you can nail all three, the claim you make will be more likely to resonate with an audience because only you can say it.
|
||||
|
||||
Earlier this year, I had an example where I embedded all of Wikipedia in 17 minutes with 20 bucks, and it got half a million views. All we posted was a video of me kicking off the job, and then you can see all the log lines go through. You see the number of containers go from 1 out of 50 to 50 out of 50.
|
||||
|
||||
It was easy to visualize and could have been proven false by being unreproducible. Lastly, Modal is the only company that could do that in such an effortless way, which made it unique.
|
||||
|
||||
## Keep It Digestible
|
||||
- Aim for 5-minute reads
|
||||
- Write at a Grade 10 reading level
|
||||
- Break up long paragraphs
|
||||
- Use headers and bullet points
|
||||
|
||||
## Make It Scannable
|
||||
- Bold key points
|
||||
- Use subheadings every 3-4 paragraphs
|
||||
- Include plenty of white space
|
||||
- Add relevant examples
|
||||
|
||||
This structure works whether you're writing a tweet thread or a full blog post. The key is making complex ideas accessible.
|
||||
|
||||
# Guide to Writing Cline Documentation
|
||||
|
||||
## Some general principles for explaining features
|
||||
|
||||
If you're talking about a feature, it's helpful to start with a human-readable explanations that cover what the feature is in simple terms. Skip jargon and explain it like you're talking to someone who's never seen it before. This sets the foundation for everything that follows.
|
||||
|
||||
Combine location and usage into one flowing section. Tell users exactly where to find the feature and how to use it, but weave the instructions into natural prose with a good balance of bullet points, numbered lists, code examples (if applicable), mintlify components, and headers/subheaders. Users shouldn't have to jump between separate "where is it" and "how do I use it" sections.
|
||||
|
||||
Show the feature in action with real examples like actual files, workflows, or code. Users need to see concrete implementations, not just abstract descriptions. This is where understanding turns into practical knowledge.
|
||||
|
||||
When talking about a feature, include an inspiration section that sparks imagination. This section pushes people from understanding to action by showing them what becomes possible when they use this feature creatively. It's what separates good documentation from great documentation.
|
||||
|
||||
## Writing Principles That Actually Work
|
||||
|
||||
### Write for Action, Not Just Understanding
|
||||
|
||||
Documentation should motivate users to try things. Instead of just explaining how something works, focus on what users can accomplish with it. The inspiration section is crucial - it's what transforms passive readers into active users.
|
||||
|
||||
### Create a Natural Story Flow
|
||||
|
||||
It should feel like a conversation that naturally progresses from "what is this?" to "how do I use it?" to "here's a real example" to "imagine what you could do with this."
|
||||
|
||||
### Show Real Examples, Not Toy Demos
|
||||
|
||||
Provide actual workflow files, real code snippets, and concrete implementations that users can copy and adapt. Abstract examples don't help anyone - users want to see exactly what they'll be working with.
|
||||
|
||||
### Keep It Scannable But Not Fragmented
|
||||
|
||||
Write in prose that flows naturally when read completely, but structure it so users can quickly find specific information when they're troubleshooting. Avoid dense walls of text, but also avoid over-formatting with excessive bullet points and bold headers. There should be a nice visual heirarchy of balance between all elements, so you can quickly scan the page and find what you're looking for.
|
||||
|
||||
## Language and Tone Guidelines
|
||||
|
||||
Write clearly without dumbing things down. Use simple language when possible, but don't avoid technical terms that users need to know. Explain concepts in terms of what users can achieve rather than how the software works internally.
|
||||
|
||||
Make your writing conversational and encouraging. Phrases like "you can also try" or "when that works" feel more natural than rigid instructional language. Help users feel confident about trying new things.
|
||||
|
||||
Keep content concise and purposeful. Every sentence should either help users understand something or help them do something. If it doesn't serve one of those purposes, cut it.
|
||||
|
||||
Build in context and reasoning. Users want to understand why they're doing something, not just what to do. This builds confidence and helps them troubleshoot when things don't work exactly as expected.
|
||||
|
||||
## Practical Implementation
|
||||
|
||||
Structure each feature page consistently with the four-section approach, but let the content flow naturally within that structure. Use visual assets like videos and screenshots to complement the written content - they often communicate more effectively than paragraphs of description.
|
||||
|
||||
Link generously to related resources, examples, and deeper documentation. Users should never feel stuck or wonder where to go next. Maintain a repository of real examples that users can reference and adapt to their own needs.
|
||||
|
||||
The goal is documentation that feels more like helpful guidance from an experienced colleague than a technical manual. Users should finish reading feeling excited about what they can accomplish, not just informed about what the feature does.
|
||||
|
||||
## Balance Structure with Flexibility
|
||||
|
||||
While they discuss having consistent documentation structure, there's also mention of making content feel less rigid and more natural. The writing should follow guidelines while still feeling conversational and engaging.
|
||||
|
||||
## Bad examples
|
||||
|
||||
I personally hate this pattern of bullet point **Bold Text** colon and then more text:
|
||||
<bad_example_of_writing>
|
||||
#### macOS
|
||||
|
||||
1. **Switch to bash**: Go to Cline Settings → Terminal → Default Terminal Profile → Select "bash"
|
||||
2. **Disable Oh-My-Zsh temporarily**: If using zsh, try `mv ~/.zshrc ~/.zshrc.backup` and restart VSCode
|
||||
3. **Set environment**: Add to your shell config: `export TERM=xterm-256color`
|
||||
|
||||
#### Windows
|
||||
|
||||
1. **Use PowerShell 7**: Install from Microsoft Store, then select it in Cline settings
|
||||
2. **Disable Windows ConPTY**: VSCode Settings → Terminal › Integrated: Windows Enable Conpty → Uncheck
|
||||
3. **Try Command Prompt**: Sometimes simpler is better - switch to cmd.exe
|
||||
|
||||
#### Linux
|
||||
|
||||
1. **Use bash**: Most reliable option - select in Cline settings
|
||||
2. **Check permissions**: Ensure VSCode has terminal access permissions
|
||||
3. **Disable custom prompts**: Comment out prompt customizations in `.bashrc`
|
||||
|
||||
</bad_example_of_writing>
|
||||
|
||||
We should instead strive to write beautiful docs that read well. We can use bullet points and numbered lists but it should read naturally and be delightful to look at hierachally when scanning through the doc. There should be a good balance between blocks of text, code snippets, paragraphs, numbered lists, and bullet points. When scanning the documentation visually, you should feel like you're adminiring a tasteful art piece.
|
||||
|
||||
<good_example_of_writing>
|
||||
#### macOS
|
||||
|
||||
The most common fix is switching to bash. Navigate to Cline Settings → Terminal → Default Terminal Profile and select "bash" from the dropdown.
|
||||
|
||||
If you're still having issues, Oh-My-Zsh might be interfering with terminal integration. Try temporarily disabling it:
|
||||
- Run `mv ~/.zshrc ~/.zshrc.backup`
|
||||
- Restart VSCode
|
||||
|
||||
You can also add `export TERM=xterm-256color` to your shell configuration file to improve compatibility.
|
||||
|
||||
#### Windows
|
||||
|
||||
PowerShell 7 provides the most reliable experience. Install it from the Microsoft Store, then select it in your Cline settings.
|
||||
|
||||
Still seeing problems? Try these solutions:
|
||||
- Disable Windows ConPTY: VSCode Settings → Terminal › Integrated: Windows Enable Conpty → uncheck
|
||||
- Switch to Command Prompt (cmd.exe) - sometimes simpler shells work better
|
||||
|
||||
#### Linux
|
||||
|
||||
Bash is your most dependable option. Select it in Cline settings if you haven't already.
|
||||
|
||||
Check these common issues:
|
||||
- Ensure VSCode has terminal access permissions
|
||||
- Temporarily comment out custom prompt configurations in your `.bashrc`
|
||||
</good_example_of_writing>
|
||||
|
||||
This is much more natural to read. Writing this way creates a conversational flow, and bullet points are used idiomatically.
|
||||
|
||||
# Using Mintlify Components Idiomatically
|
||||
|
||||
Mintlify's custom components can transform basic documentation into engaging, scannable content that users actually want to read. Here's how to use them effectively.
|
||||
|
||||
## Visual Content with Frames
|
||||
|
||||
Videos and images should be wrapped in `<Frame>` components rather than using raw HTML or markdown. This creates consistent styling and proper responsive behavior.
|
||||
|
||||
For videos, embed them directly rather than linking externally. Users are much more likely to watch a 30-second demonstration than click through to another platform:
|
||||
|
||||
```jsx
|
||||
<Frame>
|
||||
<iframe
|
||||
style={{ width: "100%", aspectRatio: "16/9" }}
|
||||
src="https://www.youtube.com/embed/your-video-id"
|
||||
title="Feature demonstration"
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen
|
||||
/>
|
||||
</Frame>
|
||||
```
|
||||
|
||||
Screenshots work similarly - the frame provides visual polish and consistency:
|
||||
|
||||
```jsx
|
||||
<Frame>
|
||||
<img src="/path/to/screenshot.png" alt="Descriptive alt text" />
|
||||
</Frame>
|
||||
```
|
||||
|
||||
## Cards for Navigation and Overview
|
||||
|
||||
Cards excel at creating scannable overviews that link to detailed documentation. They're perfect for feature listings, getting started guides, or any section where users need to choose their path.
|
||||
|
||||
Use the two-column layout for related features:
|
||||
|
||||
```jsx
|
||||
<Columns cols={2}>
|
||||
<Card title="Feature Name" icon="relevant-icon" href="/link/to/docs">
|
||||
Brief description that explains what this feature does and why someone would use it.
|
||||
</Card>
|
||||
|
||||
<Card title="Related Feature" icon="another-icon" href="/another/link">
|
||||
Another concise explanation that helps users understand the value proposition.
|
||||
</Card>
|
||||
</Columns>
|
||||
```
|
||||
|
||||
The key is writing card descriptions that are informative enough to help users decide whether to click through, but concise enough to scan quickly. Each card should answer "what does this do?" and "why would I need this?"
|
||||
|
||||
## Tips and Notes for Context
|
||||
|
||||
Use `<Tip>` components for helpful information that enhances the main content without cluttering it:
|
||||
|
||||
```jsx
|
||||
<Tip>
|
||||
Pro tip: You can combine multiple @ mentions in a single message to give Cline
|
||||
comprehensive context about your issue.
|
||||
</Tip>
|
||||
```
|
||||
|
||||
`<Note>` components work well for important caveats or technical limitations:
|
||||
|
||||
```jsx
|
||||
<Note>
|
||||
Due to VS Code limitations, some features require specific settings to work properly.
|
||||
</Note>
|
||||
```
|
||||
|
||||
`<Info>` is also cool:
|
||||
|
||||
<Info>
|
||||
**Quick Fix**: If you're experiencing terminal issues, try switching to a simpler shell like `bash` in the Cline settings.
|
||||
This resolves 90% of terminal integration problems.
|
||||
</Info>
|
||||
|
||||
**Never** fall into that awful **Bold Text** - description pattern that we specifically identified as bad writing. The content should flow naturally as connected thoughts rather than feeling like a templated AI response with forced formatting.
|
||||
|
||||
|
||||
## When to Use Bullet Points and Numbered Lists Strategically
|
||||
|
||||
Bullet points serve functional purposes - use them for:
|
||||
|
||||
**Sequential actions or troubleshooting steps** where users need to follow a specific order:
|
||||
1. Install the extension
|
||||
2. Restart VSCode
|
||||
3. Check the settings panel
|
||||
|
||||
**Lists of related options** where users need to choose one approach:
|
||||
- Try PowerShell 7 for the most reliable experience
|
||||
- Switch to Command Prompt if you're still having issues
|
||||
- Use WSL Bash for Linux compatibility
|
||||
|
||||
**Quick reference items** that users might need to scan quickly when problem-solving.
|
||||
|
||||
**Improving Visual Hierarchy** when there's a wall of text - that's a good time to introduce bullet points or numbered lists.
|
||||
|
||||
Each bulleted item or numbered list should be a discrete action or piece of information that benefits from being visually separated. This is a key weapon you can employ when going for that artwork experience I mentioned earlier.
|
||||
|
||||
<good_example_of_bullet_points>
|
||||
## Finding and Configuring Terminal Settings
|
||||
|
||||
You can access Cline's terminal settings by clicking the settings icon in the Cline sidebar, then navigating to the Terminal section. These settings control how Cline interacts with your system's terminal.
|
||||
|
||||
- The **Default Terminal Profile** setting determines which shell Cline uses for executing commands. If you're experiencing issues, this is usually the first thing to change. I personally keep this set to `bash` on all my systems because it's the most reliable option, even though I use `zsh` for my regular terminal work.
|
||||
|
||||
- **Shell Integration Timeout** controls how long Cline waits for the terminal to become ready. The default is 4 seconds, but if you have a heavy shell configuration (lots of plugins, slow startup scripts), you might need to increase this to 10 or even 15 seconds. I've found that WSL environments and SSH connections often need longer timeouts.
|
||||
|
||||
- The **Enable Aggressive Terminal Reuse** setting determines whether Cline tries to reuse existing terminals even when they're not in the correct directory. When this causes problems (commands running in the wrong directory, virtual environment issues), disabling it creates more terminal instances but ensures each command runs in a clean state.
|
||||
|
||||
- **Terminal Output Line Limit** sets how many lines of output Cline will read from commands. The default of 500 lines works for most cases, but you might need to adjust it. For verbose build outputs, increase it. For commands with progress bars that spam thousands of lines, decrease it to around 100 to avoid consuming too many tokens.
|
||||
</good_example_of_bullet_points>
|
||||
|
||||
## Write Like a Human, Not an AI
|
||||
|
||||
Keep it short and direct. If you can say something in fewer words, do it. Long explanations often confuse more than they help.
|
||||
|
||||
Use normal sentence lengths. Mix short and long sentences naturally, like you would when talking to someone. Avoid those meandering compound sentences that go on forever.
|
||||
|
||||
Cut the corporate speak. Instead of "utilize," say "use." Instead of "in order to," just say "to." Write like you're explaining something to a colleague, not drafting a press release.
|
||||
|
||||
Don't over-explain obvious things. If users are reading documentation, they probably understand basic concepts. Respect their intelligence.
|
||||
|
||||
## Never use em dashes or emojis
|
||||
|
||||
That's pretty much it. Never use them. Only AI writes with em dashes or emojis.
|
||||
|
||||
# Anthropomorphizing Cline
|
||||
|
||||
When referring to Cline, always call him a "him" not an "it".
|
||||
|
||||
Bad example:
|
||||
- When Cline can’t execute commands or read their output, you lose access to one of its most powerful capabilities.
|
||||
|
||||
Good Example:
|
||||
|
||||
- When Cline can’t execute commands or read their output, you lose access to one of his most powerful capabilities.
|
||||
|
||||
# Using "I" when sharing your workflow
|
||||
|
||||
Adding a personal touch goes a long way. There are great examples in the docs currently where I use "I" to share how I personally use cline, from dev to dev. It's a great technique.
|
||||
|
||||
# Crosslinking relevant documentation pages
|
||||
|
||||
Make sure you crosslink when you're done writing the docs. If there are relevant docs, just link to them.
|
||||
|
||||
# Brevity is the soul of wit
|
||||
|
||||
Don't ramble if you don't need to. Use bullet points and numbered lists. Keep things easy to read.
|
||||
|
||||
<bad_example>
|
||||
|
||||
When Cline can't execute commands or read their output, you lose access to one of his most powerful capabilities. Terminal integration problems are frustrating, but they're usually fixable with a few simple changes.
|
||||
|
||||
## The Most Common Problem: Shell Integration Issues
|
||||
|
||||
If you're seeing "Shell integration unavailable" or Cline isn't getting command output, the issue is almost always your shell configuration. Complex shell setups with custom prompts, plugins, and fancy configurations can interfere with VSCode's terminal integration.
|
||||
|
||||
**Switch to bash first.** This fixes the problem 90% of the time. Navigate to Cline Settings → Terminal → Default Terminal Profile and select "bash" from the dropdown. Restart VSCode after making this change.
|
||||
|
||||
Still having issues? Try increasing the shell integration timeout. Go to Cline Settings → Terminal → Shell Integration Timeout and change it from 4 seconds to 10 seconds. Heavy shell configurations need more time to initialize properly.
|
||||
|
||||
If commands are running in the wrong directories or you're seeing weird behavior, disable aggressive terminal reuse. In Cline Settings → Terminal, uncheck "Enable aggressive terminal reuse." This creates more terminal instances but ensures each command runs in a clean environment.
|
||||
|
||||
|
||||
</bad_exaxmple>
|
||||
|
||||
The first part is total filler, useless to any serious developer. You can tell it's written by a non technical person that doesn't value clean, straightforward information.
|
||||
|
||||
<good_example>
|
||||
## Shell Integration Issues
|
||||
|
||||
If you're seeing "Shell integration unavailable" or Cline can't read command output, your shell configuration is interfering with VSCode's terminal integration.
|
||||
|
||||
**Switch to bash first.** Go to Cline Settings → Terminal → Default Terminal Profile and select "bash." This fixes 90% of problems.
|
||||
|
||||
Still broken? Try these:
|
||||
- Increase shell integration timeout to 10 seconds in Cline Settings → Terminal
|
||||
- Disable "aggressive terminal reuse" if commands run in wrong directories
|
||||
- Restart VSCode after making changes
|
||||
</good_example>
|
||||
|
||||
The good version cuts straight to the problem and solution. No hand-holding, no emotional language about frustration, just the facts: what's wrong, how to fix it, what to try next. Respects that developers want information, not sympathy.RetryClaude can make mistakes. Please double-check responses.
|
||||
|
||||
ALWAYS consider your audience. And your audience is devs who don't want their time wasted. Give them the info. I cannot stress this enough. Use bullet points and numbered lists. Prose is good, but every word should actually mean something to the dev reading it.
|
||||
|
||||
# Lastly, before you start writing docs
|
||||
|
||||
1. Internalize these guidelines. I mean it.
|
||||
|
||||
2. Read `docs/docs.json` and get an understanding of the structure of the docs. This will come in handly at the end when you're doing a final pass so you can cross link to docs where relevant.
|
||||
|
||||
3. Read some good examples that I personally wrote and am proud of:
|
||||
|
||||
- docs/features/slash-commands/workflows.mdx
|
||||
- docs/features/slash-commands/new-task.mdx
|
||||
- docs/features/at-mentions/overview.mdx
|
||||
- docs/features/drag-and-drop.mdx
|
||||
|
||||
4. If the user specifies any other instructions make sure you follow them.
|
||||
@@ -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
|
||||
@@ -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
|
||||
- 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.
|
||||
|
||||
✅ 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: 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.
|
||||
|
||||
✅ 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?
|
||||
- What’s 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? What’s 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: 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: willingness-to-contribute
|
||||
attributes:
|
||||
label: Interested in implementing this?
|
||||
description: Optional
|
||||
options:
|
||||
- label: Yes, I’d like to help implement this feature
|
||||
@@ -37,10 +37,6 @@ docs/**
|
||||
!node_modules/@vscode/codicons/dist/codicon.css
|
||||
!node_modules/@vscode/codicons/dist/codicon.ttf
|
||||
|
||||
# Include KaTeX CSS and fonts for LaTeX rendering
|
||||
!webview-ui/node_modules/katex/dist/katex.min.css
|
||||
!webview-ui/node_modules/katex/dist/fonts/**
|
||||
|
||||
# Include default themes JSON files used in getTheme
|
||||
!src/integrations/theme/default-themes/**
|
||||
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [3.18.1]
|
||||
|
||||
- Add support for Claude 4 Sonnet in SAP AI Core provider (Thanks @GTxx!)
|
||||
- Fix ENAMETOOLONG error when using Claude Code provider with long conversation histories (Thanks @BarreiroT!)
|
||||
- Remove Gemini CLI provider because Google asked us to
|
||||
- Fix bug with "Delete All Tasks" functionality
|
||||
|
||||
## [3.18.0]
|
||||
|
||||
- Optimized Cline to work with the Claude 4 family of models, resulting in improved performance, reliability, and new capabilities
|
||||
- Added a new Gemini CLI provider that allows you to use your local Gemini CLI authentication to access Gemini models for free (Thanks @google-gemini!)
|
||||
- Optimized Cline to work with the Gemini 2.5 family of models
|
||||
- Updated the default and recommended model to Claude 4 Sonnet for the best performance
|
||||
- Fix race condition in Plan/Act mode switching
|
||||
- Improve robustness of search and replace parsing
|
||||
|
||||
## [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!)
|
||||
|
||||
+59
-12
@@ -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
|
||||
@@ -86,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:
|
||||
|
||||
@@ -30,7 +30,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Meet Cline, an AI assistant that can use your **CLI** a**N**d **E**ditor.
|
||||
Meet Cline (pronounced /klaɪn/, like "Klein"), an AI assistant that can use your **CLI** a**N**d **E**ditor.
|
||||
|
||||
Thanks to [Claude 3.7 Sonnet's agentic coding capabilities](https://www.anthropic.com/claude/sonnet), Cline can handle complex software development tasks step-by-step. With tools that let him create & edit files, explore large projects, use the browser, and execute terminal commands (after you grant permission), he can assist you in ways that go beyond code completion or tech support. Cline can even use the Model Context Protocol (MCP) to create new tools and extend his own capabilities. While autonomous AI scripts traditionally run in sandboxed environments, this extension provides a human-in-the-loop GUI to approve every file change and terminal command, providing a safe and accessible way to explore the potential of agentic AI.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -120,7 +120,7 @@ Example of context window usage over 50% with a 200K context window:
|
||||
# Context Window Usage
|
||||
|
||||
105,000 / 200,000 tokens (53%)
|
||||
Model: anthropic/claude-3.7-sonnet (200K context window)
|
||||
Model: anthropic/claude-sonnet-4 (200K context window)
|
||||
\`\`\`
|
||||
|
||||
**IMPORTANT**: When you see context window usage at or above 50%, you MUST:
|
||||
|
||||
@@ -6,7 +6,7 @@ title: "Telemetry"
|
||||
|
||||
To help make Cline better for everyone, we collect anonymous usage data that helps us understand how developers are using our open-source AI coding agent. This feedback loop is crucial for improving Cline's capabilities and user experience.
|
||||
|
||||
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/telemetry/TelemetryService.ts) to see exactly what we track.
|
||||
We use PostHog, an open-source analytics platform, for data collection and analysis. Our telemetry implementation is fully transparent - you can review the [source code](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see exactly what we track.
|
||||
|
||||
### Tracking Policy
|
||||
|
||||
@@ -22,7 +22,7 @@ We collect basic anonymous usage data including:
|
||||
**System Context:** OS type and VS Code environment details\
|
||||
**UI Activity:** Navigation patterns and feature usage
|
||||
|
||||
For complete transparency, you can inspect our [telemetry implementation](https://github.com/cline/cline/blob/main/src/services/telemetry/TelemetryService.ts) to see the exact events we track.
|
||||
For complete transparency, you can inspect our [telemetry implementation](https://github.com/cline/cline/blob/main/src/services/posthog/telemetry/TelemetryService.ts) to see the exact events we track.
|
||||
|
||||
### How to Opt Out
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
+3
-1
@@ -19,4 +19,6 @@ diff_editing/test_outputs/
|
||||
.cache
|
||||
|
||||
# Python bytecode cache
|
||||
*__pycache__/
|
||||
*__pycache__/
|
||||
|
||||
diff-edits/cases.zip
|
||||
@@ -14,6 +14,9 @@ interface RunDiffEvalOptions {
|
||||
testPath: string
|
||||
outputPath: string
|
||||
replay: boolean
|
||||
replayRunId?: string
|
||||
diffApplyFile?: string
|
||||
saveLocally: boolean
|
||||
maxCases?: number
|
||||
}
|
||||
|
||||
@@ -56,6 +59,14 @@ 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")
|
||||
}
|
||||
@@ -64,6 +75,10 @@ export async function runDiffEvalHandler(options: RunDiffEvalOptions) {
|
||||
args.push("--max-cases", String(options.maxCases))
|
||||
}
|
||||
|
||||
if (options.saveLocally) {
|
||||
args.push("--save-locally")
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(chalk.gray(`Executing: npx tsx ${scriptPath} ${args.join(" ")}`))
|
||||
|
||||
|
||||
@@ -93,6 +93,9 @@ program
|
||||
.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("--save-locally", "Save results to local JSON files in addition to database", false)
|
||||
.option("-v, --verbose", "Enable verbose logging", false)
|
||||
.action(async (options) => {
|
||||
try {
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
parseAssistantMessageV3,
|
||||
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 constructNewFileContentV3 } from "../../src/core/assistant-message/diff" // this defaults to the new v1 when called
|
||||
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_23_25 } from "./diff-apply/diff-06-23-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_25_25 } from "./diff-apply/diff-06-25-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_26_25 } from "./diff-apply/diff-06-26-25"
|
||||
|
||||
type ParseAssistantMessageFn = (message: string) => AssistantMessageContent[]
|
||||
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string>
|
||||
type ConstructNewFileContentFn = (diff: string, original: string, strict: boolean) => Promise<string | any>
|
||||
|
||||
const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
|
||||
parseAssistantMessageV1: parseAssistantMessageV1,
|
||||
@@ -21,9 +23,10 @@ const parsingFunctions: Record<string, ParseAssistantMessageFn> = {
|
||||
}
|
||||
|
||||
const diffEditingFunctions: Record<string, ConstructNewFileContentFn> = {
|
||||
constructNewFileContentV1: constructNewFileContentV1,
|
||||
constructNewFileContentV2: constructNewFileContentV2,
|
||||
constructNewFileContentV3: constructNewFileContentV3, // position invariant diff
|
||||
"diff-06-06-25": constructNewFileContent_06_06_25,
|
||||
"diff-06-23-25": constructNewFileContent_06_23_25,
|
||||
"diff-06-25-25": constructNewFileContent_06_25_25,
|
||||
"diff-06-26-25": constructNewFileContent_06_26_25,
|
||||
}
|
||||
|
||||
import { TestInput, TestResult, ExtractedToolCall } from "./types"
|
||||
@@ -151,6 +154,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
diffEditFunction,
|
||||
thinkingBudgetTokens,
|
||||
originalDiffEditToolCallMessage,
|
||||
diffApplyFile,
|
||||
} = input
|
||||
|
||||
const requiredParams = {
|
||||
@@ -176,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 {
|
||||
@@ -306,10 +310,18 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
|
||||
// checking if the diff edit succeeds, if it failed it will throw an error
|
||||
let diffSuccess = true
|
||||
let replacementData: any = undefined
|
||||
try {
|
||||
await constructNewFileContent(diffToolContent, originalFile, true)
|
||||
const result = await constructNewFileContent(diffToolContent, originalFile, true)
|
||||
|
||||
// Check if result is an object with replacements (new format)
|
||||
if (typeof result === 'object' && result !== null && 'replacements' in result) {
|
||||
replacementData = result.replacements
|
||||
}
|
||||
// If it's just a string, diffSuccess stays true and replacementData stays undefined
|
||||
} catch (error: any) {
|
||||
diffSuccess = false
|
||||
console.log("ERROR:",error)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -318,6 +330,7 @@ export async function runSingleEvaluation(input: TestInput): Promise<TestResult>
|
||||
toolCalls: detectedToolCalls,
|
||||
diffEdit: diffToolContent,
|
||||
diffEditSuccess: diffSuccess,
|
||||
replacementData: replacementData,
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
|
||||
@@ -63,9 +63,22 @@ For example, if we ask for 5 valid attempts per test case, the system will keep
|
||||
|
||||
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.
|
||||
|
||||
## Some known edge cases
|
||||
## Replays
|
||||
|
||||
I noticed that some of the current conversation jsons in the `./cases` folder are a little big bogus. Here's a running list of these areas:
|
||||
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.
|
||||
|
||||
- ~~What if the conversation json was using a model with a massive context window, like Google Gemini's 1M context window, and we're now re-rolling that case on a smaller context window model like claude 4 (200k) or grok-3 (128k)? It's def gonna fail, and we shouldn't just keep trying. We should have a smart system for selecting which cases we can use given the arguments passed in. For example, if we pass in claude and grok with `max cases = 2`, we shouldn't just pick the first two jsons in the folder. We should go through, use a tokenizer, and make sure it would fit with some padding like 20k tokens. Use tiktoken. 20k padding will be sufficient even though different models tokenize differently.~~
|
||||
- There are some weird jsons, where something weird happen, where essentially there's a fluke. Maybe the user was using an extremely dumb model that just hallucinated a fake filepath. Now when we try to reroll that case, we never get a valid case. This can easily be handled by making sure that the file is present before selecting that eval for testing. By "file is present" I mean, that file_contents is present in the eval. Additionally, we should in the streamlit dashboard show cases where getting a valid attempt is a challenge, so we can review those cases more easily and throw them out if they're bogus. Maybe a special tab/page in the dashboard for this purpose. Across all runs / cases, what the most consistently problematic cases are. Pop one open to see the case formatted json with all the user/assistant turns.
|
||||
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.
|
||||
|
||||
Here’s 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.
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { runSingleEvaluation, TestInput, TestResult } from "./ClineWrapper"
|
||||
import { parseAssistantMessageV2, AssistantMessageContent } from "./parsing/parse-assistant-message-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_06_25 } from "./diff-apply/diff-06-06-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_23_25 } from "./diff-apply/diff-06-23-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_25_25 } from "./diff-apply/diff-06-25-25"
|
||||
import { constructNewFileContent as constructNewFileContent_06_26_25 } from "./diff-apply/diff-06-26-25"
|
||||
import { constructNewFileContent as constructNewFileContentV3 } from "../../src/core/assistant-message/diff"
|
||||
import { basicSystemPrompt } from "./prompts/basicSystemPrompt-06-06-25"
|
||||
import { claude4SystemPrompt } from "./prompts/claude4SystemPrompt-06-06-25"
|
||||
import { formatResponse } from "./helpers"
|
||||
@@ -18,6 +24,10 @@ import {
|
||||
insertResult,
|
||||
DatabaseClient,
|
||||
CreateResultInput,
|
||||
getResultsByRun,
|
||||
getCaseById,
|
||||
getFileByHash,
|
||||
getBenchmarkRun,
|
||||
} from "./database"
|
||||
|
||||
// Load environment variables from .env file
|
||||
@@ -184,6 +194,78 @@ class NodeTestRunner {
|
||||
log(isVerbose, `✓ Created ${this.caseIdMap.size} database case records`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store replay result in database, copying original data but with new diffing results
|
||||
*/
|
||||
async storeReplayResultInDatabase(replayResult: TestResult, originalResult: any, testId: string, newCaseId: string): Promise<void> {
|
||||
if (!this.currentRunId || !this.processingFunctionsHash) {
|
||||
return; // Skip if database not initialized
|
||||
}
|
||||
|
||||
try {
|
||||
// Map error string to error enum (simple mapping)
|
||||
const errorEnum = this.mapErrorToEnum(replayResult.error);
|
||||
|
||||
// Store diff edit content if available
|
||||
let fileEditedHash: string | undefined;
|
||||
if (replayResult.diffEdit) {
|
||||
fileEditedHash = await upsertFile({
|
||||
filepath: `diff-edit-${testId}`,
|
||||
content: replayResult.diffEdit
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate basic metrics from diff edit if available
|
||||
let numEdits = 0;
|
||||
let numLinesAdded = 0;
|
||||
let numLinesDeleted = 0;
|
||||
|
||||
if (replayResult.diffEdit) {
|
||||
// Simple parsing to count edits - count SEARCH/REPLACE blocks
|
||||
const searchBlocks = (replayResult.diffEdit.match(/------- SEARCH/g) || []).length;
|
||||
numEdits = searchBlocks;
|
||||
|
||||
// Count added/deleted lines (rough approximation)
|
||||
const lines = replayResult.diffEdit.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('+') && !line.startsWith('+++')) {
|
||||
numLinesAdded++;
|
||||
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
||||
numLinesDeleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy original result data but update replay-specific fields
|
||||
const resultInput: CreateResultInput = {
|
||||
run_id: this.currentRunId, // New run ID
|
||||
case_id: newCaseId, // New case ID
|
||||
model_id: originalResult.model_id, // Copy from original
|
||||
processing_functions_hash: this.processingFunctionsHash, // New processing functions
|
||||
succeeded: replayResult.success && (replayResult.diffEditSuccess ?? false), // New result
|
||||
error_enum: errorEnum, // New error if any
|
||||
num_edits: numEdits || originalResult.num_edits, // New or original
|
||||
num_lines_deleted: numLinesDeleted || originalResult.num_lines_deleted, // New or original
|
||||
num_lines_added: numLinesAdded || originalResult.num_lines_added, // New or original
|
||||
// Copy timing and cost data from original (since we didn't make API calls)
|
||||
time_to_first_token_ms: originalResult.time_to_first_token_ms,
|
||||
time_to_first_edit_ms: originalResult.time_to_first_edit_ms,
|
||||
time_round_trip_ms: originalResult.time_round_trip_ms,
|
||||
cost_usd: originalResult.cost_usd,
|
||||
completion_tokens: originalResult.completion_tokens,
|
||||
// Use original model output (since we're replaying)
|
||||
raw_model_output: originalResult.raw_model_output,
|
||||
file_edited_hash: fileEditedHash || originalResult.file_edited_hash,
|
||||
parsed_tool_call_json: replayResult.toolCalls ? JSON.stringify(replayResult.toolCalls) : originalResult.parsed_tool_call_json
|
||||
};
|
||||
|
||||
await insertResult(resultInput);
|
||||
} catch (error) {
|
||||
console.error(`Failed to store replay result in database for ${testId}:`, error);
|
||||
// Continue execution - don't fail the test run
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store test result in database
|
||||
*/
|
||||
@@ -394,6 +476,144 @@ class NodeTestRunner {
|
||||
}
|
||||
}
|
||||
|
||||
async runDatabaseReplay(replayRunId: string, diffApplyFile: string, isVerbose: boolean) {
|
||||
log(isVerbose, `Starting database replay for run_id: ${replayRunId}`)
|
||||
log(isVerbose, `Using diff apply file: ${diffApplyFile}`)
|
||||
|
||||
// 1. Get the correct diffing function
|
||||
const diffEditingFunctions: Record<string, any> = {
|
||||
"diff-06-06-25": constructNewFileContent_06_06_25,
|
||||
"diff-06-23-25": constructNewFileContent_06_23_25,
|
||||
"diff-06-25-25": constructNewFileContent_06_25_25,
|
||||
"diff-06-26-25": constructNewFileContent_06_26_25,
|
||||
constructNewFileContentV3: constructNewFileContentV3,
|
||||
}
|
||||
const constructNewFileContent = diffEditingFunctions[diffApplyFile]
|
||||
|
||||
if (!constructNewFileContent) {
|
||||
throw new Error(`Could not find diff apply function for: ${diffApplyFile}`)
|
||||
}
|
||||
log(isVerbose, `Successfully loaded diff apply function: ${diffApplyFile}`)
|
||||
|
||||
// 2. Fetch original run data
|
||||
const originalResults = await getResultsByRun(replayRunId)
|
||||
if (originalResults.length === 0) {
|
||||
throw new Error(`No results found for run_id: ${replayRunId}`)
|
||||
}
|
||||
log(isVerbose, `Found ${originalResults.length} results to replay.`)
|
||||
|
||||
const originalRun = await getBenchmarkRun(replayRunId)
|
||||
if (!originalRun) {
|
||||
throw new Error(`Could not find original run with id ${replayRunId}`)
|
||||
}
|
||||
|
||||
// 3. Create a new benchmark run for the replay
|
||||
const replayRunDescription = `Replay of run ${replayRunId} using ${diffApplyFile}`
|
||||
this.currentRunId = await createBenchmarkRun({
|
||||
description: replayRunDescription,
|
||||
system_prompt_hash: originalRun.system_prompt_hash,
|
||||
})
|
||||
log(isVerbose, `Created new run for replay: ${this.currentRunId}`)
|
||||
|
||||
// 4. Set up processing functions for the new run
|
||||
this.processingFunctionsHash = await upsertProcessingFunctions({
|
||||
name: `replay-${diffApplyFile}`,
|
||||
parsing_function: "parseAssistantMessageV2",
|
||||
diff_edit_function: diffApplyFile,
|
||||
})
|
||||
|
||||
// 5. Process each result from the original run
|
||||
let replayedCount = 0
|
||||
const caseIdMirror: Map<string, string> = new Map()
|
||||
|
||||
for (const originalResult of originalResults) {
|
||||
// 5a. Basic validation to ensure we can even process this
|
||||
if (!originalResult.case_id) {
|
||||
log(isVerbose, `Skipping result ${originalResult.result_id} due to missing case_id.`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 5b. Mirror the case for the new run, reusing if already created
|
||||
let newCaseId = caseIdMirror.get(originalResult.case_id)
|
||||
if (!newCaseId) {
|
||||
const originalCase = await getCaseById(originalResult.case_id)
|
||||
if (!originalCase) {
|
||||
log(isVerbose, `Skipping result ${originalResult.result_id} because original case could not be found.`)
|
||||
continue
|
||||
}
|
||||
newCaseId = await createCase({
|
||||
run_id: this.currentRunId,
|
||||
description: `Replay of case ${originalCase.case_id} from run ${replayRunId}`,
|
||||
system_prompt_hash: originalCase.system_prompt_hash,
|
||||
task_id: originalCase.task_id,
|
||||
tokens_in_context: originalCase.tokens_in_context,
|
||||
file_hash: originalCase.file_hash,
|
||||
})
|
||||
caseIdMirror.set(originalResult.case_id, newCaseId)
|
||||
}
|
||||
|
||||
// 5c. Determine if the original attempt was a "valid attempt"
|
||||
const isValidOriginalAttempt = originalResult.error_enum === null || originalResult.error_enum === 3 // 3 is diff_edit_error
|
||||
|
||||
const newResultInput: CreateResultInput = {
|
||||
...(originalResult as any),
|
||||
run_id: this.currentRunId,
|
||||
case_id: newCaseId,
|
||||
processing_functions_hash: this.processingFunctionsHash,
|
||||
}
|
||||
delete (newResultInput as any).result_id
|
||||
|
||||
if (isValidOriginalAttempt) {
|
||||
// This was a valid attempt. Re-run the diff algorithm.
|
||||
const originalCase = await getCaseById(originalResult.case_id)
|
||||
if (!originalCase) {
|
||||
log(isVerbose, ` [WARN] Replay for result ${originalResult.result_id}: Could not find original case. Copying original result.`)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
} else {
|
||||
const originalFile = originalCase.file_hash ? await getFileByHash(originalCase.file_hash) : null
|
||||
const parsedToolCall = originalResult.parsed_tool_call_json ? JSON.parse(originalResult.parsed_tool_call_json)[0] : null
|
||||
const diffContent = parsedToolCall?.input?.diff
|
||||
|
||||
if (originalFile && diffContent) {
|
||||
let diffSuccess = false
|
||||
try {
|
||||
await constructNewFileContent(diffContent, originalFile.content, true)
|
||||
diffSuccess = true
|
||||
log(isVerbose, ` [OK] Replay for task ${originalCase.task_id}: Diff applied successfully.`)
|
||||
} catch (e) {
|
||||
diffSuccess = false
|
||||
log(isVerbose, ` [FAIL] Replay for task ${originalCase.task_id}: New diff algorithm failed.`)
|
||||
}
|
||||
newResultInput.succeeded = diffSuccess
|
||||
newResultInput.error_enum = diffSuccess ? undefined : 3 // 3 = diff_edit_error
|
||||
} else {
|
||||
// Something is wrong with the ground truth data, just copy it.
|
||||
log(
|
||||
isVerbose,
|
||||
` [WARN] Replay for task ${originalCase.task_id}: Valid original attempt but missing file or diff content. Copying original result.`,
|
||||
)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This was not a valid attempt. Just copy the original result's outcome.
|
||||
log(isVerbose, ` [SKIP] Replay for task ${originalResult.case_id}: Invalid original attempt. Copying original result.`)
|
||||
newResultInput.succeeded = originalResult.succeeded
|
||||
newResultInput.error_enum = originalResult.error_enum
|
||||
}
|
||||
|
||||
await insertResult(newResultInput)
|
||||
replayedCount++
|
||||
}
|
||||
|
||||
log(isVerbose, `\n✓ Database replay completed successfully.`)
|
||||
log(isVerbose, ` Total original results: ${originalResults.length}`)
|
||||
log(isVerbose, ` Total replayed results: ${replayedCount}`)
|
||||
log(isVerbose, ` New run ID: ${this.currentRunId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single test example
|
||||
*/
|
||||
@@ -420,6 +640,7 @@ class NodeTestRunner {
|
||||
diffEditFunction: testConfig.diff_edit_function,
|
||||
thinkingBudgetTokens: testConfig.thinking_tokens_budget,
|
||||
originalDiffEditToolCallMessage: testConfig.replay ? testCase.original_diff_edit_tool_call_message : undefined,
|
||||
diffApplyFile: testConfig.diff_apply_file,
|
||||
}
|
||||
|
||||
if (isVerbose) {
|
||||
@@ -708,10 +929,13 @@ async function main() {
|
||||
.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("--diff-edit-function <name>", "The diff editing function to use", "diff-06-25-25")
|
||||
.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("--save-locally", "Save results to local JSON files in addition to database", false)
|
||||
.option("-v, --verbose", "Enable verbose logging", false)
|
||||
.option("--max-concurrency <number>", "Maximum number of parallel requests", "80")
|
||||
|
||||
@@ -722,6 +946,7 @@ async function main() {
|
||||
const isVerbose = options.verbose
|
||||
const testPath = options.testPath
|
||||
const outputPath = options.outputPath
|
||||
const saveLocally = options.saveLocally
|
||||
const maxConcurrency = parseInt(options.maxConcurrency, 10);
|
||||
|
||||
// Parse model IDs from comma-separated string
|
||||
@@ -732,6 +957,17 @@ async function main() {
|
||||
}
|
||||
|
||||
const validAttemptsPerCase = parseInt(options.validAttemptsPerCase, 10);
|
||||
|
||||
const runner = new NodeTestRunner(options.replay || !!options.replayRunId)
|
||||
|
||||
if (options.replayRunId) {
|
||||
if (!options.diffApplyFile) {
|
||||
console.error("Error: --diff-apply-file is required when using --replay-run-id")
|
||||
process.exit(1)
|
||||
}
|
||||
await runner.runDatabaseReplay(options.replayRunId, options.diffApplyFile, isVerbose)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
@@ -918,6 +1154,12 @@ async function main() {
|
||||
const durationSeconds = ((endTime - startTime) / 1000).toFixed(2)
|
||||
log(isVerbose, `\n-Total execution time: ${durationSeconds} seconds`)
|
||||
|
||||
// Save results locally if requested
|
||||
if (saveLocally) {
|
||||
runner.saveTestResults(results, outputPath);
|
||||
log(isVerbose, `✓ Results also saved to JSON files in ${outputPath}`);
|
||||
}
|
||||
|
||||
log(isVerbose, `\n✓ All results stored in database. Use the dashboard to view results.`)
|
||||
} catch (error) {
|
||||
console.error("\nError running tests:", error)
|
||||
|
||||
@@ -420,21 +420,33 @@ def render_model_comparison_cards(model_performance):
|
||||
metric_col1, metric_col2, metric_col3, metric_col4 = st.columns(4)
|
||||
|
||||
with metric_col1:
|
||||
st.metric("Avg Latency", f"{model['avg_round_trip_ms']:.0f}ms")
|
||||
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:
|
||||
st.metric("Avg Cost", f"${model['avg_cost']:.4f}")
|
||||
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:
|
||||
st.metric("First Token", f"{model['avg_first_token_ms']:.0f}ms")
|
||||
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']
|
||||
# Update URL with model_id for drill down
|
||||
st.query_params["model_id"] = model['model_id']
|
||||
st.rerun()
|
||||
|
||||
st.divider() # Add a divider between models
|
||||
|
||||
@@ -835,6 +847,11 @@ def main():
|
||||
if 'selected_run_id' not in st.session_state:
|
||||
st.session_state.selected_run_id = None
|
||||
|
||||
# Handle URL parameters for direct linking
|
||||
query_params = st.query_params
|
||||
url_run_id = query_params.get("run_id")
|
||||
url_model_id = query_params.get("model_id")
|
||||
|
||||
# Load all runs for sidebar
|
||||
all_runs = load_all_runs()
|
||||
|
||||
@@ -842,6 +859,18 @@ def main():
|
||||
st.error("No evaluation runs found in the database.")
|
||||
st.stop()
|
||||
|
||||
# Set initial run selection from URL or default to latest
|
||||
if url_run_id and url_run_id in all_runs['run_id'].values:
|
||||
if st.session_state.selected_run_id != url_run_id:
|
||||
st.session_state.selected_run_id = url_run_id
|
||||
st.session_state.drill_down_model = None # Reset drill down when changing runs via URL
|
||||
elif st.session_state.selected_run_id is None:
|
||||
st.session_state.selected_run_id = all_runs.iloc[0]['run_id'] # Default to latest
|
||||
|
||||
# Set drill down model from URL
|
||||
if url_model_id and st.session_state.selected_run_id == url_run_id:
|
||||
st.session_state.drill_down_model = url_model_id
|
||||
|
||||
# Sidebar for run selection
|
||||
with st.sidebar:
|
||||
st.markdown("## 📊 Evaluation Runs")
|
||||
@@ -887,6 +916,10 @@ def main():
|
||||
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
|
||||
# Update URL with new run_id
|
||||
st.query_params["run_id"] = st.session_state.selected_run_id
|
||||
if "model_id" in st.query_params:
|
||||
del st.query_params["model_id"] # Clear model_id when changing runs
|
||||
st.rerun()
|
||||
|
||||
# Show run details in sidebar
|
||||
@@ -897,6 +930,57 @@ def main():
|
||||
st.markdown(f"**Created:** {selected_run['created_at']}")
|
||||
if selected_run['description']:
|
||||
st.markdown(f"**Description:** {selected_run['description']}")
|
||||
|
||||
# Show shareable URL
|
||||
st.markdown("---")
|
||||
st.markdown("### 🔗 Share This View")
|
||||
|
||||
# Build current URL
|
||||
# Dynamically derive the base URL
|
||||
server_address = st.server.server_address if hasattr(st.server, 'server_address') else "localhost"
|
||||
server_port = st.server.server_port if hasattr(st.server, 'server_port') else "8501"
|
||||
base_url = f"http://{server_address}:{server_port}"
|
||||
current_url = f"{base_url}/?run_id={st.session_state.selected_run_id}"
|
||||
if st.session_state.drill_down_model:
|
||||
current_url += f"&model_id={st.session_state.drill_down_model}"
|
||||
|
||||
st.markdown("**Current URL:**")
|
||||
st.code(current_url, language=None)
|
||||
|
||||
# Copy button using HTML/JS
|
||||
copy_button_html = f"""
|
||||
<button onclick="copyToClipboard('{current_url}')" style="
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ccc;
|
||||
background: #f0f2f6;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
">📋 Copy Link</button>
|
||||
<script>
|
||||
function copyToClipboard(text) {{
|
||||
navigator.clipboard.writeText(text).then(function() {{
|
||||
// Success feedback
|
||||
event.target.innerText = '✅ Copied!';
|
||||
event.target.style.backgroundColor = '#d4edda';
|
||||
setTimeout(() => {{
|
||||
event.target.innerText = '📋 Copy Link';
|
||||
event.target.style.backgroundColor = '#f0f2f6';
|
||||
}}, 2000);
|
||||
}}, function(err) {{
|
||||
// Error feedback
|
||||
event.target.innerText = '❌ Failed';
|
||||
event.target.style.backgroundColor = '#f8d7da';
|
||||
setTimeout(() => {{
|
||||
event.target.innerText = '📋 Copy Link';
|
||||
event.target.style.backgroundColor = '#f0f2f6';
|
||||
}}, 2000);
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
"""
|
||||
st.components.v1.html(copy_button_html, height=50)
|
||||
|
||||
# Load data for selected run
|
||||
current_run, model_performance = load_run_comparison(st.session_state.selected_run_id)
|
||||
@@ -914,6 +998,9 @@ def main():
|
||||
with col1:
|
||||
if st.button("Back to Overview", use_container_width=True):
|
||||
st.session_state.drill_down_model = None
|
||||
# Clear model_id from URL when going back to overview
|
||||
if "model_id" in st.query_params:
|
||||
del st.query_params["model_id"]
|
||||
st.rerun()
|
||||
|
||||
render_detailed_analysis(current_run['run_id'], st.session_state.drill_down_model)
|
||||
|
||||
@@ -33,7 +33,7 @@ This is the most granular and important table in the database. A `result` repres
|
||||
|
||||
- **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`: A unique identifier for the individual attempt.
|
||||
- `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`).
|
||||
@@ -82,3 +82,15 @@ This relational schema provides a powerful foundation for sophisticated analysis
|
||||
- "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.
|
||||
|
||||
@@ -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,829 @@
|
||||
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 LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
|
||||
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
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,954 @@
|
||||
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 LEGACY_SEARCH_BLOCK_START_REGEX = /^[<]{3,} SEARCH>?$/
|
||||
|
||||
const SEARCH_BLOCK_END_REGEX = /^[=]{3,}$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
|
||||
|
||||
// Similarity thresholds for block anchor fallback matching
|
||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
||||
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.0
|
||||
|
||||
/**
|
||||
* Levenshtein distance algorithm implementation
|
||||
*/
|
||||
function levenshtein(a: string, b: string): number {
|
||||
// Handle empty strings
|
||||
if (a === "" || b === "") {
|
||||
return Math.max(a.length, b.length)
|
||||
}
|
||||
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||
)
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
||||
}
|
||||
}
|
||||
return matrix[a.length][b.length]
|
||||
}
|
||||
|
||||
// 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,
|
||||
* with similarity checking to prevent false positives.
|
||||
* 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. Collects all candidate positions where both anchors match
|
||||
* 4. Uses levenshtein distance to calculate similarity of middle lines
|
||||
* 5. Returns match only if similarity meets threshold requirements
|
||||
*
|
||||
* 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
|
||||
* - The middle content is reasonably similar (prevents false positives)
|
||||
*
|
||||
* @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, 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++
|
||||
}
|
||||
|
||||
// Collect all candidate positions
|
||||
const candidates: number[] = []
|
||||
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
|
||||
if (originalLines[i].trim() === firstLineSearch && originalLines[i + searchBlockSize - 1].trim() === lastLineSearch) {
|
||||
candidates.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Return immediately if no candidates
|
||||
if (candidates.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Handle single candidate scenario (using relaxed threshold)
|
||||
if (candidates.length === 1) {
|
||||
const i = candidates[0]
|
||||
let similarity = 0
|
||||
let linesToCheck = searchBlockSize - 2
|
||||
|
||||
for (let j = 1; j < searchBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[i + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
}
|
||||
const distance = levenshtein(originalLine, searchLine)
|
||||
similarity += (1 - distance / maxLen) / linesToCheck
|
||||
|
||||
// Exit early when threshold is reached
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
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, similarity]
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate similarity for multiple candidates
|
||||
let bestMatchIndex = -1
|
||||
let maxSimilarity = -1
|
||||
|
||||
for (const i of candidates) {
|
||||
let similarity = 0
|
||||
for (let j = 1; j < searchBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[i + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
}
|
||||
const distance = levenshtein(originalLine, searchLine)
|
||||
similarity += 1 - distance / maxLen
|
||||
}
|
||||
similarity /= searchBlockSize - 2 // Average similarity
|
||||
|
||||
if (similarity > maxSimilarity) {
|
||||
maxSimilarity = similarity
|
||||
bestMatchIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold judgment
|
||||
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD) {
|
||||
const i = bestMatchIndex
|
||||
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, maxSimilarity]
|
||||
}
|
||||
|
||||
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<any> {
|
||||
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<any>
|
||||
> = {
|
||||
v1: constructNewFileContentV1,
|
||||
v2: constructNewFileContentV2,
|
||||
} as const
|
||||
|
||||
async function constructNewFileContentV1(diffContent: string, originalContent: string, isFinal: boolean): Promise<{
|
||||
content: string;
|
||||
replacements: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
method: string;
|
||||
similarity: number;
|
||||
searchContent: string;
|
||||
matchedText: string;
|
||||
}>;
|
||||
}> {
|
||||
let result = ""
|
||||
let lastProcessedIndex = 0
|
||||
|
||||
let currentSearchContent = ""
|
||||
let currentReplaceContent = ""
|
||||
let inSearch = false
|
||||
let inReplace = false
|
||||
|
||||
let searchMatchIndex = -1
|
||||
let searchEndIndex = -1
|
||||
let matchMethod = ""
|
||||
let similarityScore = -1.0
|
||||
|
||||
// Track all replacements to handle out-of-order edits
|
||||
let replacements: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
content: string;
|
||||
method: string;
|
||||
similarity: number;
|
||||
searchContent: string;
|
||||
matchedText: 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
|
||||
matchMethod = "empty_new_file"
|
||||
} 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
|
||||
matchMethod = "exact_match"
|
||||
} else {
|
||||
// Attempt fallback line-trimmed matching
|
||||
const lineMatch = lineTrimmedFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (lineMatch) {
|
||||
;[searchMatchIndex, searchEndIndex] = lineMatch
|
||||
matchMethod = "line_trimmed_fallback"
|
||||
} else {
|
||||
// Try block anchor fallback for larger blocks
|
||||
const blockMatch = blockAnchorFallbackMatch(originalContent, currentSearchContent, lastProcessedIndex)
|
||||
if (blockMatch) {
|
||||
;[searchMatchIndex, searchEndIndex, similarityScore] = blockMatch
|
||||
matchMethod = "block_anchor_fallback"
|
||||
} 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
|
||||
matchMethod = "full_file_search"
|
||||
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,
|
||||
method: matchMethod,
|
||||
similarity: similarityScore,
|
||||
searchContent: currentSearchContent,
|
||||
matchedText: originalContent.slice(searchMatchIndex, searchEndIndex),
|
||||
})
|
||||
|
||||
// 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
|
||||
similarityScore = -1.0
|
||||
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,
|
||||
method: matchMethod,
|
||||
similarity: similarityScore,
|
||||
searchContent: currentSearchContent,
|
||||
matchedText: originalContent.slice(searchMatchIndex, searchEndIndex),
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// For testing - return debug info
|
||||
return {
|
||||
content: result,
|
||||
replacements: replacements
|
||||
}
|
||||
}
|
||||
|
||||
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, /* ignore similarity */] = 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
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface TestConfig {
|
||||
diff_edit_function: string
|
||||
thinking_tokens_budget: number
|
||||
replay: boolean
|
||||
diff_apply_file?: string
|
||||
}
|
||||
|
||||
export interface SystemPromptDetails {
|
||||
@@ -80,6 +81,7 @@ export interface TestResult {
|
||||
diffEdit?: string
|
||||
toolCalls?: ExtractedToolCall[]
|
||||
diffEditSuccess?: boolean
|
||||
replacementData?: any
|
||||
error?: string
|
||||
errorString?: string
|
||||
}
|
||||
@@ -100,4 +102,5 @@ export interface TestInput {
|
||||
diffEditFunction: string
|
||||
thinkingBudgetTokens: number
|
||||
originalDiffEditToolCallMessage?: string
|
||||
diffApplyFile?: string
|
||||
}
|
||||
|
||||
Generated
+374
-67
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.17.15",
|
||||
"version": "3.18.1",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.17.15",
|
||||
"version": "3.18.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.17.15",
|
||||
"version": "3.18.1",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -332,13 +332,14 @@
|
||||
"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 out/",
|
||||
"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",
|
||||
"check-types": "npm run protos && tsc --noEmit",
|
||||
"lint": "eslint src --ext ts && eslint webview-ui/src --ext ts && buf lint && cd webview-ui && npm run lint",
|
||||
"format": "prettier . --check",
|
||||
"format:fix": "prettier . --write",
|
||||
"pretest": "npm run compile && npm run compile-tests && npm run compile-standalone && npm run lint",
|
||||
"test": "npm-run-all test:unit test:integration",
|
||||
"test:ci": "node scripts/test-ci.js",
|
||||
"test:integration": "vscode-test",
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Configuration file for protocol buffer build scripts
|
||||
// Contains service name mappings used by both build-proto.js and build-go-proto.js
|
||||
|
||||
// List of gRPC services
|
||||
// To add a new service, simply add it to this map and run the build scripts
|
||||
// The service handler will be automatically discovered and used by grpc-handler.ts
|
||||
export const serviceNameMap = {
|
||||
account: "cline.AccountService",
|
||||
browser: "cline.BrowserService",
|
||||
checkpoints: "cline.CheckpointsService",
|
||||
file: "cline.FileService",
|
||||
mcp: "cline.McpService",
|
||||
state: "cline.StateService",
|
||||
task: "cline.TaskService",
|
||||
web: "cline.WebService",
|
||||
models: "cline.ModelsService",
|
||||
slash: "cline.SlashService",
|
||||
ui: "cline.UiService",
|
||||
// Add new services here - no other code changes needed!
|
||||
}
|
||||
|
||||
// List of host gRPC services (IDE API bridge)
|
||||
// These services are implemented in the IDE extension and called by the standalone Cline Core
|
||||
export const hostServiceNameMap = {
|
||||
uri: "host.UriService",
|
||||
watch: "host.WatchService",
|
||||
workspace: "host.WorkspaceService",
|
||||
env: "host.EnvService",
|
||||
// Add new host services here
|
||||
}
|
||||
+18
-44
@@ -9,16 +9,18 @@ import chalk from "chalk"
|
||||
import os from "os"
|
||||
|
||||
import { createRequire } from "module"
|
||||
import { serviceNameMap, hostServiceNameMap } from "./build-proto-config.js"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT_DIR = path.resolve(SCRIPT_DIR, "..")
|
||||
|
||||
const TS_OUT_DIR = path.join(ROOT_DIR, "src/shared/proto")
|
||||
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src/generated/nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone/proto")
|
||||
const TS_OUT_DIR = path.join(ROOT_DIR, "src", "shared", "proto")
|
||||
const GRPC_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "grpc-js")
|
||||
const NICE_JS_OUT_DIR = path.join(ROOT_DIR, "src", "generated", "nice-grpc")
|
||||
const DESCRIPTOR_OUT_DIR = path.join(ROOT_DIR, "dist-standalone", "proto")
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const TS_PROTO_PLUGIN = isWindows
|
||||
@@ -34,33 +36,13 @@ const TS_PROTO_OPTIONS = [
|
||||
"useDate=false", // Timestamp fields will not be automatically converted to Date.
|
||||
]
|
||||
|
||||
// List of gRPC services
|
||||
// To add a new service, simply add it to this map and run this script
|
||||
// The service handler will be automatically discovered and used by grpc-handler.ts
|
||||
const serviceNameMap = {
|
||||
account: "cline.AccountService",
|
||||
browser: "cline.BrowserService",
|
||||
checkpoints: "cline.CheckpointsService",
|
||||
file: "cline.FileService",
|
||||
mcp: "cline.McpService",
|
||||
state: "cline.StateService",
|
||||
task: "cline.TaskService",
|
||||
web: "cline.WebService",
|
||||
models: "cline.ModelsService",
|
||||
slash: "cline.SlashService",
|
||||
ui: "cline.UiService",
|
||||
// Add new services here - no other code changes needed!
|
||||
}
|
||||
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/core/controller", serviceKey))
|
||||
// Service directories derived from imported serviceNameMap
|
||||
const serviceDirs = Object.keys(serviceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src", "core", "controller", serviceKey))
|
||||
|
||||
// List of host gRPC services (IDE API bridge)
|
||||
// These services are implemented in the IDE extension and called by the standalone Cline Core
|
||||
const hostServiceNameMap = {
|
||||
uri: "host.UriService",
|
||||
watch: "host.WatchService",
|
||||
// Add new host services here
|
||||
}
|
||||
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) => path.join(ROOT_DIR, "src/hosts/vscode", serviceKey))
|
||||
// Host service directories derived from imported hostServiceNameMap
|
||||
const hostServiceDirs = Object.keys(hostServiceNameMap).map((serviceKey) =>
|
||||
path.join(ROOT_DIR, "src", "hosts", "vscode", serviceKey),
|
||||
)
|
||||
|
||||
async function main() {
|
||||
console.log(chalk.bold.blue("Starting Protocol Buffer code generation..."))
|
||||
@@ -176,7 +158,7 @@ export {
|
||||
${serviceExports.join(",\n\t")}
|
||||
}`
|
||||
|
||||
const filePath = path.join(ROOT_DIR, "webview-ui/src/services/grpc-client.ts")
|
||||
const filePath = path.join(ROOT_DIR, "webview-ui", "src", "services", "grpc-client.ts")
|
||||
await writeFileWithMkdirs(filePath, content)
|
||||
log_verbose(chalk.green(`Generated gRPC client at ${filePath}`))
|
||||
}
|
||||
@@ -385,7 +367,7 @@ export interface ServiceHandlerConfig {
|
||||
export const serviceHandlers: Record<string, ServiceHandlerConfig> = {${serviceConfigs.join(",")}
|
||||
};`
|
||||
|
||||
const configPath = path.join(ROOT_DIR, "src/core/controller/grpc-service-config.ts")
|
||||
const configPath = path.join(ROOT_DIR, "src", "core", "controller", "grpc-service-config.ts")
|
||||
await writeFileWithMkdirs(configPath, content)
|
||||
log_verbose(chalk.green(`Generated service configuration at ${configPath}`))
|
||||
}
|
||||
@@ -601,13 +583,12 @@ async function cleanup() {
|
||||
for (const file of existingFiles) {
|
||||
await fs.unlink(path.join(TS_OUT_DIR, file))
|
||||
}
|
||||
await rmdir(path.join(ROOT_DIR, "src/generated"))
|
||||
await rmdir(path.join(ROOT_DIR, "src", "generated"))
|
||||
|
||||
// Clean up generated files that were moved.
|
||||
await fs.rm(path.join(ROOT_DIR, "src/standalone/services/host-grpc-client.ts"), { force: true })
|
||||
await rmdir(path.join(ROOT_DIR, "src/standalone/services"))
|
||||
|
||||
await fs.rm(path.join(ROOT_DIR, "hosts/vscode"), { force: true, recursive: true })
|
||||
await fs.rm(path.join(ROOT_DIR, "src", "standalone", "services", "host-grpc-client.ts"), { force: true })
|
||||
await rmdir(path.join(ROOT_DIR, "src", "standalone", "services"))
|
||||
await fs.rm(path.join(ROOT_DIR, "hosts", "vscode"), { force: true, recursive: true })
|
||||
await rmdir(path.join(ROOT_DIR, "hosts"))
|
||||
|
||||
await fs.rm(path.join(ROOT_DIR, "src/standalone/server-setup.ts"), { force: true })
|
||||
@@ -635,13 +616,6 @@ async function rmdir(path) {
|
||||
}
|
||||
}
|
||||
|
||||
function serviceNameWithoutPackage(fullServiceName) {
|
||||
return fullServiceName.replace(/.*\./, "")
|
||||
}
|
||||
function lowercaseFirstChar(str) {
|
||||
return str.charAt(0).toLowerCase() + str.slice(1)
|
||||
}
|
||||
|
||||
// Check for Apple Silicon compatibility
|
||||
function checkAppleSiliconCompatibility() {
|
||||
// Only run check on macOS
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
import "common.proto";
|
||||
|
||||
// Provides methods for working with the user's environment.
|
||||
service EnvService {
|
||||
// Writes text to the system clipboard.
|
||||
rpc clipboardWriteText(cline.StringRequest) returns (cline.Empty);
|
||||
|
||||
// Reads text from the system clipboard.
|
||||
rpc clipboardReadText(cline.EmptyRequest) returns (cline.String);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package host;
|
||||
option java_package = "bot.cline.host.proto";
|
||||
option java_multiple_files = true;
|
||||
|
||||
// Provides methods for working with workspaces/projects.
|
||||
service WorkspaceService {
|
||||
// Returns a list of the top level directories of the workspace.
|
||||
rpc getWorkspacePaths(GetWorkspacePathsRequest) returns (GetWorkspacePathsResponse);
|
||||
}
|
||||
|
||||
message GetWorkspacePathsRequest {
|
||||
// The unique ID for the workspace/project.
|
||||
// This is currently optional in vscode. It is required in other environments where cline is running at
|
||||
// the application level, and the user can open multiple projects.
|
||||
optional string id = 1;
|
||||
}
|
||||
|
||||
message GetWorkspacePathsResponse {
|
||||
// The unique ID for the workspace/project.
|
||||
optional string id = 1;
|
||||
repeated string paths = 2;
|
||||
}
|
||||
@@ -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
-8
@@ -23,8 +23,6 @@ service TaskService {
|
||||
rpc exportTaskWithId(StringRequest) returns (Empty);
|
||||
// Toggles the favorite status of a task
|
||||
rpc toggleTaskFavorite(TaskFavoriteRequest) returns (Empty);
|
||||
// Deletes all non-favorited tasks
|
||||
rpc deleteNonFavoritedTasks(EmptyRequest) returns (DeleteNonFavoritedTasksResults);
|
||||
// Gets filtered task history
|
||||
rpc getTaskHistory(GetTaskHistoryRequest) returns (TaskHistoryArray);
|
||||
// Sends a response to a previous ask operation
|
||||
@@ -35,6 +33,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(EmptyRequest) returns (DeleteAllTaskHistoryCount);
|
||||
}
|
||||
|
||||
// Request message for creating a new task
|
||||
@@ -66,12 +66,6 @@ message TaskResponse {
|
||||
int32 cache_reads = 10;
|
||||
}
|
||||
|
||||
// Results returned when deleting non-favorited tasks
|
||||
message DeleteNonFavoritedTasksResults {
|
||||
int32 tasks_preserved = 1;
|
||||
int32 tasks_deleted = 2;
|
||||
}
|
||||
|
||||
// Request for getting task history with filtering
|
||||
message GetTaskHistoryRequest {
|
||||
Metadata metadata = 1;
|
||||
@@ -116,3 +110,8 @@ message ExecuteQuickWinRequest {
|
||||
string command = 2;
|
||||
string title = 3;
|
||||
}
|
||||
|
||||
// Results returned when deleting all task history
|
||||
message DeleteAllTaskHistoryCount {
|
||||
int32 tasks_deleted = 1;
|
||||
}
|
||||
|
||||
@@ -265,4 +265,7 @@ service UiService {
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+25
-2
@@ -38,8 +38,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)
|
||||
@@ -97,3 +96,27 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
-17
@@ -56,7 +56,21 @@ export class ClineHandler implements ApiHandler {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
|
||||
const delta = chunk.choices[0]?.delta
|
||||
// Check for mid-stream error via finish_reason
|
||||
const choice = chunk.choices?.[0]
|
||||
// OpenRouter may return finish_reason = "error" with error details
|
||||
if ((choice?.finish_reason as string) === "error") {
|
||||
const choiceWithError = choice as any
|
||||
if (choiceWithError.error) {
|
||||
const error = choiceWithError.error
|
||||
console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
} else {
|
||||
throw new Error("Cline Mid-Stream Error: Stream terminated with error status but no error details provided")
|
||||
}
|
||||
}
|
||||
|
||||
const delta = choice?.delta
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -75,7 +89,7 @@ export class ClineHandler implements ApiHandler {
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
// @ts-ignore-next-line
|
||||
let totalCost = chunk.usage.cost || 0
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
const modelId = this.getModel().id
|
||||
const provider = modelId.split("/")[0]
|
||||
|
||||
@@ -84,14 +98,26 @@ export class ClineHandler implements ApiHandler {
|
||||
totalCost = 0
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost,
|
||||
if (modelId.includes("gemini")) {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost,
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost,
|
||||
}
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
@@ -117,13 +143,27 @@ export class ClineHandler implements ApiHandler {
|
||||
})
|
||||
|
||||
const generation = response.data
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,6 +44,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
for await (const chunk of stream) {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
// Check for error field directly on chunk
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
@@ -52,6 +53,29 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
}
|
||||
|
||||
// Check for error in choices[0].finish_reason
|
||||
// OpenRouter may return errors in a non-standard way within choices
|
||||
const choice = chunk.choices?.[0]
|
||||
// Use type assertion since OpenRouter uses non-standard "error" finish_reason
|
||||
if ((choice?.finish_reason as string) === "error") {
|
||||
// Use type assertion since OpenRouter adds non-standard error property
|
||||
const choiceWithError = choice as any
|
||||
if (choiceWithError.error) {
|
||||
const error = choiceWithError.error
|
||||
console.error(
|
||||
`OpenRouter Mid-Stream Error: ${error?.code || "Unknown"} - ${error?.message || "Unknown error"}`,
|
||||
)
|
||||
// Format error details
|
||||
const errorDetails = typeof error === "object" ? JSON.stringify(error, null, 2) : String(error)
|
||||
throw new Error(`OpenRouter Mid-Stream Error: ${errorDetails}`)
|
||||
} else {
|
||||
// Fallback if error details are not available
|
||||
throw new Error(
|
||||
`OpenRouter Mid-Stream Error: Stream terminated with error status but no error details provided`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.lastGenerationId && chunk.id) {
|
||||
this.lastGenerationId = chunk.id
|
||||
}
|
||||
@@ -74,14 +98,27 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (!didOutputUsage && chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: chunk.usage.cost || 0,
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: (chunk.usage.prompt_tokens || 0) - (chunk.usage.prompt_tokens_details?.cached_tokens || 0),
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
// @ts-ignore-next-line
|
||||
totalCost: (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0),
|
||||
}
|
||||
}
|
||||
didOutputUsage = true
|
||||
}
|
||||
@@ -103,14 +140,27 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
// console.log("OpenRouter generation details:", generation)
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
let modelId = this.options.openRouterModelId
|
||||
if (modelId && modelId.includes("gemini")) {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: (generation?.native_tokens_prompt || 0) - (generation?.native_tokens_cached || 0),
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
cacheReadTokens: generation?.native_tokens_cached || 0,
|
||||
// openrouter generation endpoint fails often
|
||||
inputTokens: generation?.native_tokens_prompt || 0,
|
||||
outputTokens: generation?.native_tokens_completion || 0,
|
||||
totalCost: generation?.total_cost || 0,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
|
||||
@@ -122,6 +122,8 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
const deploymentId = await this.getDeploymentForModel(model.id)
|
||||
|
||||
const anthropicModels = [
|
||||
"anthropic--claude-4-sonnet",
|
||||
"anthropic--claude-4-opus",
|
||||
"anthropic--claude-3.7-sonnet",
|
||||
"anthropic--claude-3.5-sonnet",
|
||||
"anthropic--claude-3-sonnet",
|
||||
@@ -136,7 +138,11 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
if (anthropicModels.includes(model.id)) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/invoke-with-response-stream`
|
||||
|
||||
if (model.id === "anthropic--claude-3.7-sonnet") {
|
||||
if (
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
model.id === "anthropic--claude-4-opus" ||
|
||||
model.id === "anthropic--claude-3.7-sonnet"
|
||||
) {
|
||||
url = `${this.options.sapAiCoreBaseUrl}/v2/inference/deployments/${deploymentId}/converse-stream`
|
||||
payload = {
|
||||
inferenceConfig: {
|
||||
@@ -221,7 +227,11 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
} else if (openAIModels.includes(model.id)) {
|
||||
yield* this.streamCompletionGPT(response.data, model)
|
||||
} else if (model.id === "anthropic--claude-3.7-sonnet") {
|
||||
} else if (
|
||||
model.id === "anthropic--claude-4-sonnet" ||
|
||||
model.id === "anthropic--claude-4-opus" ||
|
||||
model.id === "anthropic--claude-3.7-sonnet"
|
||||
) {
|
||||
yield* this.streamCompletionSonnet37(response.data, model)
|
||||
} else {
|
||||
yield* this.streamCompletion(response.data, model)
|
||||
|
||||
@@ -8,11 +8,13 @@ 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_START_REGEX = /^[-]{3,} SEARCH>?$/
|
||||
const LEGACY_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$/
|
||||
|
||||
const REPLACE_BLOCK_END_REGEX = /^[+]{3,} REPLACE>?$/
|
||||
const LEGACY_REPLACE_BLOCK_END_REGEX = /^[>]{3,} REPLACE>?$/
|
||||
|
||||
// Helper functions to check if a line matches the flexible patterns
|
||||
function isSearchBlockStart(line: string): boolean {
|
||||
|
||||
@@ -6,6 +6,9 @@ import * as path from "path"
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
import * as diskModule from "@core/storage/disk"
|
||||
import type { TaskMetadata, FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
import type { WebviewProviderCreator } from "@/hosts/host-providers"
|
||||
import * as hostProviders from "@hosts/host-providers"
|
||||
import { vscodeHostBridgeClient } from "@/hosts/vscode/client/host-grpc-client"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
@@ -37,7 +40,6 @@ describe("FileContextTracker", () => {
|
||||
}
|
||||
|
||||
// Use a function replacement instead of a direct stub
|
||||
const originalCreateFileSystemWatcher = vscode.workspace.createFileSystemWatcher
|
||||
vscode.workspace.createFileSystemWatcher = function () {
|
||||
return mockFileSystemWatcher
|
||||
}
|
||||
@@ -51,6 +53,7 @@ describe("FileContextTracker", () => {
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [] }
|
||||
getTaskMetadataStub = sandbox.stub(diskModule, "getTaskMetadata").resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub = sandbox.stub(diskModule, "saveTaskMetadata").resolves()
|
||||
hostProviders.initializeHostProviders(((_) => {}) as WebviewProviderCreator, vscodeHostBridgeClient)
|
||||
|
||||
// Create tracker instance
|
||||
taskId = "test-task-id"
|
||||
|
||||
@@ -5,6 +5,8 @@ import { getWorkspaceState, updateWorkspaceState } from "@core/storage/state"
|
||||
import { getGlobalState } from "@core/storage/state"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import { getHostBridgeProvider } from "@/hosts/host-providers"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
// This class is responsible for tracking file operations that may result in stale context.
|
||||
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
|
||||
@@ -37,8 +39,8 @@ export class FileContextTracker {
|
||||
/**
|
||||
* Gets the current working directory or returns undefined if it cannot be determined
|
||||
*/
|
||||
private getCwd(): string | undefined {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
private async getCwd(): Promise<string | undefined> {
|
||||
const cwd = await getCwd(undefined)
|
||||
if (!cwd) {
|
||||
console.info("No workspace folder available - cannot determine current working directory")
|
||||
}
|
||||
@@ -54,7 +56,7 @@ export class FileContextTracker {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = this.getCwd()
|
||||
const cwd = await this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
@@ -85,7 +87,7 @@ export class FileContextTracker {
|
||||
*/
|
||||
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
|
||||
try {
|
||||
const cwd = this.getCwd()
|
||||
const cwd = await this.getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as vscode from "vscode"
|
||||
import { Controller } from ".."
|
||||
import { Empty, StringRequest } from "../../../shared/proto/common"
|
||||
import { writeTextToClipboard } from "@/utils/env"
|
||||
|
||||
/**
|
||||
* Copies text to the system clipboard
|
||||
@@ -11,7 +12,7 @@ import { Empty, StringRequest } from "../../../shared/proto/common"
|
||||
export async function copyToClipboard(controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
try {
|
||||
if (request.value) {
|
||||
await vscode.env.clipboard.writeText(request.value)
|
||||
await writeTextToClipboard(request.value)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error copying to clipboard:", error)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { openMention as coreOpenMention } from "../../mentions"
|
||||
* @param request The string request containing the mention text
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function openMention(controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
export async function openMention(_controller: Controller, request: StringRequest): Promise<Empty> {
|
||||
coreOpenMention(request.value)
|
||||
return Empty.create()
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { convertGitCommitsToProtoGitCommits } from "@shared/proto-conversions/fi
|
||||
* @returns GitCommits containing the matching commits
|
||||
*/
|
||||
export const searchCommits: FileMethodHandler = async (controller: Controller, request: StringRequest): Promise<GitCommits> => {
|
||||
const cwd = getWorkspacePath()
|
||||
const cwd = await getWorkspacePath()
|
||||
if (!cwd) {
|
||||
return GitCommits.create({ commits: [] })
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ import { convertSearchResultsToProtoFileInfos } from "@shared/proto-conversions/
|
||||
* @returns Results containing matching files/folders
|
||||
*/
|
||||
export const searchFiles: FileMethodHandler = async (
|
||||
controller: Controller,
|
||||
_controller: Controller,
|
||||
request: FileSearchRequest,
|
||||
): Promise<FileSearchResults> => {
|
||||
const workspacePath = getWorkspacePath()
|
||||
const workspacePath = await getWorkspacePath()
|
||||
|
||||
if (!workspacePath) {
|
||||
// Handle case where workspace path is not available
|
||||
|
||||
@@ -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: [] })
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export class ServiceRegistry {
|
||||
* @param serviceName The name of the service (used for logging)
|
||||
*/
|
||||
constructor(serviceName: string) {
|
||||
console.log(`Registering Protobus service: ${serviceName}...`)
|
||||
this.serviceName = serviceName
|
||||
}
|
||||
|
||||
@@ -56,7 +57,6 @@ export class ServiceRegistry {
|
||||
}
|
||||
|
||||
this.methodMetadata[methodName] = { isStreaming, ...metadata }
|
||||
console.log(`Registered ${this.serviceName} method: ${methodName}${isStreaming ? " (streaming)" : ""}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+55
-194
@@ -1,30 +1,31 @@
|
||||
import { telemetryService } from "@/services/posthog/telemetry/TelemetryService"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { buildApiHandler } from "@api/index"
|
||||
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
|
||||
import { extractCommitMessage } from "@integrations/git/commit-message-generator"
|
||||
import { downloadTask } from "@integrations/misc/export-markdown"
|
||||
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
|
||||
import { ClineAccountService } from "@services/account/ClineAccountService"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { ApiProvider, ModelInfo } from "@shared/api"
|
||||
import { ChatContent } from "@shared/ChatContent"
|
||||
import { ChatSettings, StoredChatSettings } from "@shared/ChatSettings"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { McpMarketplaceCatalog } from "@shared/mcp"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { UserInfo } from "@shared/UserInfo"
|
||||
import { WebviewMessage } from "@shared/WebviewMessage"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
import axios from "axios"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import fs from "fs/promises"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
|
||||
import { buildApiHandler } from "@api/index"
|
||||
import { cleanupLegacyCheckpoints } from "@integrations/checkpoints/CheckpointMigration"
|
||||
import { downloadTask } from "@integrations/misc/export-markdown"
|
||||
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
|
||||
import { ClineAccountService } from "@services/account/ClineAccountService"
|
||||
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 { ExtensionMessage, ExtensionState, Platform } from "@shared/ExtensionMessage"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { McpMarketplaceCatalog } from "@shared/mcp"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { WebviewMessage } from "@shared/WebviewMessage"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
import { extractCommitMessage } from "@integrations/git/commit-message-generator"
|
||||
import { ensureMcpServersDirectoryExists, ensureSettingsDirectoryExists, GlobalFileNames } from "../storage/disk"
|
||||
import {
|
||||
getAllExtensionState,
|
||||
@@ -32,17 +33,15 @@ import {
|
||||
getSecret,
|
||||
getWorkspaceState,
|
||||
storeSecret,
|
||||
updateApiConfiguration,
|
||||
updateGlobalState,
|
||||
updateWorkspaceState,
|
||||
} from "../storage/state"
|
||||
import { Task } from "../task"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { sendAuthCallbackEvent } from "./account/subscribeToAuthCallback"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "./grpc-handler"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
import { sendAddToInputEvent } from "./ui/subscribeToAddToInput"
|
||||
import { sendAuthCallbackEvent } from "./account/subscribeToAuthCallback"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { sendRelinquishControlEvent } from "./ui/subscribeToRelinquishControl"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -55,11 +54,12 @@ export class Controller {
|
||||
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
|
||||
accountService: ClineAccountService
|
||||
latestAnnouncementId = "may-22-2025_16:11:00" // update to some unique identifier when we add a new announcement
|
||||
latestAnnouncementId = "june-25-2025_16:11:00" // update to some unique identifier when we add a new announcement
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
@@ -124,7 +124,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)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class Controller {
|
||||
apiConfiguration,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
chatSettings: storedChatSettings,
|
||||
shellIntegrationTimeout,
|
||||
terminalReuseEnabled,
|
||||
terminalOutputLineLimit,
|
||||
@@ -144,6 +144,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"
|
||||
@@ -165,7 +171,6 @@ export class Controller {
|
||||
this.workspaceTracker,
|
||||
(historyItem) => this.updateTaskHistory(historyItem),
|
||||
() => this.postStateToWebview(),
|
||||
(message) => this.postMessageToWebview(message),
|
||||
(taskId) => this.reinitExistingTaskFromId(taskId),
|
||||
() => this.cancelTask(),
|
||||
apiConfiguration,
|
||||
@@ -204,48 +209,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)
|
||||
@@ -268,11 +235,15 @@ export class Controller {
|
||||
await updateGlobalState(this.context, "telemetrySetting", telemetrySetting)
|
||||
const isOptedIn = telemetrySetting !== "disabled"
|
||||
telemetryService.updateTelemetryState(isOptedIn)
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -442,7 +413,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) {
|
||||
@@ -496,20 +469,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> {
|
||||
@@ -720,8 +679,8 @@ export class Controller {
|
||||
|
||||
// Context menus and code actions
|
||||
|
||||
getFileMentionFromPath(filePath: string) {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
async getFileMentionFromPath(filePath: string) {
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
return "@/" + filePath
|
||||
}
|
||||
@@ -736,7 +695,7 @@ export class Controller {
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
// Post message to webview with the selected code
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
const fileMention = await this.getFileMentionFromPath(filePath)
|
||||
|
||||
let input = `${fileMention}\n\`\`\`\n${code}\n\`\`\``
|
||||
if (diagnostics) {
|
||||
@@ -773,7 +732,7 @@ export class Controller {
|
||||
await vscode.commands.executeCommand("claude-dev.SidebarProvider.focus")
|
||||
await setTimeoutPromise(100)
|
||||
|
||||
const fileMention = this.getFileMentionFromPath(filePath)
|
||||
const fileMention = await this.getFileMentionFromPath(filePath)
|
||||
const problemsString = this.convertDiagnosticsToProblemsString(diagnostics)
|
||||
await this.initTask(`Fix the following code in ${fileMention}\n\`\`\`\n${code}\n\`\`\`\n\nProblems:\n${problemsString}`)
|
||||
|
||||
@@ -852,110 +811,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) || []
|
||||
@@ -970,7 +825,7 @@ export class Controller {
|
||||
|
||||
async postStateToWebview() {
|
||||
const state = await this.getStateToPostToWebview()
|
||||
await sendStateUpdate(state)
|
||||
await sendStateUpdate(this.id, state)
|
||||
}
|
||||
|
||||
async getStateToPostToWebview(): Promise<ExtensionState> {
|
||||
@@ -980,7 +835,7 @@ export class Controller {
|
||||
taskHistory,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
chatSettings: storedChatSettings,
|
||||
userInfo,
|
||||
mcpMarketplaceEnabled,
|
||||
mcpRichDisplayEnabled,
|
||||
@@ -997,6 +852,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) || {}
|
||||
|
||||
@@ -1050,7 +911,7 @@ export class Controller {
|
||||
if (this.task) {
|
||||
await telemetryService.sendCollectedEvents(this.task.taskId)
|
||||
}
|
||||
this.task?.abortTask()
|
||||
await this.task?.abortTask()
|
||||
this.task = undefined // removes reference to it, so once promises end it will be garbage collected
|
||||
}
|
||||
|
||||
@@ -1123,7 +984,7 @@ export class Controller {
|
||||
async generateGitCommitMessage() {
|
||||
try {
|
||||
// Check if there's a workspace folder open
|
||||
const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
vscode.window.showErrorMessage("No workspace folder open")
|
||||
return
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function updateAutoApprovalSettings(controller: Controller, request
|
||||
await updateGlobalState(controller.context, "autoApprovalSettings", settings)
|
||||
|
||||
if (controller.task) {
|
||||
controller.task.autoApprovalSettings = settings
|
||||
controller.task.updateAutoApprovalSettings(settings)
|
||||
}
|
||||
|
||||
await controller.postStateToWebview()
|
||||
|
||||
@@ -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,138 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Controller } from ".."
|
||||
import { DeleteAllTaskHistoryCount } from "../../../shared/proto/task"
|
||||
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): 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
|
||||
|
||||
const userChoice = await vscode.window.showWarningMessage(
|
||||
"What would you like to delete?",
|
||||
{ modal: true },
|
||||
"Delete All Except Favorites",
|
||||
"Delete Everything",
|
||||
)
|
||||
|
||||
// Default VS Code Cancel button returns `undefined` - don't delete anything
|
||||
if (userChoice === undefined) {
|
||||
return DeleteAllTaskHistoryCount.create({
|
||||
tasksDeleted: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// If preserving favorites, filter out non-favorites
|
||||
if (userChoice === "Delete All Except Favorites") {
|
||||
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 {
|
||||
// No favorited tasks found - show warning and ask user what to do
|
||||
const answer = await vscode.window.showWarningMessage(
|
||||
"No favorited tasks found. Would you like to delete all tasks anyway?",
|
||||
{ modal: true },
|
||||
"Delete All Tasks",
|
||||
)
|
||||
|
||||
// User cancelled - don't delete anything
|
||||
if (answer === undefined) {
|
||||
return DeleteAllTaskHistoryCount.create({
|
||||
tasksDeleted: 0,
|
||||
})
|
||||
}
|
||||
// If user chose "Delete All Tasks", fall through to the `delete everything` section below
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Controller } from ".."
|
||||
import { EmptyRequest } from "../../../shared/proto/common"
|
||||
import { DeleteNonFavoritedTasksResults } from "../../../shared/proto/task"
|
||||
import { getGlobalState, updateGlobalState } from "../../storage/state"
|
||||
import { fileExistsAtPath } from "../../../utils/fs"
|
||||
|
||||
/**
|
||||
* Deletes all non-favorited tasks, preserving only favorited ones
|
||||
* @param controller The controller instance
|
||||
* @param request Empty request
|
||||
* @returns DeleteNonFavoritedTasksResults with counts of preserved and deleted tasks
|
||||
*/
|
||||
export async function deleteNonFavoritedTasks(
|
||||
controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
): Promise<DeleteNonFavoritedTasksResults> {
|
||||
try {
|
||||
// Clear current task first
|
||||
await controller.clearTask()
|
||||
|
||||
// Get existing task history
|
||||
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
|
||||
|
||||
// Filter out non-favorited tasks
|
||||
const favoritedTasks = taskHistory.filter((task) => task.isFavorited === true)
|
||||
const deletedCount = taskHistory.length - favoritedTasks.length
|
||||
|
||||
console.log(`[deleteNonFavoritedTasks] Found ${favoritedTasks.length} favorited tasks to preserve`)
|
||||
|
||||
// Update global state
|
||||
if (favoritedTasks.length > 0) {
|
||||
await updateGlobalState(controller.context, "taskHistory", favoritedTasks)
|
||||
} else {
|
||||
await updateGlobalState(controller.context, "taskHistory", undefined)
|
||||
}
|
||||
|
||||
// Handle file system cleanup for deleted tasks
|
||||
const preserveTaskIds = favoritedTasks.map((task) => task.id)
|
||||
await cleanupTaskFiles(controller, preserveTaskIds)
|
||||
|
||||
// Update webview
|
||||
try {
|
||||
await controller.postStateToWebview()
|
||||
} catch (webviewErr) {
|
||||
console.error("Error posting to webview:", webviewErr)
|
||||
}
|
||||
|
||||
return DeleteNonFavoritedTasksResults.create({
|
||||
tasksPreserved: favoritedTasks.length,
|
||||
tasksDeleted: deletedCount,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error in deleteNonFavoritedTasks:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to cleanup task files while preserving specified tasks
|
||||
*/
|
||||
async function cleanupTaskFiles(controller: Controller, preserveTaskIds: string[]) {
|
||||
const taskDirPath = path.join(controller.context.globalStorageUri.fsPath, "tasks")
|
||||
|
||||
try {
|
||||
if (await fileExistsAtPath(taskDirPath)) {
|
||||
if (preserveTaskIds.length > 0) {
|
||||
const taskDirs = await fs.readdir(taskDirPath)
|
||||
console.debug(`[cleanupTaskFiles] Found ${taskDirs.length} task directories`)
|
||||
|
||||
// Delete only non-preserved task directories
|
||||
for (const dir of taskDirs) {
|
||||
if (!preserveTaskIds.includes(dir)) {
|
||||
await fs.rm(path.join(taskDirPath, dir), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No tasks to preserve, delete everything
|
||||
await fs.rm(taskDirPath, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cleaning up task files:", error)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function getTaskHistory(controller: Controller, request: GetTaskHis
|
||||
|
||||
// Get task history from global state
|
||||
const taskHistory = ((await getGlobalState(controller.context, "taskHistory")) as any[]) || []
|
||||
const workspacePath = getWorkspacePath()
|
||||
const workspacePath = await getWorkspacePath()
|
||||
|
||||
// Apply filters
|
||||
let filteredTasks = taskHistory.filter((item) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,14 @@ import { getLatestTerminalOutput } from "@integrations/terminal/get-latest-outpu
|
||||
import { getCommitInfo } from "@utils/git"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
import { FileContextTracker } from "../context/context-tracking/FileContextTracker"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
export function openMention(mention?: string): void {
|
||||
export async function openMention(mention?: string): Promise<void> {
|
||||
if (!mention) {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -257,7 +257,6 @@ CAPABILITIES
|
||||
: ""
|
||||
}
|
||||
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
|
||||
- You can use LaTeX syntax in your responses to render mathematical expressions
|
||||
|
||||
====
|
||||
|
||||
|
||||
@@ -268,7 +268,9 @@ Usage:
|
||||
</new_task>
|
||||
|
||||
## plan_mode_respond
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution. IMPORTANT NOTE: You should NOT ask for permission to read files or explore the repo. Just do that proactively. This tool should only be used when you've already gathered enough information to make a plan, or if you have a question for the user.
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should ONLY be used when you have already explored the relevant files and are ready to present a concrete plan. DO NOT use this tool to announce what files you're going to read - just read them first. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN_MODE then you should not use this tool. For example, if the user's task is to create a website, you may start by asking some clarifying questions with the ask_followup_question tool if their message was vague, explore the codebase, read files, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT_MODE to implement the solution.
|
||||
CRITICAL: You must complete your information gathering (reading files, exploring the codebase) BEFORE using this tool. The user expects to see a well thought-out plan based on actual analysis, not intentions.
|
||||
|
||||
Parameters:
|
||||
- response: (required) The response to provide to the user. Do not try to use tools in this parameter, this is simply a chat response. (You MUST use the response parameter, do not simply place the response text directly within <plan_mode_respond> tags.)
|
||||
Usage:
|
||||
@@ -572,8 +574,8 @@ In each user message, the environment_details will specify the current mode. The
|
||||
## What is PLAN MODE?
|
||||
|
||||
- While you are usually in ACT MODE, the user may switch to PLAN MODE in order to have a back and forth with you to plan how to best accomplish the task.
|
||||
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions to get a better understanding of the task. You may return mermaid diagrams to visually display your understanding.
|
||||
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task. Returning mermaid diagrams may be helpful here as well.
|
||||
- When starting in PLAN MODE, depending on the user's request, you may need to do some information gathering e.g. using read_file or search_files to get more context about the task. You may also ask the user clarifying questions with ask_followup_question to get a better understanding of the task. You may return mermaid diagrams to visually display your understanding.
|
||||
- Once you've gained more context about the user's request, you should architect a detailed plan for how you will accomplish the task. Returning mermaid diagrams may be helpful here as well. Present the plan to the user using the plan_mode_respond tool.
|
||||
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
|
||||
- If at any point a mermaid diagram would make your plan clearer to help the user quickly see the structure, you are encouraged to include a Mermaid code block in the response. (Note: if you use colors in your mermaid diagrams, be sure to use high contrast colors so the text is readable.)
|
||||
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.
|
||||
@@ -595,7 +597,6 @@ CAPABILITIES
|
||||
: ""
|
||||
}
|
||||
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
|
||||
- You can use LaTeX syntax in your responses to render mathematical expressions
|
||||
|
||||
====
|
||||
|
||||
@@ -621,6 +622,7 @@ RULES
|
||||
- When you want to modify a file, use the replace_in_file or write_to_file tool directly with the desired changes. You do not need to display the changes before using the tool.
|
||||
- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively. When you've completed your task, you must use the attempt_completion tool to present the result to the user. The user may provide feedback, which you can use to make improvements and try again.
|
||||
- You are only allowed to ask the user questions using the ask_followup_question tool. Use this tool only when you need additional details to complete a task, and be sure to use a clear and concise question that will help you move forward with the task. However if you can use the available tools to avoid having to ask the user questions, you should do so. For example, if the user mentions a file that may be in an outside directory like the Desktop, you should use the list_files tool to list the files in the Desktop and check if the file they are talking about is there, rather than asking the user to provide the file path themselves.
|
||||
- When the user is being vague, you should be proactive about asking clarifying questions using the ask_followup_question tool to ensure you understand their request. However, if you can infer the user's intent based on the context and available tools, you should proceed without asking unnecessary questions
|
||||
- When executing commands, if you don't see the expected output, assume the terminal executed the command successfully and proceed with the task. The user's terminal may be unable to stream the output back properly. If you absolutely need to see the actual terminal output, use the ask_followup_question tool to request the user to copy and paste it back to you.
|
||||
- The user may provide a file's contents directly in their message, in which case you shouldn't use the read_file tool to get the file contents again since you already have it.
|
||||
- Your goal is to try to accomplish the user's task, NOT engage in a back and forth conversation.${
|
||||
|
||||
@@ -12,14 +12,14 @@ export const SYSTEM_PROMPT = async (
|
||||
supportsBrowserUse: boolean,
|
||||
mcpHub: McpHub,
|
||||
browserSettings: BrowserSettings,
|
||||
isClaude4ModelFamily: boolean = false,
|
||||
isNextGenModel: boolean = false,
|
||||
) => {
|
||||
|
||||
if (isClaude4ModelFamily && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
|
||||
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
|
||||
return SYSTEM_PROMPT_CLAUDE4_EXPERIMENTAL(cwd, supportsBrowserUse, mcpHub, browserSettings)
|
||||
}
|
||||
|
||||
if (isClaude4ModelFamily) {
|
||||
if (isNextGenModel) {
|
||||
return SYSTEM_PROMPT_CLAUDE4(cwd, supportsBrowserUse, mcpHub, browserSettings)
|
||||
}
|
||||
|
||||
@@ -591,7 +591,6 @@ CAPABILITIES
|
||||
: ""
|
||||
}
|
||||
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
|
||||
- You can use LaTeX syntax in your responses to render mathematical expressions
|
||||
|
||||
====
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import * as vscode from "vscode"
|
||||
import { ensureRulesDirectoryExists } from "./disk"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { getGlobalState, getWorkspaceState, updateGlobalState, updateWorkspaceState } from "./state"
|
||||
import { GlobalStateKey } from "./state-keys"
|
||||
|
||||
export async function migratePlanActGlobalToWorkspaceStorage(context: vscode.ExtensionContext) {
|
||||
// Keys that were migrated from global storage to workspace storage
|
||||
const keysToMigrate = [
|
||||
// Core settings
|
||||
"apiProvider",
|
||||
"apiModelId",
|
||||
"thinkingBudgetTokens",
|
||||
"reasoningEffort",
|
||||
"chatSettings",
|
||||
"vsCodeLmModelSelector",
|
||||
|
||||
// Provider-specific model keys
|
||||
"awsBedrockCustomSelected",
|
||||
"awsBedrockCustomModelBaseId",
|
||||
"openRouterModelId",
|
||||
"openRouterModelInfo",
|
||||
"openAiModelId",
|
||||
"openAiModelInfo",
|
||||
"ollamaModelId",
|
||||
"lmStudioModelId",
|
||||
"liteLlmModelId",
|
||||
"liteLlmModelInfo",
|
||||
"requestyModelId",
|
||||
"requestyModelInfo",
|
||||
"togetherModelId",
|
||||
"fireworksModelId",
|
||||
|
||||
// Previous mode settings
|
||||
"previousModeApiProvider",
|
||||
"previousModeModelId",
|
||||
"previousModeModelInfo",
|
||||
"previousModeVsCodeLmModelSelector",
|
||||
"previousModeThinkingBudgetTokens",
|
||||
"previousModeReasoningEffort",
|
||||
"previousModeAwsBedrockCustomSelected",
|
||||
"previousModeAwsBedrockCustomModelBaseId",
|
||||
]
|
||||
|
||||
for (const key of keysToMigrate) {
|
||||
const globalValue = await getGlobalState(context, key as GlobalStateKey)
|
||||
if (globalValue !== undefined) {
|
||||
const workspaceValue = await getWorkspaceState(context, key)
|
||||
if (workspaceValue === undefined) {
|
||||
await updateWorkspaceState(context, key, globalValue)
|
||||
}
|
||||
// Delete from global storage regardless of whether we copied it
|
||||
await updateGlobalState(context, key as GlobalStateKey, undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateMcpMarketplaceEnableSetting(mcpMarketplaceEnabledRaw: boolean | undefined): Promise<boolean> {
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
const mcpMarketplaceEnabled = config.get<boolean>("mcpMarketplace.enabled")
|
||||
if (mcpMarketplaceEnabled !== undefined) {
|
||||
// Remove from VSCode configuration
|
||||
await config.update("mcpMarketplace.enabled", undefined, true)
|
||||
|
||||
return !mcpMarketplaceEnabled
|
||||
}
|
||||
return mcpMarketplaceEnabledRaw ?? true
|
||||
}
|
||||
|
||||
export async function migrateEnableCheckpointsSetting(enableCheckpointsSettingRaw: boolean | undefined): Promise<boolean> {
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
const enableCheckpoints = config.get<boolean>("enableCheckpoints")
|
||||
if (enableCheckpoints !== undefined) {
|
||||
// Remove from VSCode configuration
|
||||
await config.update("enableCheckpoints", undefined, true)
|
||||
return enableCheckpoints
|
||||
}
|
||||
return enableCheckpointsSettingRaw ?? true
|
||||
}
|
||||
|
||||
export async function migrateCustomInstructionsToGlobalRules(context: vscode.ExtensionContext) {
|
||||
try {
|
||||
const customInstructions = (await context.globalState.get("customInstructions")) as string | undefined
|
||||
|
||||
if (customInstructions?.trim()) {
|
||||
console.log("Migrating custom instructions to global Cline rules...")
|
||||
|
||||
// Create global .clinerules directory if it doesn't exist
|
||||
const globalRulesDir = await ensureRulesDirectoryExists()
|
||||
|
||||
// Use a fixed filename for custom instructions
|
||||
const migrationFileName = "custom_instructions.md"
|
||||
const migrationFilePath = path.join(globalRulesDir, migrationFileName)
|
||||
|
||||
try {
|
||||
// Check if file already exists to determine if we should append
|
||||
let existingContent = ""
|
||||
try {
|
||||
existingContent = await fs.readFile(migrationFilePath, "utf8")
|
||||
} catch (readError) {
|
||||
// File doesn't exist, which is fine
|
||||
}
|
||||
|
||||
// Append or create the file with custom instructions
|
||||
const contentToWrite = existingContent
|
||||
? `${existingContent}\n\n---\n\n${customInstructions.trim()}`
|
||||
: customInstructions.trim()
|
||||
|
||||
await fs.writeFile(migrationFilePath, contentToWrite)
|
||||
console.log(`Successfully ${existingContent ? "appended to" : "created"} migration file: ${migrationFilePath}`)
|
||||
} catch (fileError) {
|
||||
console.error("Failed to write migration file:", fileError)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove customInstructions from global state only after successful file creation
|
||||
await context.globalState.update("customInstructions", undefined)
|
||||
console.log("Successfully migrated custom instructions to global Cline rules")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate custom instructions to global rules:", error)
|
||||
// Continue execution - migration failure shouldn't break extension startup
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateModeFromWorkspaceStorageToControllerState(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
|
||||
}
|
||||
}
|
||||
+4
-125
@@ -1,5 +1,5 @@
|
||||
import * as vscode from "vscode"
|
||||
import { DEFAULT_CHAT_SETTINGS, OpenAIReasoningEffort } from "@shared/ChatSettings"
|
||||
import { DEFAULT_CHAT_SETTINGS } from "@shared/ChatSettings"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import { GlobalStateKey, SecretKey } from "./state-keys"
|
||||
@@ -7,13 +7,11 @@ 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 { StoredChatSettings } from "@shared/ChatSettings"
|
||||
import { TelemetrySetting } from "@shared/TelemetrySetting"
|
||||
import { UserInfo } from "@shared/UserInfo"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { ensureRulesDirectoryExists } from "./disk"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { migrateEnableCheckpointsSetting, migrateMcpMarketplaceEnableSetting } from "./state-migrations"
|
||||
/*
|
||||
Storage
|
||||
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
|
||||
@@ -54,125 +52,6 @@ export async function getWorkspaceState(context: vscode.ExtensionContext, key: s
|
||||
return await context.workspaceState.get(key)
|
||||
}
|
||||
|
||||
export async function migratePlanActGlobalToWorkspaceStorage(context: vscode.ExtensionContext) {
|
||||
// Keys that were migrated from global storage to workspace storage
|
||||
const keysToMigrate = [
|
||||
// Core settings
|
||||
"apiProvider",
|
||||
"apiModelId",
|
||||
"thinkingBudgetTokens",
|
||||
"reasoningEffort",
|
||||
"chatSettings",
|
||||
"vsCodeLmModelSelector",
|
||||
|
||||
// Provider-specific model keys
|
||||
"awsBedrockCustomSelected",
|
||||
"awsBedrockCustomModelBaseId",
|
||||
"openRouterModelId",
|
||||
"openRouterModelInfo",
|
||||
"openAiModelId",
|
||||
"openAiModelInfo",
|
||||
"ollamaModelId",
|
||||
"lmStudioModelId",
|
||||
"liteLlmModelId",
|
||||
"liteLlmModelInfo",
|
||||
"requestyModelId",
|
||||
"requestyModelInfo",
|
||||
"togetherModelId",
|
||||
"fireworksModelId",
|
||||
|
||||
// Previous mode settings
|
||||
"previousModeApiProvider",
|
||||
"previousModeModelId",
|
||||
"previousModeModelInfo",
|
||||
"previousModeVsCodeLmModelSelector",
|
||||
"previousModeThinkingBudgetTokens",
|
||||
"previousModeReasoningEffort",
|
||||
"previousModeAwsBedrockCustomSelected",
|
||||
"previousModeAwsBedrockCustomModelBaseId",
|
||||
]
|
||||
|
||||
for (const key of keysToMigrate) {
|
||||
const globalValue = await getGlobalState(context, key as GlobalStateKey)
|
||||
if (globalValue !== undefined) {
|
||||
const workspaceValue = await getWorkspaceState(context, key)
|
||||
if (workspaceValue === undefined) {
|
||||
await updateWorkspaceState(context, key, globalValue)
|
||||
}
|
||||
// Delete from global storage regardless of whether we copied it
|
||||
await updateGlobalState(context, key as GlobalStateKey, undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateMcpMarketplaceEnableSetting(mcpMarketplaceEnabledRaw: boolean | undefined): Promise<boolean> {
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
const mcpMarketplaceEnabled = config.get<boolean>("mcpMarketplace.enabled")
|
||||
if (mcpMarketplaceEnabled !== undefined) {
|
||||
// Remove from VSCode configuration
|
||||
await config.update("mcpMarketplace.enabled", undefined, true)
|
||||
|
||||
return !mcpMarketplaceEnabled
|
||||
}
|
||||
return mcpMarketplaceEnabledRaw ?? true
|
||||
}
|
||||
|
||||
async function migrateEnableCheckpointsSetting(enableCheckpointsSettingRaw: boolean | undefined): Promise<boolean> {
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
const enableCheckpoints = config.get<boolean>("enableCheckpoints")
|
||||
if (enableCheckpoints !== undefined) {
|
||||
// Remove from VSCode configuration
|
||||
await config.update("enableCheckpoints", undefined, true)
|
||||
return enableCheckpoints
|
||||
}
|
||||
return enableCheckpointsSettingRaw ?? true
|
||||
}
|
||||
|
||||
export async function migrateCustomInstructionsToGlobalRules(context: vscode.ExtensionContext) {
|
||||
try {
|
||||
const customInstructions = (await context.globalState.get("customInstructions")) as string | undefined
|
||||
|
||||
if (customInstructions?.trim()) {
|
||||
console.log("Migrating custom instructions to global Cline rules...")
|
||||
|
||||
// Create global .clinerules directory if it doesn't exist
|
||||
const globalRulesDir = await ensureRulesDirectoryExists()
|
||||
|
||||
// Use a fixed filename for custom instructions
|
||||
const migrationFileName = "custom_instructions.md"
|
||||
const migrationFilePath = path.join(globalRulesDir, migrationFileName)
|
||||
|
||||
try {
|
||||
// Check if file already exists to determine if we should append
|
||||
let existingContent = ""
|
||||
try {
|
||||
existingContent = await fs.readFile(migrationFilePath, "utf8")
|
||||
} catch (readError) {
|
||||
// File doesn't exist, which is fine
|
||||
}
|
||||
|
||||
// Append or create the file with custom instructions
|
||||
const contentToWrite = existingContent
|
||||
? `${existingContent}\n\n---\n\n${customInstructions.trim()}`
|
||||
: customInstructions.trim()
|
||||
|
||||
await fs.writeFile(migrationFilePath, contentToWrite)
|
||||
console.log(`Successfully ${existingContent ? "appended to" : "created"} migration file: ${migrationFilePath}`)
|
||||
} catch (fileError) {
|
||||
console.error("Failed to write migration file:", fileError)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove customInstructions from global state only after successful file creation
|
||||
await context.globalState.update("customInstructions", undefined)
|
||||
console.log("Successfully migrated custom instructions to global Cline rules")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate custom instructions to global rules:", error)
|
||||
// Continue execution - migration failure shouldn't break extension startup
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
const [
|
||||
isNewUser,
|
||||
@@ -360,7 +239,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>,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { ChatSettings } from "@shared/ChatSettings"
|
||||
import { ToolParamName, ToolUse, ToolUseName } from "../assistant-message"
|
||||
import { AutoApprove } from "./tools/autoApprove"
|
||||
import {
|
||||
BrowserAction,
|
||||
BrowserActionResult,
|
||||
@@ -30,7 +31,7 @@ import { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { formatResponse } from "../prompts/responses"
|
||||
import { isClaude4ModelFamily } from "@utils/model-utils"
|
||||
import { isClaude4ModelFamily, isGemini2dot5ModelFamily } from "@utils/model-utils"
|
||||
import { ToolResponse, USE_EXPERIMENTAL_CLAUDE4_FEATURES } from "."
|
||||
import { serializeError } from "serialize-error"
|
||||
import * as path from "path"
|
||||
@@ -57,6 +58,17 @@ import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import { ChangeLocation, StreamingJsonReplacer } from "../assistant-message/diff-json"
|
||||
|
||||
export class ToolExecutor {
|
||||
private autoApprover: AutoApprove
|
||||
|
||||
// Auto-approval methods using the AutoApprove class
|
||||
private shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
|
||||
return this.autoApprover.shouldAutoApproveTool(toolName)
|
||||
}
|
||||
|
||||
private shouldAutoApproveToolWithPath(blockname: ToolUseName, autoApproveActionpath: string | undefined): boolean {
|
||||
return this.autoApprover.shouldAutoApproveToolWithPath(blockname, autoApproveActionpath)
|
||||
}
|
||||
|
||||
constructor(
|
||||
// Core Services & Managers
|
||||
private context: vscode.ExtensionContext,
|
||||
@@ -96,21 +108,28 @@ export class ToolExecutor {
|
||||
private saveCheckpoint: (isAttemptCompletionMessage?: boolean) => Promise<void>,
|
||||
private reinitExistingTaskFromId: (taskId: string) => Promise<void>,
|
||||
private cancelTask: () => Promise<void>,
|
||||
private shouldAutoApproveTool: (toolName: ToolUseName) => boolean | [boolean, boolean],
|
||||
private shouldAutoApproveToolWithPath: (blockname: ToolUseName, autoApproveActionpath: string | undefined) => boolean,
|
||||
private sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string, relPath?: string) => Promise<any>,
|
||||
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>,
|
||||
private executeCommandTool: (command: string) => Promise<[boolean, any]>,
|
||||
private doesLatestTaskCompletionHaveNewChanges: () => Promise<boolean>,
|
||||
) {}
|
||||
) {
|
||||
this.autoApprover = new AutoApprove(autoApprovalSettings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the auto approval settings
|
||||
*/
|
||||
public updateAutoApprovalSettings(settings: AutoApprovalSettings): void {
|
||||
this.autoApprover.updateSettings(settings)
|
||||
}
|
||||
|
||||
private pushToolResult = (content: ToolResponse, block: ToolUse) => {
|
||||
const isClaude4Model = isClaude4ModelFamily(this.api)
|
||||
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
|
||||
|
||||
if (typeof content === "string") {
|
||||
const resultText = content || "(tool did not return anything)"
|
||||
|
||||
if (isClaude4Model && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
|
||||
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
|
||||
// Claude 4 family: Use function_results format
|
||||
this.taskState.userMessageContent.push({
|
||||
type: "text",
|
||||
@@ -472,9 +491,9 @@ export class ToolExecutor {
|
||||
|
||||
const currentFullJson = block.params.diff
|
||||
// Check if we should use streaming (e.g., for specific models)
|
||||
const isClaude4Model = isClaude4ModelFamily(this.api)
|
||||
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
|
||||
// Going through claude family of models
|
||||
if (isClaude4Model && USE_EXPERIMENTAL_CLAUDE4_FEATURES && currentFullJson) {
|
||||
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES && currentFullJson) {
|
||||
const streamingResult = await this.handleStreamingJsonReplacement(block, relPath, currentFullJson)
|
||||
|
||||
if (streamingResult.error) {
|
||||
@@ -555,7 +574,7 @@ export class ToolExecutor {
|
||||
tool: fileExists ? "editedExistingFile" : "newFileCreated",
|
||||
path: getReadablePath(this.cwd, this.removeClosingTag(block, "path", relPath)),
|
||||
content: diff || content,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
}
|
||||
|
||||
if (block.partial) {
|
||||
@@ -626,7 +645,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: diff || content,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
// ? formatResponse.createPrettyPatch(
|
||||
// relPath,
|
||||
// this.diffViewProvider.originalContent,
|
||||
@@ -769,7 +788,7 @@ export class ToolExecutor {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: undefined,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -800,7 +819,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: absolutePath,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(relPath),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -839,7 +858,6 @@ export class ToolExecutor {
|
||||
}
|
||||
}
|
||||
case "list_files": {
|
||||
const isClaude4Model = isClaude4ModelFamily(this.api)
|
||||
const relDirPath: string | undefined = block.params.path
|
||||
const recursiveRaw: string | undefined = block.params.recursive
|
||||
const recursive = recursiveRaw?.toLowerCase() === "true"
|
||||
@@ -852,7 +870,7 @@ export class ToolExecutor {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -884,7 +902,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: result,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -927,7 +945,7 @@ export class ToolExecutor {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -956,7 +974,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: result,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -989,7 +1007,6 @@ export class ToolExecutor {
|
||||
}
|
||||
}
|
||||
case "search_files": {
|
||||
const isClaude4Model = isClaude4ModelFamily(this.api)
|
||||
const relDirPath: string | undefined = block.params.path
|
||||
const regex: string | undefined = block.params.regex
|
||||
const filePattern: string | undefined = block.params.file_pattern
|
||||
@@ -1004,7 +1021,7 @@ export class ToolExecutor {
|
||||
const partialMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: "",
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
@@ -1041,7 +1058,7 @@ export class ToolExecutor {
|
||||
const completeMessage = JSON.stringify({
|
||||
...sharedMessageProps,
|
||||
content: results,
|
||||
operationIsLocatedInWorkspace: isLocatedInWorkspace(block.params.path),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
} satisfies ClineSayTool)
|
||||
if (this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
|
||||
this.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
|
||||
+49
-123
@@ -1,13 +1,4 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { execa } from "execa"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import os from "os"
|
||||
import pTimeout from "p-timeout"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { ApiHandler, buildApiHandler } from "@api/index"
|
||||
import { AnthropicHandler } from "@api/providers/anthropic"
|
||||
import { ClineHandler } from "@api/providers/cline"
|
||||
@@ -21,6 +12,7 @@ import { TerminalManager } from "@integrations/terminal/TerminalManager"
|
||||
import { BrowserSession } from "@services/browser/BrowserSession"
|
||||
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
|
||||
import { listFiles } from "@services/glob/list-files"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { telemetryService } from "@services/posthog/telemetry/TelemetryService"
|
||||
import { ApiConfiguration } from "@shared/api"
|
||||
import { findLast, findLastIndex } from "@shared/array"
|
||||
@@ -29,42 +21,47 @@ import { BrowserSettings } from "@shared/BrowserSettings"
|
||||
import { ChatSettings } from "@shared/ChatSettings"
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import {
|
||||
ClineApiReqCancelReason,
|
||||
ClineApiReqInfo,
|
||||
ClineAsk,
|
||||
ClineMessage,
|
||||
ClineSay,
|
||||
ExtensionMessage,
|
||||
} from "@shared/ExtensionMessage"
|
||||
import { ClineApiReqCancelReason, ClineApiReqInfo, ClineAsk, ClineMessage, ClineSay } from "@shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@shared/getApiMetrics"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { DEFAULT_LANGUAGE_SETTINGS, getLanguageKey, LanguageDisplay } from "@shared/Languages"
|
||||
import { ClineAskResponse, ClineCheckpointRestore } from "@shared/WebviewMessage"
|
||||
import { arePathsEqual } from "@utils/path"
|
||||
import cloneDeep from "clone-deep"
|
||||
import { execa } from "execa"
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import os from "os"
|
||||
import pTimeout from "p-timeout"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
import {
|
||||
AssistantMessageContent,
|
||||
parseAssistantMessageV2,
|
||||
parseAssistantMessageV3,
|
||||
ToolParamName,
|
||||
ToolUseName,
|
||||
} from "@core/assistant-message"
|
||||
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
import { parseMentions } from "@core/mentions"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from "@core/prompts/system"
|
||||
import { sendPartialMessageEvent } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import { sendRelinquishControlEvent } from "@core/controller/ui/subscribeToRelinquishControl"
|
||||
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
|
||||
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import { ModelContextTracker } from "@core/context/context-tracking/ModelContextTracker"
|
||||
import { parseAssistantMessageV2, parseAssistantMessageV3, ToolUseName } from "@core/assistant-message"
|
||||
import {
|
||||
checkIsAnthropicContextWindowError,
|
||||
checkIsOpenRouterContextWindowError,
|
||||
} from "@core/context/context-management/context-error-handling"
|
||||
import { getContextWindowInfo } from "@core/context/context-management/context-window-utils"
|
||||
import { ContextManager } from "@core/context/context-management/ContextManager"
|
||||
import { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
|
||||
import { ModelContextTracker } from "@core/context/context-tracking/ModelContextTracker"
|
||||
import {
|
||||
getGlobalClineRules,
|
||||
getLocalClineRules,
|
||||
refreshClineRulesToggles,
|
||||
} from "@core/context/instructions/user-instructions/cline-rules"
|
||||
import {
|
||||
getLocalCursorRules,
|
||||
getLocalWindsurfRules,
|
||||
refreshExternalRulesToggles,
|
||||
} from "@core/context/instructions/user-instructions/external-rules"
|
||||
import { sendPartialMessageEvent } from "@core/controller/ui/subscribeToPartialMessage"
|
||||
import { sendRelinquishControlEvent } from "@core/controller/ui/subscribeToRelinquishControl"
|
||||
import { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
|
||||
import { parseMentions } from "@core/mentions"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { addUserInstructions, SYSTEM_PROMPT } from "@core/prompts/system"
|
||||
import { parseSlashCommands } from "@core/slash-commands"
|
||||
import {
|
||||
ensureRulesDirectoryExists,
|
||||
ensureTaskDirectoryExists,
|
||||
@@ -72,29 +69,19 @@ import {
|
||||
getSavedClineMessages,
|
||||
GlobalFileNames,
|
||||
} from "@core/storage/disk"
|
||||
import {
|
||||
getGlobalClineRules,
|
||||
getLocalClineRules,
|
||||
refreshClineRulesToggles,
|
||||
} from "@core/context/instructions/user-instructions/cline-rules"
|
||||
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
|
||||
import {
|
||||
refreshExternalRulesToggles,
|
||||
getLocalWindsurfRules,
|
||||
getLocalCursorRules,
|
||||
} from "@core/context/instructions/user-instructions/external-rules"
|
||||
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
|
||||
import { getWorkspaceState } from "@core/storage/state"
|
||||
import { parseSlashCommands } from "@core/slash-commands"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import WorkspaceTracker from "@integrations/workspace/WorkspaceTracker"
|
||||
import { McpHub } from "@services/mcp/McpHub"
|
||||
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message"
|
||||
import { isClaude4ModelFamily, isGemini2dot5ModelFamily } from "@utils/model-utils"
|
||||
import { isInTestMode } from "../../services/test/TestMode"
|
||||
import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { isClaude4ModelFamily } from "@utils/model-utils"
|
||||
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
|
||||
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { ToolExecutor } from "./ToolExecutor"
|
||||
import { formatErrorWithStatusCode, updateApiReqMsg } from "./utils"
|
||||
|
||||
export const USE_EXPERIMENTAL_CLAUDE4_FEATURES = false
|
||||
|
||||
@@ -137,7 +124,6 @@ export class Task {
|
||||
// Callbacks
|
||||
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
private postStateToWebview: () => Promise<void>
|
||||
private postMessageToWebview: (message: ExtensionMessage) => Promise<void>
|
||||
private reinitExistingTaskFromId: (taskId: string) => Promise<void>
|
||||
private cancelTask: () => Promise<void>
|
||||
|
||||
@@ -154,7 +140,6 @@ export class Task {
|
||||
workspaceTracker: WorkspaceTracker,
|
||||
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>,
|
||||
postStateToWebview: () => Promise<void>,
|
||||
postMessageToWebview: (message: ExtensionMessage) => Promise<void>,
|
||||
reinitExistingTaskFromId: (taskId: string) => Promise<void>,
|
||||
cancelTask: () => Promise<void>,
|
||||
apiConfiguration: ApiConfiguration,
|
||||
@@ -177,7 +162,6 @@ export class Task {
|
||||
this.workspaceTracker = workspaceTracker
|
||||
this.updateTaskHistory = updateTaskHistory
|
||||
this.postStateToWebview = postStateToWebview
|
||||
this.postMessageToWebview = postMessageToWebview
|
||||
this.reinitExistingTaskFromId = reinitExistingTaskFromId
|
||||
this.cancelTask = cancelTask
|
||||
this.clineIgnoreController = new ClineIgnoreController(cwd)
|
||||
@@ -314,8 +298,6 @@ export class Task {
|
||||
this.saveCheckpoint.bind(this),
|
||||
this.reinitExistingTaskFromId.bind(this),
|
||||
this.cancelTask.bind(this),
|
||||
this.shouldAutoApproveTool.bind(this),
|
||||
this.shouldAutoApproveToolWithPath.bind(this),
|
||||
this.sayAndCreateMissingParamError.bind(this),
|
||||
this.removeLastPartialMessageIfExistsWithType.bind(this),
|
||||
this.executeCommandTool.bind(this),
|
||||
@@ -333,6 +315,13 @@ export class Task {
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the auto approval settings for this task
|
||||
*/
|
||||
public updateAutoApprovalSettings(settings: AutoApprovalSettings): void {
|
||||
this.toolExecutor.updateAutoApprovalSettings(settings)
|
||||
}
|
||||
|
||||
async restoreCheckpoint(messageTs: number, restoreType: ClineCheckpointRestore, offset?: number) {
|
||||
const clineMessages = this.messageStateHandler.getClineMessages()
|
||||
const messageIndex = clineMessages.findIndex((m) => m.ts === messageTs) - (offset || 0)
|
||||
@@ -1554,69 +1543,6 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the tool should be auto-approved based on the settings
|
||||
// Returns bool for most tools, and tuple for tools with nested settings
|
||||
shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
|
||||
if (this.autoApprovalSettings.enabled) {
|
||||
switch (toolName) {
|
||||
case "read_file":
|
||||
case "list_files":
|
||||
case "list_code_definition_names":
|
||||
case "search_files":
|
||||
return [
|
||||
this.autoApprovalSettings.actions.readFiles,
|
||||
this.autoApprovalSettings.actions.readFilesExternally ?? false,
|
||||
]
|
||||
case "new_rule":
|
||||
case "write_to_file":
|
||||
case "replace_in_file":
|
||||
return [
|
||||
this.autoApprovalSettings.actions.editFiles,
|
||||
this.autoApprovalSettings.actions.editFilesExternally ?? false,
|
||||
]
|
||||
case "execute_command":
|
||||
return [
|
||||
this.autoApprovalSettings.actions.executeSafeCommands ?? false,
|
||||
this.autoApprovalSettings.actions.executeAllCommands ?? false,
|
||||
]
|
||||
case "browser_action":
|
||||
return this.autoApprovalSettings.actions.useBrowser
|
||||
case "web_fetch":
|
||||
return this.autoApprovalSettings.actions.useBrowser
|
||||
case "access_mcp_resource":
|
||||
case "use_mcp_tool":
|
||||
return this.autoApprovalSettings.actions.useMcp
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the tool should be auto-approved based on the settings
|
||||
// and the path of the action. Returns true if the tool should be auto-approved
|
||||
// based on the user's settings and the path of the action.
|
||||
shouldAutoApproveToolWithPath(blockname: ToolUseName, autoApproveActionpath: string | undefined): boolean {
|
||||
let isLocalRead: boolean = false
|
||||
if (autoApproveActionpath) {
|
||||
const absolutePath = path.resolve(cwd, autoApproveActionpath)
|
||||
isLocalRead = absolutePath.startsWith(cwd)
|
||||
} else {
|
||||
// If we do not get a path for some reason, default to a (safer) false return
|
||||
isLocalRead = false
|
||||
}
|
||||
|
||||
// Get auto-approve settings for local and external edits
|
||||
const autoApproveResult = this.shouldAutoApproveTool(blockname)
|
||||
const [autoApproveLocal, autoApproveExternal] = Array.isArray(autoApproveResult)
|
||||
? autoApproveResult
|
||||
: [autoApproveResult, false]
|
||||
|
||||
if ((isLocalRead && autoApproveLocal) || (!isLocalRead && autoApproveLocal && autoApproveExternal)) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
|
||||
*/
|
||||
@@ -1654,8 +1580,8 @@ export class Task {
|
||||
|
||||
const supportsBrowserUse = modelSupportsBrowserUse && !disableBrowserTool // only enable browser use if the model supports it and the user hasn't disabled it
|
||||
|
||||
const isClaude4Model = isClaude4ModelFamily(this.api)
|
||||
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsBrowserUse, this.mcpHub, this.browserSettings, isClaude4Model)
|
||||
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
|
||||
let systemPrompt = await SYSTEM_PROMPT(cwd, supportsBrowserUse, this.mcpHub, this.browserSettings, isNextGenModel)
|
||||
|
||||
await this.migratePreferredLanguageToolSetting()
|
||||
const preferredLanguage = getLanguageKey(this.chatSettings.preferredLanguage as LanguageDisplay)
|
||||
@@ -1965,7 +1891,7 @@ export class Task {
|
||||
"mistake_limit_reached",
|
||||
this.api.getModel().id.includes("claude")
|
||||
? `This may indicate a failure in his thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. "Try breaking down the task into smaller steps").`
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 3.7 Sonnet for its advanced agentic coding capabilities.",
|
||||
: "Cline uses complex prompts and iterative task execution that may be challenging for less capable models. For best results, it's recommended to use Claude 4 Sonnet for its advanced agentic coding capabilities.",
|
||||
)
|
||||
if (response === "messageResponse") {
|
||||
// This userContent is for the *next* API call.
|
||||
@@ -2223,8 +2149,8 @@ export class Task {
|
||||
assistantMessage += chunk.text
|
||||
// parse raw assistant message into content blocks
|
||||
const prevLength = this.taskState.assistantMessageContent.length
|
||||
const isClaude4Model = isClaude4ModelFamily(this.api)
|
||||
if (isClaude4Model && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
|
||||
const isNextGenModel = isClaude4ModelFamily(this.api) || isGemini2dot5ModelFamily(this.api)
|
||||
if (isNextGenModel && USE_EXPERIMENTAL_CLAUDE4_FEATURES) {
|
||||
this.taskState.assistantMessageContent = parseAssistantMessageV3(assistantMessage)
|
||||
} else {
|
||||
this.taskState.assistantMessageContent = parseAssistantMessageV2(assistantMessage)
|
||||
|
||||
@@ -12,6 +12,7 @@ import CheckpointTracker from "@integrations/checkpoints/CheckpointTracker"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import Anthropic from "@anthropic-ai/sdk"
|
||||
import { TaskState } from "./TaskState"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
interface MessageStateHandlerParams {
|
||||
context: vscode.ExtensionContext
|
||||
@@ -21,8 +22,6 @@ interface MessageStateHandlerParams {
|
||||
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
|
||||
|
||||
export class MessageStateHandler {
|
||||
private apiConversationHistory: Anthropic.MessageParam[] = []
|
||||
private clineMessages: ClineMessage[] = []
|
||||
@@ -84,6 +83,7 @@ export class MessageStateHandler {
|
||||
} catch (error) {
|
||||
console.error("Failed to get task directory size:", taskDir, error)
|
||||
}
|
||||
const cwd = await getCwd(path.join(os.homedir(), "Desktop"))
|
||||
await this.updateTaskHistory({
|
||||
id: this.taskId,
|
||||
ts: lastRelevantMessage.ts,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { ToolUseName } from "@core/assistant-message"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import os from "os"
|
||||
|
||||
export const cwd =
|
||||
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop")
|
||||
|
||||
export class AutoApprove {
|
||||
autoApprovalSettings: AutoApprovalSettings
|
||||
|
||||
constructor(autoApprovalSettings: AutoApprovalSettings) {
|
||||
this.autoApprovalSettings = autoApprovalSettings
|
||||
}
|
||||
|
||||
// Check if the tool should be auto-approved based on the settings
|
||||
// Returns bool for most tools, and tuple for tools with nested settings
|
||||
shouldAutoApproveTool(toolName: ToolUseName): boolean | [boolean, boolean] {
|
||||
if (this.autoApprovalSettings.enabled) {
|
||||
switch (toolName) {
|
||||
case "read_file":
|
||||
case "list_files":
|
||||
case "list_code_definition_names":
|
||||
case "search_files":
|
||||
return [
|
||||
this.autoApprovalSettings.actions.readFiles,
|
||||
this.autoApprovalSettings.actions.readFilesExternally ?? false,
|
||||
]
|
||||
case "new_rule":
|
||||
case "write_to_file":
|
||||
case "replace_in_file":
|
||||
return [
|
||||
this.autoApprovalSettings.actions.editFiles,
|
||||
this.autoApprovalSettings.actions.editFilesExternally ?? false,
|
||||
]
|
||||
case "execute_command":
|
||||
return [
|
||||
this.autoApprovalSettings.actions.executeSafeCommands ?? false,
|
||||
this.autoApprovalSettings.actions.executeAllCommands ?? false,
|
||||
]
|
||||
case "browser_action":
|
||||
return this.autoApprovalSettings.actions.useBrowser
|
||||
case "web_fetch":
|
||||
return this.autoApprovalSettings.actions.useBrowser
|
||||
case "access_mcp_resource":
|
||||
case "use_mcp_tool":
|
||||
return this.autoApprovalSettings.actions.useMcp
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the tool should be auto-approved based on the settings
|
||||
// and the path of the action. Returns true if the tool should be auto-approved
|
||||
// based on the user's settings and the path of the action.
|
||||
shouldAutoApproveToolWithPath(blockname: ToolUseName, autoApproveActionpath: string | undefined): boolean {
|
||||
let isLocalRead: boolean = false
|
||||
if (autoApproveActionpath) {
|
||||
const absolutePath = path.resolve(cwd, autoApproveActionpath)
|
||||
isLocalRead = absolutePath.startsWith(cwd)
|
||||
} else {
|
||||
// If we do not get a path for some reason, default to a (safer) false return
|
||||
isLocalRead = false
|
||||
}
|
||||
|
||||
// Get auto-approve settings for local and external edits
|
||||
const autoApproveResult = this.shouldAutoApproveTool(blockname)
|
||||
const [autoApproveLocal, autoApproveExternal] = Array.isArray(autoApproveResult)
|
||||
? autoApproveResult
|
||||
: [autoApproveResult, false]
|
||||
|
||||
if ((isLocalRead && autoApproveLocal) || (!isLocalRead && autoApproveLocal && autoApproveExternal)) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
updateSettings(settings: AutoApprovalSettings): void {
|
||||
this.autoApprovalSettings = settings
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,6 @@ export abstract class WebviewProvider {
|
||||
// don't forget to add font-src ${webview.cspSource};
|
||||
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
|
||||
|
||||
const katexCssUri = this.getExtensionUri("webview-ui", "node_modules", "katex", "dist", "katex.min.css")
|
||||
|
||||
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
|
||||
|
||||
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
|
||||
@@ -198,9 +196,8 @@ export abstract class WebviewProvider {
|
||||
<meta name="theme-color" content="#000000">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<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:;
|
||||
@@ -274,9 +271,6 @@ export abstract class WebviewProvider {
|
||||
const stylesUri = this.getExtensionUri("webview-ui", "build", "assets", "index.css")
|
||||
const codiconsUri = this.getExtensionUri("node_modules", "@vscode", "codicons", "dist", "codicon.css")
|
||||
|
||||
// Get KaTeX resources
|
||||
const katexCssUri = this.getExtensionUri("webview-ui", "node_modules", "katex", "dist", "katex.min.css")
|
||||
|
||||
const scriptEntrypoint = "src/main.tsx"
|
||||
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
|
||||
|
||||
@@ -292,7 +286,7 @@ export abstract class WebviewProvider {
|
||||
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`font-src ${this.getCspSource()} data:`,
|
||||
`font-src ${this.getCspSource()}`,
|
||||
`style-src ${this.getCspSource()} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`img-src ${this.getCspSource()} https: data:`,
|
||||
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
||||
@@ -309,7 +303,6 @@ export abstract class WebviewProvider {
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<link href="${katexCssUri}" rel="stylesheet" />
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+15
-6
@@ -22,7 +22,11 @@ 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,
|
||||
migrateModeFromWorkspaceStorageToControllerState,
|
||||
} from "./core/storage/state-migrations"
|
||||
|
||||
import { sendFocusChatInputEvent } from "./core/controller/ui/subscribeToFocusChatInput"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
@@ -30,6 +34,7 @@ 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"
|
||||
import { writeTextToClipboard, readTextFromClipboard } from "@/utils/env"
|
||||
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
@@ -60,6 +65,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// Migrate custom instructions to global Cline rules (one-time cleanup)
|
||||
await migrateCustomInstructionsToGlobalRules(context)
|
||||
|
||||
// Migrate mode from workspace storage to controller state (one-time cleanup)
|
||||
await migrateModeFromWorkspaceStorageToControllerState(context)
|
||||
|
||||
// Clean up orphaned file context warnings (startup cleanup)
|
||||
await FileContextTracker.cleanupOrphanedWarnings(context)
|
||||
|
||||
@@ -68,8 +76,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const previousVersion = context.globalState.get<string>("clineVersion")
|
||||
const sidebarWebview = hostProviders.createWebviewProvider(WebviewProviderType.SIDEBAR)
|
||||
|
||||
const testModeWatchers = await initializeTestMode(sidebarWebview)
|
||||
// Initialize test mode and add disposables to context
|
||||
context.subscriptions.push(...initializeTestMode(context, sidebarWebview))
|
||||
context.subscriptions.push(...testModeWatchers)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
|
||||
@@ -362,17 +371,17 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
|
||||
// Save current clipboard content
|
||||
const tempCopyBuffer = await vscode.env.clipboard.readText()
|
||||
const tempCopyBuffer = await readTextFromClipboard()
|
||||
|
||||
try {
|
||||
// Copy the *existing* terminal selection (without selecting all)
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.copySelection")
|
||||
|
||||
// Get copied content
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
let terminalContents = (await readTextFromClipboard()).trim()
|
||||
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
await writeTextToClipboard(tempCopyBuffer)
|
||||
|
||||
if (!terminalContents) {
|
||||
// No terminal content was copied (either nothing selected or some error)
|
||||
@@ -398,7 +407,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
await visibleWebview?.controller.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
} catch (error) {
|
||||
// Ensure clipboard is restored even if an error occurs
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
await writeTextToClipboard(tempCopyBuffer)
|
||||
console.error("Error getting terminal contents:", error)
|
||||
vscode.window.showErrorMessage("Failed to get terminal contents")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { UriServiceClientInterface, WatchServiceClientInterface } from "@generated/hosts/host-bridge-client-types"
|
||||
import {
|
||||
UriServiceClientInterface,
|
||||
WatchServiceClientInterface,
|
||||
WorkspaceServiceClientInterface,
|
||||
EnvServiceClientInterface,
|
||||
} from "@generated/hosts/host-bridge-client-types"
|
||||
|
||||
/**
|
||||
* Interface for host bridge client providers
|
||||
@@ -6,6 +11,8 @@ import { UriServiceClientInterface, WatchServiceClientInterface } from "@generat
|
||||
export interface HostBridgeClientProvider {
|
||||
uriServiceClient: UriServiceClientInterface
|
||||
watchServiceClient: WatchServiceClientInterface
|
||||
workspaceClient: WorkspaceServiceClientInterface
|
||||
envClient: EnvServiceClientInterface
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,4 +5,6 @@ import * as host from "@shared/proto/index.host"
|
||||
export const vscodeHostBridgeClient: HostBridgeClientProvider = {
|
||||
uriServiceClient: createGrpcClient(host.UriServiceDefinition),
|
||||
watchServiceClient: createGrpcClient(host.WatchServiceDefinition),
|
||||
workspaceClient: createGrpcClient(host.WorkspaceServiceDefinition),
|
||||
envClient: createGrpcClient(host.EnvServiceDefinition),
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { EmptyRequest, String } from "@/shared/proto/common"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export async function clipboardReadText(_: EmptyRequest): Promise<String> {
|
||||
const text = await vscode.env.clipboard.readText()
|
||||
return String.create({ value: text })
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { StringRequest, Empty } from "@/shared/proto/common"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
export async function clipboardWriteText(request: StringRequest): Promise<Empty> {
|
||||
await vscode.env.clipboard.writeText(request.value)
|
||||
return Empty.create({})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { GetWorkspacePathsRequest, GetWorkspacePathsResponse } from "@/shared/proto/index.host"
|
||||
import * as vscode from "vscode"
|
||||
export async function getWorkspacePaths(_: GetWorkspacePathsRequest): Promise<GetWorkspacePathsResponse> {
|
||||
const paths = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? []
|
||||
return GetWorkspacePathsResponse.create({ paths: paths })
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { Controller as ClineProvider } from "@core/controller"
|
||||
import { fileExistsAtPath } from "@utils/fs"
|
||||
import { globby } from "globby"
|
||||
|
||||
class CheckpointTracker {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private taskId: string
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private cwd: string
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
lastCheckpointHash?: string
|
||||
|
||||
private constructor(provider: ClineProvider, taskId: string, cwd: string) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
this.taskId = taskId
|
||||
this.cwd = cwd
|
||||
}
|
||||
|
||||
public static async create(
|
||||
taskId: string,
|
||||
enableCheckpointsSetting: boolean,
|
||||
provider?: ClineProvider,
|
||||
): Promise<CheckpointTracker | undefined> {
|
||||
try {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required to create a checkpoint tracker")
|
||||
}
|
||||
|
||||
if (!enableCheckpointsSetting) {
|
||||
return undefined // Don't create tracker when disabled
|
||||
}
|
||||
|
||||
// Check if git is installed by attempting to get version
|
||||
try {
|
||||
await simpleGit().version()
|
||||
} catch (error) {
|
||||
throw new Error("Git must be installed to use checkpoints.") // FIXME: must match what we check for in TaskHeader to show link
|
||||
}
|
||||
|
||||
const cwd = await CheckpointTracker.getWorkingDirectory()
|
||||
const newTracker = new CheckpointTracker(provider, taskId, cwd)
|
||||
await newTracker.initShadowGit()
|
||||
return newTracker
|
||||
} catch (error) {
|
||||
console.error("Failed to create CheckpointTracker:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static async getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
const homedir = os.homedir()
|
||||
const desktopPath = path.join(homedir, "Desktop")
|
||||
const documentsPath = path.join(homedir, "Documents")
|
||||
const downloadsPath = path.join(homedir, "Downloads")
|
||||
|
||||
switch (cwd) {
|
||||
case homedir:
|
||||
throw new Error("Cannot use checkpoints in home directory")
|
||||
case desktopPath:
|
||||
throw new Error("Cannot use checkpoints in Desktop directory")
|
||||
case documentsPath:
|
||||
throw new Error("Cannot use checkpoints in Documents directory")
|
||||
case downloadsPath:
|
||||
throw new Error("Cannot use checkpoints in Downloads directory")
|
||||
default:
|
||||
return cwd
|
||||
}
|
||||
}
|
||||
|
||||
private async getShadowGitPath(): Promise<string> {
|
||||
const globalStoragePath = this.providerRef.deref()?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
throw new Error("Global storage uri is invalid")
|
||||
}
|
||||
const checkpointsDir = path.join(globalStoragePath, "tasks", this.taskId, "checkpoints")
|
||||
await fs.mkdir(checkpointsDir, { recursive: true })
|
||||
const gitPath = path.join(checkpointsDir, ".git")
|
||||
return gitPath
|
||||
}
|
||||
|
||||
public static async doesShadowGitExist(taskId: string, provider?: ClineProvider): Promise<boolean> {
|
||||
const globalStoragePath = provider?.context.globalStorageUri.fsPath
|
||||
if (!globalStoragePath) {
|
||||
return false
|
||||
}
|
||||
const gitPath = path.join(globalStoragePath, "tasks", taskId, "checkpoints", ".git")
|
||||
return await fileExistsAtPath(gitPath)
|
||||
}
|
||||
|
||||
public async initShadowGit(): Promise<string> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
if (await fileExistsAtPath(gitPath)) {
|
||||
// Make sure it's the same cwd as the configured worktree
|
||||
const worktree = await this.getShadowGitConfigWorkTree()
|
||||
if (worktree !== this.cwd) {
|
||||
throw new Error("Checkpoints can only be used in the original workspace: " + worktree)
|
||||
}
|
||||
|
||||
return gitPath
|
||||
} else {
|
||||
const checkpointsDir = path.dirname(gitPath)
|
||||
const git = simpleGit(checkpointsDir)
|
||||
await git.init()
|
||||
|
||||
await git.addConfig("core.worktree", this.cwd) // sets the working tree to the current workspace
|
||||
|
||||
// Disable commit signing for shadow repo
|
||||
await git.addConfig("commit.gpgSign", "false")
|
||||
|
||||
// Get LFS patterns from workspace if they exist
|
||||
let lfsPatterns: string[] = []
|
||||
try {
|
||||
const attributesPath = path.join(this.cwd, ".gitattributes")
|
||||
if (await fileExistsAtPath(attributesPath)) {
|
||||
const attributesContent = await fs.readFile(attributesPath, "utf8")
|
||||
lfsPatterns = attributesContent
|
||||
.split("\n")
|
||||
.filter((line) => line.includes("filter=lfs"))
|
||||
.map((line) => line.split(" ")[0].trim())
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to read .gitattributes:", error)
|
||||
}
|
||||
|
||||
// Add basic excludes directly in git config, while respecting any .gitignore in the workspace
|
||||
// .git/info/exclude is local to the shadow git repo, so it's not shared with the main repo - and won't conflict with user's .gitignore
|
||||
// TODO: let user customize these
|
||||
const excludesPath = path.join(gitPath, "info", "exclude")
|
||||
await fs.mkdir(path.join(gitPath, "info"), { recursive: true })
|
||||
await fs.writeFile(
|
||||
excludesPath,
|
||||
[
|
||||
".git/", // ignore the user's .git
|
||||
`.git${GIT_DISABLED_SUFFIX}/`, // ignore the disabled nested git repos
|
||||
".DS_Store",
|
||||
"*.log",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
"env/",
|
||||
"venv/",
|
||||
"target/dependency/",
|
||||
"build/dependencies/",
|
||||
"dist/",
|
||||
"out/",
|
||||
"bundle/",
|
||||
"vendor/",
|
||||
"tmp/",
|
||||
"temp/",
|
||||
"deps/",
|
||||
"pkg/",
|
||||
"Pods/",
|
||||
// Media files
|
||||
"*.jpg",
|
||||
"*.jpeg",
|
||||
"*.png",
|
||||
"*.gif",
|
||||
"*.bmp",
|
||||
"*.ico",
|
||||
// "*.svg",
|
||||
"*.mp3",
|
||||
"*.mp4",
|
||||
"*.wav",
|
||||
"*.avi",
|
||||
"*.mov",
|
||||
"*.wmv",
|
||||
"*.webm",
|
||||
"*.webp",
|
||||
"*.m4a",
|
||||
"*.flac",
|
||||
// Build and dependency directories
|
||||
"build/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".gradle/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
".vs/",
|
||||
"coverage/",
|
||||
".next/",
|
||||
".nuxt/",
|
||||
// Cache and temporary files
|
||||
"*.cache",
|
||||
"*.tmp",
|
||||
"*.temp",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
".pytest_cache/",
|
||||
".eslintcache",
|
||||
// Environment and config files
|
||||
".env*",
|
||||
"*.local",
|
||||
"*.development",
|
||||
"*.production",
|
||||
// Large data files
|
||||
"*.zip",
|
||||
"*.tar",
|
||||
"*.gz",
|
||||
"*.rar",
|
||||
"*.7z",
|
||||
"*.iso",
|
||||
"*.bin",
|
||||
"*.exe",
|
||||
"*.dll",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
// Database files
|
||||
"*.sqlite",
|
||||
"*.db",
|
||||
"*.sql",
|
||||
// Log files
|
||||
"*.logs",
|
||||
"*.error",
|
||||
"npm-debug.log*",
|
||||
"yarn-debug.log*",
|
||||
"yarn-error.log*",
|
||||
...lfsPatterns,
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
// Set up git identity (git throws an error if user.name or user.email is not set)
|
||||
await git.addConfig("user.name", "Cline Checkpoint")
|
||||
await git.addConfig("user.email", "noreply@example.com")
|
||||
|
||||
await this.addAllFiles(git)
|
||||
// Initial commit (--allow-empty ensures it works even with no files)
|
||||
await git.commit("initial commit", { "--allow-empty": null })
|
||||
|
||||
return gitPath
|
||||
}
|
||||
}
|
||||
|
||||
public async getShadowGitConfigWorkTree(): Promise<string | undefined> {
|
||||
if (this.lastRetrievedShadowGitConfigWorkTree) {
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
}
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
const worktree = await git.getConfig("core.worktree")
|
||||
this.lastRetrievedShadowGitConfigWorkTree = worktree.value || undefined
|
||||
return this.lastRetrievedShadowGitConfigWorkTree
|
||||
} catch (error) {
|
||||
console.error("Failed to get shadow git config worktree:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async commit(): Promise<string | undefined> {
|
||||
try {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
await this.addAllFiles(git)
|
||||
const result = await git.commit("checkpoint", {
|
||||
"--allow-empty": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
this.lastCheckpointHash = commitHash
|
||||
return commitHash
|
||||
} catch (error) {
|
||||
console.error("Failed to create checkpoint:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
public async resetHead(commitHash: string): Promise<void> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// Clean working directory and force reset
|
||||
// This ensures that the operation will succeed regardless of:
|
||||
// - Untracked files in the workspace
|
||||
// - Staged changes
|
||||
// - Unstaged changes
|
||||
// - Partial commits
|
||||
// - Merge conflicts
|
||||
await git.clean("f", ["-d", "-f"]) // Remove untracked files and directories
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array describing changed files between one commit and either:
|
||||
* - another commit, or
|
||||
* - the current working directory (including uncommitted changes).
|
||||
*
|
||||
* If `rhsHash` is omitted, compares `lhsHash` to the working directory.
|
||||
* If you want truly untracked files to appear, `git add` them first.
|
||||
*
|
||||
* @param lhsHash - The commit to compare from (older commit)
|
||||
* @param rhsHash - The commit to compare to (newer commit).
|
||||
* If omitted, we compare to the working directory.
|
||||
* @returns Array of file changes with before/after content
|
||||
*/
|
||||
public async getDiffSet(
|
||||
lhsHash?: string,
|
||||
rhsHash?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
relativePath: string
|
||||
absolutePath: string
|
||||
before: string
|
||||
after: string
|
||||
}>
|
||||
> {
|
||||
const gitPath = await this.getShadowGitPath()
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
|
||||
// If lhsHash is missing, use the initial commit of the repo
|
||||
let baseHash = lhsHash
|
||||
if (!baseHash) {
|
||||
const rootCommit = await git.raw(["rev-list", "--max-parents=0", "HEAD"])
|
||||
baseHash = rootCommit.trim()
|
||||
}
|
||||
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.addAllFiles(git)
|
||||
|
||||
const diffSummary = rhsHash ? await git.diffSummary([`${baseHash}..${rhsHash}`]) : await git.diffSummary([baseHash])
|
||||
|
||||
// For each changed file, gather before/after content
|
||||
const result = []
|
||||
const cwdPath = (await this.getShadowGitConfigWorkTree()) || this.cwd || ""
|
||||
|
||||
for (const file of diffSummary.files) {
|
||||
const filePath = file.file
|
||||
const absolutePath = path.join(cwdPath, filePath)
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${baseHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
|
||||
let afterContent = ""
|
||||
if (rhsHash) {
|
||||
// if user provided a newer commit, use git.show at that commit
|
||||
try {
|
||||
afterContent = await git.show([`${rhsHash}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
}
|
||||
} else {
|
||||
// otherwise, read from disk (includes uncommitted changes)
|
||||
try {
|
||||
afterContent = await fs.readFile(absolutePath, "utf8")
|
||||
} catch (_) {
|
||||
// file might be deleted => remains empty
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
relativePath: filePath,
|
||||
absolutePath,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async addAllFiles(git: SimpleGit) {
|
||||
await this.renameNestedGitRepos(true)
|
||||
try {
|
||||
await git.add(".")
|
||||
} catch (error) {
|
||||
console.error("Failed to add files to git:", error)
|
||||
} finally {
|
||||
await this.renameNestedGitRepos(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Since we use git to track checkpoints, we need to temporarily disable nested git repos to work around git's requirement of using submodules for nested repos.
|
||||
private async renameNestedGitRepos(disable: boolean) {
|
||||
// Find all .git directories that are not at the root level
|
||||
const gitPaths = await globby("**/.git" + (disable ? "" : GIT_DISABLED_SUFFIX), {
|
||||
cwd: this.cwd,
|
||||
onlyDirectories: true,
|
||||
ignore: [".git"], // Ignore root level .git
|
||||
dot: true,
|
||||
markDirectories: false,
|
||||
})
|
||||
|
||||
// For each nested .git directory, rename it based on operation
|
||||
for (const gitPath of gitPaths) {
|
||||
const fullPath = path.join(this.cwd, gitPath)
|
||||
let newPath: string
|
||||
if (disable) {
|
||||
newPath = fullPath + GIT_DISABLED_SUFFIX
|
||||
} else {
|
||||
newPath = fullPath.endsWith(GIT_DISABLED_SUFFIX) ? fullPath.slice(0, -GIT_DISABLED_SUFFIX.length) : fullPath
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rename(fullPath, newPath)
|
||||
console.log(`CheckpointTracker ${disable ? "disabled" : "enabled"} nested git repo ${gitPath}`)
|
||||
} catch (error) {
|
||||
console.error(`CheckpointTracker failed to ${disable ? "disable" : "enable"} nested git repo ${gitPath}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.disposables.forEach((d) => d.dispose())
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
|
||||
const GIT_DISABLED_SUFFIX = "_disabled"
|
||||
|
||||
export default CheckpointTracker
|
||||
@@ -2,6 +2,7 @@ import { mkdir, access, constants } from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import os from "os"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
/**
|
||||
* Gets the path to the shadow Git repository in globalStorage.
|
||||
@@ -45,7 +46,7 @@ export async function getShadowGitPath(globalStoragePath: string, taskId: string
|
||||
* @throws Error if no workspace is detected, if in a protected directory, or if no read access
|
||||
*/
|
||||
export async function getWorkingDirectory(): Promise<string> {
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
throw new Error("No workspace detected. Please open Cline in a workspace to use checkpoints.")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/**
|
||||
* Filters out image blocks from messages since Claude Code doesn't support images.
|
||||
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
|
||||
*/
|
||||
export function filterMessagesForClaudeCode(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
|
||||
return messages.map((message) => {
|
||||
// Handle simple string messages
|
||||
if (typeof message.content === "string") {
|
||||
return message
|
||||
}
|
||||
|
||||
// Handle complex message structures
|
||||
const filteredContent = message.content.map((block) => {
|
||||
if (block.type === "image") {
|
||||
// Replace image blocks with text placeholders
|
||||
const sourceType = block.source?.type || "unknown"
|
||||
const mediaType = block.source?.media_type || "unknown"
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
|
||||
}
|
||||
}
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: filteredContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,31 +1,124 @@
|
||||
import * as vscode from "vscode"
|
||||
import Anthropic from "@anthropic-ai/sdk"
|
||||
import type Anthropic from "@anthropic-ai/sdk"
|
||||
import { execa } from "execa"
|
||||
import { ClaudeCodeMessage } from "./types"
|
||||
import readline from "readline"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
|
||||
export function runClaudeCode({
|
||||
systemPrompt,
|
||||
messages,
|
||||
path,
|
||||
modelId,
|
||||
}: {
|
||||
type ClaudeCodeOptions = {
|
||||
systemPrompt: string
|
||||
messages: Anthropic.Messages.MessageParam[]
|
||||
path?: string
|
||||
modelId?: string
|
||||
}) {
|
||||
}
|
||||
|
||||
type ProcessState = {
|
||||
partialData: string | null
|
||||
error: Error | null
|
||||
stderrLogs: string
|
||||
exitCode: number | null
|
||||
}
|
||||
|
||||
export async function* runClaudeCode(options: ClaudeCodeOptions): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
const process = runProcess(options)
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdout,
|
||||
})
|
||||
|
||||
try {
|
||||
const processState: ProcessState = {
|
||||
error: null,
|
||||
stderrLogs: "",
|
||||
exitCode: null,
|
||||
partialData: null,
|
||||
}
|
||||
|
||||
process.stderr.on("data", (data) => {
|
||||
processState.stderrLogs += data.toString()
|
||||
})
|
||||
|
||||
process.on("close", (code) => {
|
||||
processState.exitCode = code
|
||||
})
|
||||
|
||||
process.on("error", (err) => {
|
||||
processState.error = err
|
||||
})
|
||||
|
||||
for await (const line of rl) {
|
||||
if (processState.error) {
|
||||
throw processState.error
|
||||
}
|
||||
|
||||
if (line.trim()) {
|
||||
const chunk = parseChunk(line, processState)
|
||||
|
||||
if (!chunk) {
|
||||
continue
|
||||
}
|
||||
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
// We rely on the assistant message. If the output was truncated, it's better having a poorly formatted message
|
||||
// from which to extract something, than throwing an error/showing the model didn't return any messages.
|
||||
if (processState.partialData && processState.partialData.startsWith(`{"type":"assistant"`)) {
|
||||
yield processState.partialData
|
||||
}
|
||||
|
||||
const { exitCode } = await process
|
||||
if (exitCode !== null && exitCode !== 0) {
|
||||
const errorOutput = processState.error?.message || processState.stderrLogs?.trim()
|
||||
throw new Error(
|
||||
`Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput}` : ""}`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
rl.close()
|
||||
if (!process.killed) {
|
||||
process.kill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We want the model to use our custom tool format instead of built-in tools.
|
||||
// Disabling built-in tools prevents tool-only responses and ensures text output.
|
||||
const claudeCodeTools = [
|
||||
"Task",
|
||||
"Bash",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"LS",
|
||||
"exit_plan_mode",
|
||||
"Read",
|
||||
"Edit",
|
||||
"MultiEdit",
|
||||
"Write",
|
||||
"NotebookRead",
|
||||
"NotebookEdit",
|
||||
"WebFetch",
|
||||
"TodoRead",
|
||||
"TodoWrite",
|
||||
"WebSearch",
|
||||
].join(",")
|
||||
|
||||
const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes
|
||||
|
||||
function runProcess({ systemPrompt, messages, path, modelId }: ClaudeCodeOptions) {
|
||||
const claudePath = path || "claude"
|
||||
|
||||
// TODO: Is it worh using sessions? Where do we store the session ID?
|
||||
const args = [
|
||||
"-p",
|
||||
JSON.stringify(messages),
|
||||
"--system-prompt",
|
||||
systemPrompt,
|
||||
"--verbose",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--disallowedTools",
|
||||
claudeCodeTools,
|
||||
// Cline will handle recursive calls
|
||||
"--max-turns",
|
||||
"1",
|
||||
@@ -35,11 +128,54 @@ export function runClaudeCode({
|
||||
args.push("--model", modelId)
|
||||
}
|
||||
|
||||
return execa(claudePath, args, {
|
||||
stdin: "ignore",
|
||||
const claudeCodeProcess = execa(claudePath, args, {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: process.env,
|
||||
env: {
|
||||
...process.env,
|
||||
// The default is 32000. However, I've gotten larger responses, so we increase it unless the user specified it.
|
||||
CLAUDE_CODE_MAX_OUTPUT_TOKENS: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || "64000",
|
||||
},
|
||||
cwd,
|
||||
maxBuffer: 1024 * 1024 * 1000,
|
||||
timeout: CLAUDE_CODE_TIMEOUT,
|
||||
})
|
||||
|
||||
claudeCodeProcess.stdin.write(JSON.stringify(messages))
|
||||
claudeCodeProcess.stdin.end()
|
||||
|
||||
return claudeCodeProcess
|
||||
}
|
||||
|
||||
function parseChunk(data: string, processState: ProcessState) {
|
||||
if (processState.partialData) {
|
||||
processState.partialData += data
|
||||
|
||||
const chunk = attemptParseChunk(processState.partialData)
|
||||
|
||||
if (!chunk) {
|
||||
return null
|
||||
}
|
||||
|
||||
processState.partialData = null
|
||||
return chunk
|
||||
}
|
||||
|
||||
const chunk = attemptParseChunk(data)
|
||||
|
||||
if (!chunk) {
|
||||
processState.partialData = data
|
||||
}
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
function attemptParseChunk(data: string): ClaudeCodeMessage | null {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch (error) {
|
||||
console.error("Error parsing chunk:", error, data.length)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
type InitMessage = {
|
||||
type: "system"
|
||||
subtype: "init"
|
||||
session_id: string
|
||||
tools: string[]
|
||||
mcp_servers: string[]
|
||||
}
|
||||
|
||||
type ClaudeCodeContent = {
|
||||
type: "text"
|
||||
text: string
|
||||
apiKeySource: "none" | "/login managed key" | string
|
||||
}
|
||||
|
||||
type AssistantMessage = {
|
||||
type: "assistant"
|
||||
message: {
|
||||
id: string
|
||||
type: "message"
|
||||
role: "assistant"
|
||||
model: string
|
||||
content: ClaudeCodeContent[]
|
||||
stop_reason: null
|
||||
stop_sequence: null
|
||||
usage: {
|
||||
input_tokens: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
output_tokens: number
|
||||
service_tier: "standard"
|
||||
}
|
||||
}
|
||||
message: Anthropic.Messages.Message
|
||||
session_id: string
|
||||
}
|
||||
|
||||
@@ -39,13 +22,12 @@ type ErrorMessage = {
|
||||
type ResultMessage = {
|
||||
type: "result"
|
||||
subtype: "success"
|
||||
cost_usd: number
|
||||
total_cost_usd: number
|
||||
is_error: boolean
|
||||
duration_ms: number
|
||||
duration_api_ms: number
|
||||
num_turns: number
|
||||
result: string
|
||||
total_cost: number
|
||||
session_id: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as vscode from "vscode"
|
||||
import { getWorkingState } from "@utils/git"
|
||||
import { writeTextToClipboard } from "@utils/env"
|
||||
|
||||
/**
|
||||
* Formats the git diff into a prompt for the AI
|
||||
@@ -57,7 +58,7 @@ export function extractCommitMessage(aiResponse: string): string {
|
||||
* @param message The commit message to copy
|
||||
*/
|
||||
export async function copyCommitMessageToClipboard(message: string): Promise<void> {
|
||||
await vscode.env.clipboard.writeText(message)
|
||||
await writeTextToClipboard(message)
|
||||
vscode.window.showInformationMessage("Commit message copied to clipboard")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import sizeOf from "image-size"
|
||||
|
||||
export async function selectImages(): Promise<string[]> {
|
||||
const options: vscode.OpenDialogOptions = {
|
||||
canSelectMany: true,
|
||||
openLabel: "Select",
|
||||
filters: {
|
||||
Images: ["png", "jpg", "jpeg", "webp"], // supported by anthropic and openrouter
|
||||
},
|
||||
}
|
||||
|
||||
const fileUris = await vscode.window.showOpenDialog(options)
|
||||
|
||||
if (!fileUris || fileUris.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const processedImagePromises = fileUris.map(async (uri) => {
|
||||
const imagePath = uri.fsPath
|
||||
let buffer: Buffer
|
||||
try {
|
||||
// Read the file into a buffer first
|
||||
buffer = await fs.readFile(imagePath)
|
||||
// Convert Node.js Buffer to Uint8Array
|
||||
const uint8Array = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
|
||||
const dimensions = sizeOf(uint8Array) // Get dimensions from Uint8Array
|
||||
if (dimensions.width! > 7500 || dimensions.height! > 7500) {
|
||||
console.warn(`Image dimensions exceed 7500px, skipping: ${imagePath}`)
|
||||
vscode.window.showErrorMessage(
|
||||
`Image too large: ${path.basename(imagePath)} was skipped (dimensions exceed 7500px).`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file or getting dimensions for ${imagePath}:`, error)
|
||||
vscode.window.showErrorMessage(`Could not read dimensions for ${path.basename(imagePath)}, skipping.`)
|
||||
return null
|
||||
}
|
||||
|
||||
// If dimensions are valid, proceed to convert the existing buffer to base64
|
||||
const base64 = buffer.toString("base64")
|
||||
const mimeType = getMimeType(imagePath)
|
||||
return `data:${mimeType};base64,${base64}`
|
||||
})
|
||||
|
||||
const dataUrlsWithNulls = await Promise.all(processedImagePromises)
|
||||
return dataUrlsWithNulls.filter((url) => url !== null) as string[] // Filter out skipped images
|
||||
}
|
||||
|
||||
function getMimeType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
switch (ext) {
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpeg":
|
||||
case ".jpg":
|
||||
return "image/jpeg"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
default:
|
||||
throw new Error(`Unsupported file type: ${ext}`)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export async function showSystemNotification(options: NotificationOptions): Prom
|
||||
const escapedOptions = {
|
||||
...options,
|
||||
title: title.replace(/"/g, '\\"'),
|
||||
message: message.replace(/\\/g, "\\\\").replace(/"/g, '\\"'),
|
||||
message: message.replace(/"/g, '\\"'),
|
||||
subtitle: options.subtitle?.replace(/"/g, '\\"') || "",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from "vscode"
|
||||
import { readTextFromClipboard, writeTextToClipboard } from "@utils/env"
|
||||
|
||||
/**
|
||||
* Gets the contents of the active terminal
|
||||
@@ -6,7 +7,7 @@ import * as vscode from "vscode"
|
||||
*/
|
||||
export async function getLatestTerminalOutput(): Promise<string> {
|
||||
// Store original clipboard content to restore later
|
||||
const originalClipboard = await vscode.env.clipboard.readText()
|
||||
const originalClipboard = await readTextFromClipboard()
|
||||
|
||||
try {
|
||||
// Select terminal content
|
||||
@@ -19,7 +20,7 @@ export async function getLatestTerminalOutput(): Promise<string> {
|
||||
await vscode.commands.executeCommand("workbench.action.terminal.clearSelection")
|
||||
|
||||
// Get terminal contents from clipboard
|
||||
let terminalContents = (await vscode.env.clipboard.readText()).trim()
|
||||
let terminalContents = (await readTextFromClipboard()).trim()
|
||||
|
||||
// Check if there's actually a terminal open
|
||||
if (terminalContents === originalClipboard) {
|
||||
@@ -40,6 +41,6 @@ export async function getLatestTerminalOutput(): Promise<string> {
|
||||
return terminalContents
|
||||
} finally {
|
||||
// Restore original clipboard content
|
||||
await vscode.env.clipboard.writeText(originalClipboard)
|
||||
await writeTextToClipboard(originalClipboard)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,22 @@ import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { listFiles } from "@services/glob/list-files"
|
||||
import { sendWorkspaceUpdateEvent } from "@core/controller/file/subscribeToWorkspaceUpdates"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
|
||||
class WorkspaceTracker {
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private filePaths: Set<string> = new Set()
|
||||
private cwd: string = ""
|
||||
|
||||
constructor() {
|
||||
this.initializeCwd()
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
private async initializeCwd() {
|
||||
this.cwd = await getCwd()
|
||||
}
|
||||
|
||||
private get activeFiles() {
|
||||
return new Set(
|
||||
@@ -18,16 +27,12 @@ class WorkspaceTracker {
|
||||
)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
async populateFilePaths() {
|
||||
// should not auto get filepaths for desktop since it would immediately show permission popup before cline ever creates a file
|
||||
if (!cwd) {
|
||||
if (!this.cwd) {
|
||||
return
|
||||
}
|
||||
const [files, _] = await listFiles(cwd, true, 1_000)
|
||||
const [files, _] = await listFiles(this.cwd, true, 1_000)
|
||||
files.forEach((file) => this.filePaths.add(this.normalizeFilePath(file)))
|
||||
this.workspaceDidUpdate()
|
||||
}
|
||||
@@ -91,18 +96,18 @@ class WorkspaceTracker {
|
||||
}
|
||||
|
||||
private async workspaceDidUpdate() {
|
||||
if (!cwd) {
|
||||
if (!this.cwd) {
|
||||
return
|
||||
}
|
||||
const filePaths = Array.from(new Set([...this.activeFiles, ...this.filePaths])).map((file) => {
|
||||
const relativePath = path.relative(cwd, file).toPosix()
|
||||
const relativePath = path.relative(this.cwd, file).toPosix()
|
||||
return file.endsWith("/") ? relativePath + "/" : relativePath
|
||||
})
|
||||
await sendWorkspaceUpdateEvent(filePaths)
|
||||
}
|
||||
|
||||
private normalizeFilePath(filePath: string): string {
|
||||
const resolvedPath = cwd ? path.resolve(cwd, filePath) : path.resolve(filePath)
|
||||
const resolvedPath = this.cwd ? path.resolve(this.cwd, filePath) : path.resolve(filePath)
|
||||
return filePath.endsWith("/") ? resolvedPath + "/" : resolvedPath
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCwd } from "@/utils/path"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
/*
|
||||
@@ -28,12 +29,12 @@ export async function getPythonEnvPath(): Promise<string | undefined> {
|
||||
// Access the Python extension API
|
||||
const pythonApi = pythonExtension.exports
|
||||
// Get the active environment path for the current workspace
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
|
||||
const workspaceFolder = await getCwd()
|
||||
if (!workspaceFolder) {
|
||||
return undefined
|
||||
}
|
||||
// Get the active python environment path for the current workspace
|
||||
const pythonEnv = await pythonApi?.environments?.getActiveEnvironmentPath(workspaceFolder.uri)
|
||||
const pythonEnv = await pythonApi?.environments?.getActiveEnvironmentPath(workspaceFolder)
|
||||
if (pythonEnv && pythonEnv.path) {
|
||||
return pythonEnv.path
|
||||
} else {
|
||||
|
||||
@@ -49,61 +49,43 @@ export class ClineAccountService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's current credit balance
|
||||
* RPC variant that fetches the user's current credit balance without posting to webview
|
||||
* @returns Balance data or undefined if failed
|
||||
*/
|
||||
async fetchBalance(): Promise<BalanceResponse | undefined> {
|
||||
async fetchBalanceRPC(): Promise<BalanceResponse | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
|
||||
|
||||
// Post to webview
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsBalance",
|
||||
userCreditsBalance: data,
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch balance:", error)
|
||||
console.error("Failed to fetch balance (RPC):", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's usage transactions
|
||||
* RPC variant that fetches the user's usage transactions without posting to webview
|
||||
* @returns Usage transactions or undefined if failed
|
||||
*/
|
||||
async fetchUsageTransactions(): Promise<UsageTransaction[] | undefined> {
|
||||
async fetchUsageTransactionsRPC(): Promise<UsageTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
|
||||
|
||||
// Post to webview
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsUsage",
|
||||
userCreditsUsage: data,
|
||||
})
|
||||
|
||||
return data
|
||||
const data = await this.authenticatedRequest<{ usageTransactions: UsageTransaction[] }>("/user/credits/usage")
|
||||
return data.usageTransactions
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch usage transactions:", error)
|
||||
console.error("Failed to fetch usage transactions (RPC):", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the user's payment transactions
|
||||
* RPC variant that fetches the user's payment transactions without posting to webview
|
||||
* @returns Payment transactions or undefined if failed
|
||||
*/
|
||||
async fetchPaymentTransactions(): Promise<PaymentTransaction[] | undefined> {
|
||||
async fetchPaymentTransactionsRPC(): Promise<PaymentTransaction[] | undefined> {
|
||||
try {
|
||||
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
|
||||
|
||||
// Post to webview
|
||||
await this.postMessageToWebview({
|
||||
type: "userCreditsPayments",
|
||||
userCreditsPayments: data,
|
||||
})
|
||||
|
||||
return data
|
||||
const data = await this.authenticatedRequest<{ paymentTransactions: PaymentTransaction[] }>("/user/credits/payments")
|
||||
return data.paymentTransactions
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch payment transactions:", error)
|
||||
console.error("Failed to fetch payment transactions (RPC):", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,6 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { execa } from "execa"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { WebviewProvider } from "@core/webview"
|
||||
|
||||
/**
|
||||
* Gets a valid workspace path for Git operations
|
||||
* @param visibleWebview The visible webview instance
|
||||
* @returns A valid workspace path
|
||||
*/
|
||||
export function getWorkspacePath(visibleWebview: WebviewProvider): string {
|
||||
// First try to get the path from the controller's state
|
||||
let workspacePath = visibleWebview.controller.context.workspaceState.get<string>("cwd") || ""
|
||||
|
||||
// If workspace path is empty, try to get it from the active workspace folder
|
||||
if (!workspacePath) {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders
|
||||
if (workspaceFolders && workspaceFolders.length > 0) {
|
||||
workspacePath = workspaceFolders[0].uri.fsPath
|
||||
Logger.log(`Using workspace folder path: ${workspacePath}`)
|
||||
} else {
|
||||
// If no workspace folder is open, use the extension directory as a fallback
|
||||
workspacePath = path.join(__dirname, "..", "..", "..")
|
||||
Logger.log(`No workspace folder found, using extension directory: ${workspacePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
return workspacePath
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the workspace path is valid and writable for Git operations
|
||||
@@ -42,7 +15,7 @@ export async function validateWorkspacePath(workspacePath: string): Promise<void
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
const { stdout } = await execa("test", ["-d", workspacePath])
|
||||
await execa("test", ["-d", workspacePath])
|
||||
} catch (error) {
|
||||
throw new Error(`Workspace path does not exist or is not a directory: ${workspacePath}`)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { Logger } from "../logging/Logger"
|
||||
import { createTestServer, shutdownTestServer } from "./TestServer"
|
||||
import { getHostBridgeProvider } from "@/hosts/host-providers"
|
||||
import { GetWorkspacePathsRequest } from "@/shared/proto/index.host"
|
||||
|
||||
// State variable
|
||||
let isTestMode = false
|
||||
@@ -31,13 +33,13 @@ export function isInTestMode(): boolean {
|
||||
/**
|
||||
* Check if we're in test mode by looking for evals.env file in workspace folders
|
||||
*/
|
||||
function checkForTestMode(): boolean {
|
||||
async function checkForTestMode(): Promise<boolean> {
|
||||
// Get all workspace folders
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders || []
|
||||
const workspaceFolders = await getHostBridgeProvider().workspaceClient.getWorkspacePaths({})
|
||||
|
||||
// Check each workspace folder for an evals.env file
|
||||
for (const folder of workspaceFolders) {
|
||||
const evalsEnvPath = path.join(folder.uri.fsPath, "evals.env")
|
||||
for (const folder of workspaceFolders.paths) {
|
||||
const evalsEnvPath = path.join(folder, "evals.env")
|
||||
if (fs.existsSync(evalsEnvPath)) {
|
||||
Logger.log(`Found evals.env file at ${evalsEnvPath}, activating test mode`)
|
||||
return true
|
||||
@@ -49,14 +51,13 @@ function checkForTestMode(): boolean {
|
||||
|
||||
/**
|
||||
* Initialize test mode detection and setup file watchers
|
||||
* @param context VSCode extension context
|
||||
* @param webviewProvider The webview provider instance
|
||||
*/
|
||||
export function initializeTestMode(context: vscode.ExtensionContext, webviewProvider?: any): vscode.Disposable[] {
|
||||
export async function initializeTestMode(webviewProvider?: any): Promise<vscode.Disposable[]> {
|
||||
const disposables: vscode.Disposable[] = []
|
||||
|
||||
// Check if we're in test mode
|
||||
const IS_TEST = checkForTestMode()
|
||||
const IS_TEST = await checkForTestMode()
|
||||
|
||||
// Set test mode state for other parts of the code
|
||||
if (IS_TEST) {
|
||||
|
||||
@@ -6,13 +6,7 @@ import { Logger } from "@services/logging/Logger"
|
||||
import { WebviewProvider } from "@core/webview"
|
||||
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
|
||||
import { TaskServiceClient } from "webview-ui/src/services/grpc-client"
|
||||
import {
|
||||
getWorkspacePath,
|
||||
validateWorkspacePath,
|
||||
initializeGitRepository,
|
||||
getFileChanges,
|
||||
calculateToolSuccessRate,
|
||||
} from "./GitHelper"
|
||||
import { validateWorkspacePath, initializeGitRepository, getFileChanges, calculateToolSuccessRate } from "./GitHelper"
|
||||
import {
|
||||
updateGlobalState,
|
||||
getAllExtensionState,
|
||||
@@ -25,6 +19,7 @@ import { ApiProvider } from "@shared/api"
|
||||
import { HistoryItem } from "@shared/HistoryItem"
|
||||
import { getSavedClineMessages, getSavedApiConversationHistory } from "@core/storage/disk"
|
||||
import { AskResponseRequest } from "@/shared/proto/task"
|
||||
import { getCwd } from "@/utils/path"
|
||||
|
||||
/**
|
||||
* Creates a tracker to monitor tool calls and failures during task execution
|
||||
@@ -215,7 +210,7 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
|
||||
|
||||
try {
|
||||
// Get and validate the workspace path
|
||||
const workspacePath = getWorkspacePath(visibleWebview)
|
||||
const workspacePath = await getCwd()
|
||||
Logger.log(`Using workspace path: ${workspacePath}`)
|
||||
|
||||
// Validate workspace path before proceeding with any operations
|
||||
@@ -375,7 +370,7 @@ export function createTestServer(webviewProvider?: WebviewProvider): http.Server
|
||||
let fileChanges
|
||||
try {
|
||||
// Get the workspace path using our helper function
|
||||
const workspacePath = getWorkspacePath(visibleWebview)
|
||||
const workspacePath = await getCwd()
|
||||
Logger.log(`Getting file changes from workspace path: ${workspacePath}`)
|
||||
|
||||
// Log directory contents for debugging
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface ChatSettings {
|
||||
|
||||
export type PartialChatSettings = Partial<ChatSettings>
|
||||
|
||||
// Type for chat settings stored in workspace (excludes in-memory mode)
|
||||
export type StoredChatSettings = Omit<ChatSettings, "mode">
|
||||
|
||||
export const DEFAULT_CHAT_SETTINGS: ChatSettings = {
|
||||
mode: "act",
|
||||
preferredLanguage: "English",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user