mirror of
https://github.com/cline/cline.git
synced 2026-09-06 12:28:08 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2212deb3d | |||
| 36bdf643a6 | |||
| e9b8991e5e | |||
| 06cc49f115 | |||
| 87c28fc422 | |||
| 096ba34948 | |||
| 07dedee284 | |||
| dde81ec0af | |||
| 90807717a7 | |||
| bef69c3be2 | |||
| 7b60a97771 | |||
| fca81a1eba | |||
| 6b7ce5d7c7 | |||
| 833d8c7395 | |||
| 49e6bc2c1f |
+66
-60
@@ -1,62 +1,68 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "1.0.3",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "cline-core.js",
|
||||
"bin": {
|
||||
"cline": "./bin/cline",
|
||||
"cline-host": "./bin/cline-host"
|
||||
},
|
||||
"man": "./man/cline.1",
|
||||
"scripts": {
|
||||
"postinstall": "node postinstall.js"
|
||||
},
|
||||
"bundleDependencies": [
|
||||
"@grpc/grpc-js",
|
||||
"@grpc/reflection",
|
||||
"better-sqlite3",
|
||||
"grpc-health-check",
|
||||
"open",
|
||||
"vscode-uri"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"claude",
|
||||
"dev",
|
||||
"mcp",
|
||||
"openrouter",
|
||||
"coding",
|
||||
"agent",
|
||||
"autonomous",
|
||||
"chatgpt",
|
||||
"sonnet",
|
||||
"ai",
|
||||
"llama",
|
||||
"cli"
|
||||
],
|
||||
"author": {
|
||||
"name": "Cline Bot Inc."
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline"
|
||||
},
|
||||
"homepage": "https://cline.bot",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cline/cline/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.13.3",
|
||||
"@grpc/reflection": "^1.0.4",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"grpc-health-check": "^2.0.2",
|
||||
"open": "^10.1.2",
|
||||
"vscode-uri": "^3.1.0"
|
||||
},
|
||||
"os": ["darwin", "linux"],
|
||||
"cpu": ["x64", "arm64"]
|
||||
"name": "cline",
|
||||
"version": "1.0.3",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "cline-core.js",
|
||||
"bin": {
|
||||
"cline": "./bin/cline",
|
||||
"cline-host": "./bin/cline-host"
|
||||
},
|
||||
"man": "./man/cline.1",
|
||||
"scripts": {
|
||||
"postinstall": "node postinstall.js"
|
||||
},
|
||||
"bundleDependencies": [
|
||||
"@grpc/grpc-js",
|
||||
"@grpc/reflection",
|
||||
"better-sqlite3",
|
||||
"grpc-health-check",
|
||||
"open",
|
||||
"vscode-uri"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"cline",
|
||||
"claude",
|
||||
"dev",
|
||||
"mcp",
|
||||
"openrouter",
|
||||
"coding",
|
||||
"agent",
|
||||
"autonomous",
|
||||
"chatgpt",
|
||||
"sonnet",
|
||||
"ai",
|
||||
"llama",
|
||||
"cli"
|
||||
],
|
||||
"author": {
|
||||
"name": "Cline Bot Inc."
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/cline/cline"
|
||||
},
|
||||
"homepage": "https://cline.bot",
|
||||
"bugs": {
|
||||
"url": "https://github.com/cline/cline/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.13.3",
|
||||
"@grpc/reflection": "^1.0.4",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"grpc-health-check": "^2.0.2",
|
||||
"open": "^10.1.2",
|
||||
"vscode-uri": "^3.1.0"
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface ToolUse {
|
||||
// params is a partial record, allowing only some or none of the possible parameters to be used
|
||||
params: Partial<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
isNativeToolCall?: boolean
|
||||
}
|
||||
|
||||
// parseAssistantmessageV1 removed in https://github.com/cline/cline/pull/5425
|
||||
@@ -96,11 +97,11 @@ export interface ToolUse {
|
||||
export function parseAssistantMessageV2(assistantMessage: string): AssistantMessageContent[] {
|
||||
const contentBlocks: AssistantMessageContent[] = []
|
||||
let currentTextContentStart = 0 // Index where the current text block started
|
||||
let currentTextContent: TextContent | undefined = undefined
|
||||
let currentTextContent: TextContent | undefined
|
||||
let currentToolUseStart = 0 // Index *after* the opening tag of the current tool use
|
||||
let currentToolUse: ToolUse | undefined = undefined
|
||||
let currentToolUse: ToolUse | undefined
|
||||
let currentParamValueStart = 0 // Index *after* the opening tag of the current param
|
||||
let currentParamName: ToolParamName | undefined = undefined
|
||||
let currentParamName: ToolParamName | undefined
|
||||
|
||||
// Precompute tags for faster lookups
|
||||
const toolUseOpenTags = new Map<string, ToolUseName>()
|
||||
|
||||
@@ -32,7 +32,7 @@ const ESCAPE_MAP: Record<string, string> = {
|
||||
const ESCAPE_PATTERN = /\\[ntr"\\]/g
|
||||
|
||||
/**
|
||||
* Handles streaming tool use blocks and converts them to Anthropic.ToolUseBlockParam format
|
||||
* Handles streaming native tool use blocks and converts them to Anthropic.ToolUseBlockParam format
|
||||
*/
|
||||
export class ToolUseHandler {
|
||||
private pendingToolUses = new Map<string, PendingToolUse>()
|
||||
@@ -130,6 +130,7 @@ export class ToolUseHandler {
|
||||
arguments: JSON.stringify(input),
|
||||
},
|
||||
partial: true,
|
||||
isNativeToolCall: true,
|
||||
})
|
||||
} else {
|
||||
const params: Record<string, string> = {}
|
||||
@@ -143,6 +144,7 @@ export class ToolUseHandler {
|
||||
name: pending.name as ClineDefaultTool,
|
||||
params: params as any,
|
||||
partial: true,
|
||||
isNativeToolCall: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +51,6 @@ export interface ToolUse {
|
||||
// params is a partial record, allowing only some or none of the possible parameters to be used
|
||||
params: Partial<Record<ToolParamName, string>>
|
||||
partial: boolean
|
||||
// Whether this tool use was initiated by a native tool call
|
||||
isNativeToolCall?: boolean
|
||||
}
|
||||
|
||||
@@ -358,6 +358,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
|
||||
if (request.nativeToolCallEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("nativeToolCallEnabled", !!request.nativeToolCallEnabled)
|
||||
if (controller.task) {
|
||||
telemetryService.captureFeatureToggle(
|
||||
controller.task.ulid,
|
||||
"native-tool-call",
|
||||
request.nativeToolCallEnabled,
|
||||
controller.task.api.getModel().id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Post updated state to webview
|
||||
|
||||
@@ -299,10 +299,9 @@ describe("Prompt System Integration Tests", () => {
|
||||
`This is a new test case. Run with --update-snapshots to create the initial snapshot.`,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
// Re-throw comparison errors
|
||||
throw error
|
||||
}
|
||||
// Re-throw comparison errors
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -390,14 +389,8 @@ describe("Prompt System Integration Tests", () => {
|
||||
this.timeout(30000)
|
||||
|
||||
const invalidContext = {} as SystemPromptContext
|
||||
|
||||
try {
|
||||
const prompt = await getSystemPrompt(invalidContext)
|
||||
expect(prompt).to.be.a("string")
|
||||
} catch (error) {
|
||||
// Error is acceptable for invalid context
|
||||
expect(error).to.be.instanceOf(Error)
|
||||
}
|
||||
const { systemPrompt } = await getSystemPrompt(invalidContext)
|
||||
expect(systemPrompt).to.be.a("string")
|
||||
})
|
||||
|
||||
it("should handle undefined context properties", async function () {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineTool } from "@/shared/tools"
|
||||
import type { ClineTool } from "@/shared/tools"
|
||||
import { ClineToolSet } from ".."
|
||||
import { getSystemPromptComponents } from "../components"
|
||||
import { registerClineToolSets } from "../tools"
|
||||
@@ -68,18 +68,21 @@ export class PromptRegistry {
|
||||
}
|
||||
|
||||
getModelFamily(context: SystemPromptContext) {
|
||||
// Loop through all registered variants to find the first one that matches
|
||||
for (const [id, v] of this.variants.entries()) {
|
||||
try {
|
||||
if (v.matcher(context)) {
|
||||
return v.family
|
||||
// Ensure providerInfo and model ID are available
|
||||
if (context.providerInfo?.model?.id) {
|
||||
// Loop through all registered variants to find the first one that matches
|
||||
for (const [_, v] of this.variants.entries()) {
|
||||
try {
|
||||
if (v.matcher(context)) {
|
||||
return v.family
|
||||
}
|
||||
} catch {
|
||||
// Continue to next variant if matcher throws
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Matcher function error for variant '${id}':`, error)
|
||||
// Continue to next variant if matcher throws
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generic variant if no match found
|
||||
console.log("No matching variant found, falling back to generic")
|
||||
return ModelFamily.GENERIC
|
||||
}
|
||||
/**
|
||||
|
||||
@@ -45,8 +45,9 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
|
||||
.tools(
|
||||
ClineDefaultTool.BASH,
|
||||
ClineDefaultTool.FILE_READ,
|
||||
ClineDefaultTool.FILE_NEW,
|
||||
ClineDefaultTool.FILE_EDIT,
|
||||
ClineDefaultTool.APPLY_PATCH,
|
||||
// ClineDefaultTool.FILE_NEW, // Replaced by APPLY_PATCH
|
||||
// ClineDefaultTool.FILE_EDIT, // Replaced by APPLY_PATCH
|
||||
ClineDefaultTool.SEARCH,
|
||||
ClineDefaultTool.LIST_FILES,
|
||||
ClineDefaultTool.LIST_CODE_DEF,
|
||||
@@ -80,6 +81,9 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
|
||||
.overrideComponent(SystemPromptSection.FEEDBACK, {
|
||||
template: GPT_5_TEMPLATE_OVERRIDES.FEEDBACK,
|
||||
})
|
||||
.overrideComponent(SystemPromptSection.EDITING_FILES, {
|
||||
enabled: false,
|
||||
})
|
||||
.build()
|
||||
|
||||
// Compile-time validation
|
||||
|
||||
@@ -15,10 +15,6 @@ export const BASE = `{{${SystemPromptSection.AGENT_ROLE}}}
|
||||
|
||||
====
|
||||
|
||||
{{${SystemPromptSection.EDITING_FILES}}}
|
||||
|
||||
====
|
||||
|
||||
{{${SystemPromptSection.ACT_VS_PLAN}}}
|
||||
|
||||
====
|
||||
|
||||
@@ -80,7 +80,16 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
|
||||
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
true,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
const notificationMessage = `Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`
|
||||
@@ -92,10 +101,28 @@ export class AccessMcpResourceHandler implements IFullyManagedTool {
|
||||
|
||||
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
|
||||
if (!didApprove) {
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
} else {
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -166,6 +166,7 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
|
||||
true,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
@@ -188,6 +189,7 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
false,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
}
|
||||
@@ -199,6 +201,7 @@ export class ExecuteCommandToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,16 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
true,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
const notificationMessage = `Cline wants to analyze code definitions in ${getWorkspaceBasename(absolutePath, "ListCodeDefinitionNamesToolHandler.notification")}`
|
||||
@@ -100,10 +109,28 @@ export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
|
||||
|
||||
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
|
||||
if (!didApprove) {
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
} else {
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
|
||||
true,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
@@ -133,6 +134,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
false,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
} else {
|
||||
@@ -144,6 +146,7 @@ export class ListFilesToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
true,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
@@ -131,6 +132,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
false,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
} else {
|
||||
@@ -142,6 +144,7 @@ export class ReadFileToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +319,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
true,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
@@ -339,6 +340,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
false,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
} else {
|
||||
@@ -350,6 +352,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,16 @@ export class UseMcpToolHandler implements IFullyManagedTool {
|
||||
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
|
||||
|
||||
// Capture telemetry
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, true, true)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
true,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
const notificationMessage = `Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`
|
||||
@@ -107,10 +116,28 @@ export class UseMcpToolHandler implements IFullyManagedTool {
|
||||
|
||||
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("use_mcp_server", completeMessage, config)
|
||||
if (!didApprove) {
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
} else {
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,16 @@ export class WebFetchToolHandler implements IFullyManagedTool {
|
||||
// Auto-approve flow
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
telemetryService.captureToolUsage(config.ulid, "web_fetch", config.api.getModel().id, provider, true, true)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
"web_fetch",
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
true,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
} else {
|
||||
// Manual approval flow
|
||||
showNotificationForApproval(
|
||||
@@ -75,10 +84,28 @@ export class WebFetchToolHandler implements IFullyManagedTool {
|
||||
|
||||
const didApprove = await ToolResultUtils.askApprovalAndPushFeedback("tool", completeMessage, config)
|
||||
if (!didApprove) {
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, false)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
return formatResponse.toolDenied()
|
||||
} else {
|
||||
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, provider, false, true)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
block.name,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
true,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
|
||||
// we need an artificial delay to let the diagnostics catch up to the changes
|
||||
@@ -235,6 +236,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
false,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
|
||||
await config.services.diffViewProvider.revertChanges()
|
||||
@@ -265,6 +267,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
false,
|
||||
true,
|
||||
workspaceContext,
|
||||
block.isNativeToolCall,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -404,6 +407,11 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
// Full original behavior - comprehensive error handling even for partial blocks
|
||||
await config.callbacks.say("diff_error", relPath)
|
||||
|
||||
// Extract provider information for telemetry
|
||||
const apiConfig = config.services.stateManager.getApiConfiguration()
|
||||
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
|
||||
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
|
||||
|
||||
// Extract error type from error message if possible
|
||||
const errorType =
|
||||
error instanceof Error && error.message.includes("does not match anything")
|
||||
@@ -411,7 +419,14 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
: "other_diff_error"
|
||||
|
||||
// Add telemetry for diff edit failure
|
||||
telemetryService.captureDiffEditFailure(config.ulid, config.api.getModel().id, errorType)
|
||||
const isNativeToolCall = block.isNativeToolCall === true
|
||||
telemetryService.captureDiffEditFailure(
|
||||
config.ulid,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
errorType,
|
||||
isNativeToolCall,
|
||||
)
|
||||
|
||||
// Push tool result with detailed error using existing utilities
|
||||
const errorResponse = formatResponse.toolError(
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface StronglyTypedUIHelpers {
|
||||
askApproval: (messageType: ClineAsk, message: string) => Promise<boolean>
|
||||
|
||||
// Telemetry and notifications
|
||||
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean) => void
|
||||
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean, isNativeToolCall?: boolean) => void
|
||||
showNotificationIfEnabled: (message: string) => void
|
||||
|
||||
// Config access - returns the proper typed config
|
||||
@@ -57,13 +57,22 @@ export function createUIHelpers(config: TaskConfig): StronglyTypedUIHelpers {
|
||||
const { response } = await config.callbacks.ask(messageType, message, false)
|
||||
return response === "yesButtonClicked"
|
||||
},
|
||||
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean) => {
|
||||
captureTelemetry: (toolName: ClineDefaultTool, autoApproved: boolean, approved: boolean, isNativeToolCall?: boolean) => {
|
||||
// Extract provider information for telemetry
|
||||
const apiConfig = config.services.stateManager.getApiConfiguration()
|
||||
const currentMode = config.services.stateManager.getGlobalSettingsKey("mode")
|
||||
const provider = (currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider) as string
|
||||
|
||||
telemetryService.captureToolUsage(config.ulid, toolName, config.api.getModel().id, provider, autoApproved, approved)
|
||||
telemetryService.captureToolUsage(
|
||||
config.ulid,
|
||||
toolName,
|
||||
config.api.getModel().id,
|
||||
provider,
|
||||
autoApproved,
|
||||
approved,
|
||||
undefined,
|
||||
isNativeToolCall,
|
||||
)
|
||||
},
|
||||
showNotificationIfEnabled: (message: string) => {
|
||||
showNotificationForApproval(message, config.autoApprovalSettings.enableNotifications)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
|
||||
|
||||
export interface FileOpsResult {
|
||||
finalContent?: string
|
||||
deleted?: boolean
|
||||
newProblemsMessage?: string
|
||||
userEdits?: string
|
||||
autoFormattingEdits?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility class for file operations via a DiffViewProvider
|
||||
*/
|
||||
export class FileProviderOperations {
|
||||
constructor(private provider: DiffViewProvider) {}
|
||||
|
||||
async createFile(path: string, content: string): Promise<FileOpsResult> {
|
||||
this.provider.editType = "create"
|
||||
await this.provider.open(path)
|
||||
await this.provider.update(content, true)
|
||||
const result = await this.provider.saveChanges()
|
||||
await this.provider.reset()
|
||||
return result
|
||||
}
|
||||
|
||||
async modifyFile(path: string, content: string): Promise<FileOpsResult> {
|
||||
this.provider.editType = "modify"
|
||||
await this.provider.open(path)
|
||||
await this.provider.update(content, true)
|
||||
const result = await this.provider.saveChanges()
|
||||
await this.provider.reset()
|
||||
return result
|
||||
}
|
||||
|
||||
async deleteFile(path: string): Promise<void> {
|
||||
this.provider.editType = "modify"
|
||||
await this.provider.open(path)
|
||||
await this.provider.revertChanges()
|
||||
}
|
||||
|
||||
async moveFile(oldPath: string, newPath: string, content: string): Promise<FileOpsResult> {
|
||||
const result = await this.createFile(newPath, content)
|
||||
await this.deleteFile(oldPath)
|
||||
return result
|
||||
}
|
||||
|
||||
async getFileContent(): Promise<string | undefined> {
|
||||
return this.provider.originalContent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import {
|
||||
DiffError,
|
||||
PATCH_MARKERS,
|
||||
type Patch,
|
||||
type PatchAction,
|
||||
PatchActionType,
|
||||
type PatchChunk,
|
||||
type PatchWarning,
|
||||
} from "@/shared/Patch"
|
||||
import { canonicalize } from "@/shared/string"
|
||||
|
||||
/**
|
||||
* Parser for Apply Patch content
|
||||
*/
|
||||
export class PatchParser {
|
||||
private patch: Patch = { actions: {}, warnings: [] }
|
||||
private index = 0
|
||||
private fuzz = 0
|
||||
private currentPath?: string
|
||||
|
||||
constructor(
|
||||
private lines: string[],
|
||||
private currentFiles: Record<string, string>,
|
||||
) {}
|
||||
|
||||
parse(): { patch: Patch; fuzz: number } {
|
||||
this.skipBeginSentinel()
|
||||
|
||||
while (this.hasMoreLines() && !this.isEndMarker()) {
|
||||
this.parseNextAction()
|
||||
}
|
||||
|
||||
// Clean up empty warnings array
|
||||
if (this.patch.warnings?.length === 0) {
|
||||
delete this.patch.warnings
|
||||
}
|
||||
|
||||
return { patch: this.patch, fuzz: this.fuzz }
|
||||
}
|
||||
|
||||
private addWarning(warning: PatchWarning): void {
|
||||
if (!this.patch.warnings) {
|
||||
this.patch.warnings = []
|
||||
}
|
||||
this.patch.warnings.push(warning)
|
||||
}
|
||||
|
||||
private skipBeginSentinel(): void {
|
||||
if (this.lines[this.index]?.startsWith(PATCH_MARKERS.BEGIN)) {
|
||||
this.index++
|
||||
}
|
||||
}
|
||||
|
||||
private hasMoreLines(): boolean {
|
||||
return this.index < this.lines.length
|
||||
}
|
||||
|
||||
private isEndMarker(): boolean {
|
||||
return this.lines[this.index]?.startsWith(PATCH_MARKERS.END) ?? false
|
||||
}
|
||||
|
||||
private parseNextAction(): void {
|
||||
const line = this.lines[this.index]
|
||||
|
||||
if (line.startsWith(PATCH_MARKERS.UPDATE)) {
|
||||
this.parseUpdate(line.substring(PATCH_MARKERS.UPDATE.length).trim())
|
||||
} else if (line.startsWith(PATCH_MARKERS.DELETE)) {
|
||||
this.parseDelete(line.substring(PATCH_MARKERS.DELETE.length).trim())
|
||||
} else if (line.startsWith(PATCH_MARKERS.ADD)) {
|
||||
this.parseAdd(line.substring(PATCH_MARKERS.ADD.length).trim())
|
||||
} else {
|
||||
throw new DiffError(`Unknown line while parsing: ${line}`)
|
||||
}
|
||||
}
|
||||
|
||||
private checkDuplicate(path: string, operation: string): void {
|
||||
if (path in this.patch.actions) {
|
||||
throw new DiffError(`Duplicate ${operation} for file: ${path}`)
|
||||
}
|
||||
}
|
||||
|
||||
private parseUpdate(path: string): void {
|
||||
this.checkDuplicate(path, "update")
|
||||
this.currentPath = path
|
||||
|
||||
this.index++
|
||||
const movePath = this.lines[this.index]?.startsWith(PATCH_MARKERS.MOVE)
|
||||
? this.lines[this.index++].substring(PATCH_MARKERS.MOVE.length).trim()
|
||||
: undefined
|
||||
|
||||
if (!(path in this.currentFiles)) {
|
||||
throw new DiffError(`Update File Error: Missing File: ${path}`)
|
||||
}
|
||||
|
||||
const text = this.currentFiles[path]!
|
||||
const action = this.parseUpdateFile(text, path)
|
||||
action.movePath = movePath
|
||||
|
||||
this.patch.actions[path] = action
|
||||
this.currentPath = undefined
|
||||
}
|
||||
|
||||
private parseUpdateFile(text: string, _path: string): PatchAction {
|
||||
const action: PatchAction = { type: PatchActionType.UPDATE, chunks: [] }
|
||||
const fileLines = text.split("\n")
|
||||
let index = 0
|
||||
|
||||
const stopMarkers = [
|
||||
PATCH_MARKERS.END,
|
||||
PATCH_MARKERS.UPDATE,
|
||||
PATCH_MARKERS.DELETE,
|
||||
PATCH_MARKERS.ADD,
|
||||
PATCH_MARKERS.END_FILE,
|
||||
]
|
||||
|
||||
while (!stopMarkers.some((m) => this.lines[this.index]?.startsWith(m.trim()))) {
|
||||
const defStr = this.lines[this.index]?.startsWith("@@ ") ? this.lines[this.index]!.substring(3) : undefined
|
||||
const sectionStr = this.lines[this.index] === "@@" ? this.lines[this.index] : undefined
|
||||
|
||||
if (defStr !== undefined || sectionStr !== undefined) {
|
||||
this.index++
|
||||
} else if (index !== 0) {
|
||||
throw new DiffError(`Invalid Line:\n${this.lines[this.index]}`)
|
||||
}
|
||||
|
||||
// Try to find the @@ context marker in the file
|
||||
if (defStr?.trim()) {
|
||||
const canonDefStr = canonicalize(defStr.trim())
|
||||
for (let i = index; i < fileLines.length; i++) {
|
||||
if (canonicalize(fileLines[i]!) === canonDefStr || canonicalize(fileLines[i]!.trim()) === canonDefStr) {
|
||||
index = i + 1
|
||||
if (canonicalize(fileLines[i]!.trim()) === canonDefStr && canonicalize(fileLines[i]!) !== canonDefStr) {
|
||||
this.fuzz++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [nextChunkContext, chunks, endPatchIndex, eof] = peek(this.lines, this.index)
|
||||
const [newIndex, fuzz, similarity] = findContext(fileLines, nextChunkContext, index, eof)
|
||||
|
||||
if (newIndex === -1) {
|
||||
const ctxText = nextChunkContext.join("\n")
|
||||
// Add warning but continue - skip this chunk
|
||||
this.addWarning({
|
||||
path: this.currentPath || _path,
|
||||
chunkIndex: action.chunks.length,
|
||||
message: `Could not find matching context (similarity: ${similarity.toFixed(2)}). Chunk skipped.`,
|
||||
context: ctxText.length > 200 ? `${ctxText.substring(0, 200)}...` : ctxText,
|
||||
})
|
||||
// Move patch index forward to skip this chunk, but keep file position
|
||||
// so subsequent chunks can still be found
|
||||
this.index = endPatchIndex
|
||||
// Don't advance file position - let next chunk search from current position
|
||||
} else {
|
||||
this.fuzz += fuzz
|
||||
|
||||
for (const chunk of chunks) {
|
||||
chunk.origIndex += newIndex
|
||||
action.chunks.push(chunk)
|
||||
}
|
||||
|
||||
index = newIndex + nextChunkContext.length
|
||||
this.index = endPatchIndex
|
||||
}
|
||||
}
|
||||
|
||||
return action
|
||||
}
|
||||
|
||||
private parseDelete(path: string): void {
|
||||
this.checkDuplicate(path, "delete")
|
||||
|
||||
if (!(path in this.currentFiles)) {
|
||||
throw new DiffError(`Delete File Error: Missing File: ${path}`)
|
||||
}
|
||||
|
||||
this.patch.actions[path] = { type: PatchActionType.DELETE, chunks: [] }
|
||||
this.index++
|
||||
}
|
||||
|
||||
private parseAdd(path: string): void {
|
||||
this.checkDuplicate(path, "add")
|
||||
|
||||
if (path in this.currentFiles) {
|
||||
throw new DiffError(`Add File Error: File already exists: ${path}`)
|
||||
}
|
||||
|
||||
this.index++
|
||||
const lines: string[] = []
|
||||
|
||||
const stopMarkers = [PATCH_MARKERS.END, PATCH_MARKERS.UPDATE, PATCH_MARKERS.DELETE, PATCH_MARKERS.ADD]
|
||||
|
||||
while (this.hasMoreLines() && !stopMarkers.some((m) => this.lines[this.index].startsWith(m.trim()))) {
|
||||
const line = this.lines[this.index++]
|
||||
if (!line.startsWith("+")) {
|
||||
throw new DiffError(`Invalid Add File line (missing '+'): ${line}`)
|
||||
}
|
||||
lines.push(line.substring(1))
|
||||
}
|
||||
|
||||
this.patch.actions[path] = { type: PatchActionType.ADD, newFile: lines.join("\n"), chunks: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity between two strings (0-1 range)
|
||||
*/
|
||||
function calculateSimilarity(str1: string, str2: string): number {
|
||||
const longer = str1.length > str2.length ? str1 : str2
|
||||
const shorter = str1.length > str2.length ? str2 : str1
|
||||
if (longer.length === 0) {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
const editDistance = levenshteinDistance(shorter, longer)
|
||||
return (longer.length - editDistance) / longer.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
function levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix: number[][] = []
|
||||
|
||||
for (let i = 0; i <= str2.length; i++) {
|
||||
matrix[i] = [i]
|
||||
}
|
||||
|
||||
for (let j = 0; j <= str1.length; j++) {
|
||||
matrix[0]![j] = j
|
||||
}
|
||||
|
||||
for (let i = 1; i <= str2.length; i++) {
|
||||
for (let j = 1; j <= str1.length; j++) {
|
||||
if (str2[i - 1] === str1[j - 1]) {
|
||||
matrix[i]![j] = matrix[i - 1]![j - 1]!
|
||||
} else {
|
||||
matrix[i]![j] = Math.min(
|
||||
matrix[i - 1]![j - 1]! + 1, // substitution
|
||||
matrix[i]![j - 1]! + 1, // insertion
|
||||
matrix[i - 1]![j]! + 1, // deletion
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length]![str1.length]!
|
||||
}
|
||||
|
||||
/**
|
||||
* Find context in file with fuzzy matching (whitespace tolerance)
|
||||
* Returns [index, fuzz, similarity] where fuzz indicates match quality and similarity is best match score
|
||||
*/
|
||||
function findContext(lines: string[], context: string[], start: number, eof: boolean): [number, number, number] {
|
||||
if (context.length === 0) {
|
||||
return [start, 0, 1.0]
|
||||
}
|
||||
|
||||
let bestSimilarity = 0
|
||||
|
||||
const findCore = (startIdx: number): [number, number, number] => {
|
||||
// Pass 1: exact equality after canonicalization
|
||||
const canonicalContext = canonicalize(context.join("\n"))
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
const segment = canonicalize(lines.slice(i, i + context.length).join("\n"))
|
||||
if (segment === canonicalContext) {
|
||||
return [i, 0, 1.0]
|
||||
}
|
||||
// Track best similarity for reporting
|
||||
const similarity = calculateSimilarity(segment, canonicalContext)
|
||||
if (similarity > bestSimilarity) {
|
||||
bestSimilarity = similarity
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: ignore trailing whitespace
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
const segment = canonicalize(
|
||||
lines
|
||||
.slice(i, i + context.length)
|
||||
.map((s) => s.trimEnd())
|
||||
.join("\n"),
|
||||
)
|
||||
const ctx = canonicalize(context.map((s) => s.trimEnd()).join("\n"))
|
||||
if (segment === ctx) {
|
||||
return [i, 1, 1.0]
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: ignore all surrounding whitespace
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
const segment = canonicalize(
|
||||
lines
|
||||
.slice(i, i + context.length)
|
||||
.map((s) => s.trim())
|
||||
.join("\n"),
|
||||
)
|
||||
const ctx = canonicalize(context.map((s) => s.trim()).join("\n"))
|
||||
if (segment === ctx) {
|
||||
return [i, 100, 1.0]
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Partial matching with similarity threshold (66% match = 2/3 lines)
|
||||
const SIMILARITY_THRESHOLD = 0.66
|
||||
for (let i = startIdx; i < lines.length; i++) {
|
||||
const segment = canonicalize(lines.slice(i, i + context.length).join("\n"))
|
||||
const similarity = calculateSimilarity(segment, canonicalContext)
|
||||
if (similarity >= SIMILARITY_THRESHOLD) {
|
||||
return [i, 1000, similarity]
|
||||
}
|
||||
if (similarity > bestSimilarity) {
|
||||
bestSimilarity = similarity
|
||||
}
|
||||
}
|
||||
|
||||
return [-1, 0, bestSimilarity]
|
||||
}
|
||||
|
||||
if (eof) {
|
||||
// Try from end first for EOF context
|
||||
let [newIndex, fuzz, similarity] = findCore(lines.length - context.length)
|
||||
if (newIndex !== -1) {
|
||||
return [newIndex, fuzz, similarity]
|
||||
}
|
||||
;[newIndex, fuzz, similarity] = findCore(start)
|
||||
return [newIndex, fuzz + 10000, similarity]
|
||||
}
|
||||
|
||||
return findCore(start)
|
||||
}
|
||||
|
||||
type PeekResult = [string[], PatchChunk[], number, boolean]
|
||||
|
||||
/**
|
||||
* Peek ahead to extract the next section's context and chunks
|
||||
* Returns [context, chunks, endIndex, isEOF]
|
||||
*/
|
||||
function peek(lines: string[], initialIndex: number): PeekResult {
|
||||
let index = initialIndex
|
||||
const old: string[] = []
|
||||
let delLines: string[] = []
|
||||
let insLines: string[] = []
|
||||
const chunks: PatchChunk[] = []
|
||||
let mode: "keep" | "add" | "delete" = "keep"
|
||||
|
||||
const stopMarkers = [
|
||||
"@@",
|
||||
PATCH_MARKERS.END,
|
||||
PATCH_MARKERS.UPDATE,
|
||||
PATCH_MARKERS.DELETE,
|
||||
PATCH_MARKERS.ADD,
|
||||
PATCH_MARKERS.END_FILE,
|
||||
]
|
||||
|
||||
while (index < lines.length) {
|
||||
const s = lines[index]!
|
||||
if (stopMarkers.some((m) => s.startsWith(m.trim()))) {
|
||||
break
|
||||
}
|
||||
if (s === "***") {
|
||||
break
|
||||
}
|
||||
if (s.startsWith("***")) {
|
||||
throw new DiffError(`Invalid line: ${s}`)
|
||||
}
|
||||
|
||||
index++
|
||||
const lastMode: "keep" | "add" | "delete" = mode
|
||||
let line = s
|
||||
|
||||
if (line[0] === "+") {
|
||||
mode = "add"
|
||||
} else if (line[0] === "-") {
|
||||
mode = "delete"
|
||||
} else if (line[0] === " ") {
|
||||
mode = "keep"
|
||||
} else {
|
||||
// Tolerate missing leading whitespace for context lines
|
||||
mode = "keep"
|
||||
line = ` ${line}`
|
||||
}
|
||||
|
||||
line = line.slice(1)
|
||||
|
||||
if (mode === "keep" && lastMode !== mode) {
|
||||
if (insLines.length || delLines.length) {
|
||||
chunks.push({
|
||||
origIndex: old.length - delLines.length,
|
||||
delLines: delLines,
|
||||
insLines: insLines,
|
||||
})
|
||||
}
|
||||
delLines = []
|
||||
insLines = []
|
||||
}
|
||||
|
||||
if (mode === "delete") {
|
||||
delLines.push(line)
|
||||
old.push(line)
|
||||
} else if (mode === "add") {
|
||||
insLines.push(line)
|
||||
} else {
|
||||
old.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
if (insLines.length || delLines.length) {
|
||||
chunks.push({
|
||||
origIndex: old.length - delLines.length,
|
||||
delLines: delLines,
|
||||
insLines: insLines,
|
||||
})
|
||||
}
|
||||
|
||||
if (index < lines.length && lines[index] === PATCH_MARKERS.END_FILE) {
|
||||
index++
|
||||
return [old, chunks, index, true]
|
||||
}
|
||||
|
||||
return [old, chunks, index, false]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { resolveWorkspacePath } from "@/core/workspace"
|
||||
import type { ToolValidator } from "../ToolValidator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
|
||||
/**
|
||||
* Utility class for resolving and validating file paths within a task context
|
||||
*/
|
||||
export class PathResolver {
|
||||
constructor(
|
||||
private config: TaskConfig,
|
||||
private validator: ToolValidator,
|
||||
) {}
|
||||
|
||||
resolve(filePath: string, caller: string): { absolutePath: string; resolvedPath: string } | undefined {
|
||||
try {
|
||||
const pathResult = resolveWorkspacePath(this.config, filePath, caller)
|
||||
return typeof pathResult === "string"
|
||||
? { absolutePath: pathResult, resolvedPath: filePath }
|
||||
: { absolutePath: pathResult.absolutePath, resolvedPath: pathResult.resolvedPath }
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
validate(resolvedPath: string): { ok: boolean; error?: string } {
|
||||
return this.validator.checkClineIgnorePath(resolvedPath)
|
||||
}
|
||||
|
||||
async resolveAndValidate(
|
||||
filePath: string,
|
||||
caller: string,
|
||||
): Promise<{ absolutePath: string; resolvedPath: string } | undefined> {
|
||||
const resolution = this.resolve(filePath, caller)
|
||||
if (!resolution) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const validation = this.validate(resolution.resolvedPath)
|
||||
if (!validation.ok) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return resolution
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -179,6 +179,8 @@ export class TelemetryService {
|
||||
AUTO_COMPACT: "task.summarize_task",
|
||||
// Tracks when slash commands or workflows are activated
|
||||
SLASH_COMMAND_USED: "task.slash_command_used",
|
||||
// Tracks when a feature is toggled on/off
|
||||
FEATURE_TOGGLED: "task.feature_toggled",
|
||||
// Tracks when individual Cline rules are toggled on/off
|
||||
RULE_TOGGLED: "task.rule_toggled",
|
||||
// Tracks when auto condense setting is toggled on/off
|
||||
@@ -702,6 +704,7 @@ export class TelemetryService {
|
||||
resolvedToNonPrimary: boolean
|
||||
resolutionMethod: "hint" | "primary_fallback" | "path_detection"
|
||||
},
|
||||
isNativeToolCall = false,
|
||||
) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.TOOL_USED,
|
||||
@@ -719,6 +722,7 @@ export class TelemetryService {
|
||||
workspace_resolved_non_primary: workspaceContext.resolvedToNonPrimary,
|
||||
workspace_resolution_method: workspaceContext.resolutionMethod,
|
||||
}),
|
||||
isNativeToolCall,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -743,6 +747,7 @@ export class TelemetryService {
|
||||
status: "started" | "success" | "error",
|
||||
errorMessage?: string,
|
||||
argumentKeys?: string[],
|
||||
isNativeToolCall = false,
|
||||
) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.MCP_TOOL_CALLED,
|
||||
@@ -753,6 +758,7 @@ export class TelemetryService {
|
||||
status,
|
||||
errorMessage,
|
||||
argumentKeys,
|
||||
isNativeToolCall,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -789,7 +795,7 @@ export class TelemetryService {
|
||||
* @param provider The API provider being used
|
||||
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
|
||||
*/
|
||||
public captureDiffEditFailure(ulid: string, modelId: string, provider: string, errorType?: string) {
|
||||
public captureDiffEditFailure(ulid: string, modelId: string, provider: string, errorType?: string, isNativeToolCall = false) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.DIFF_EDIT_FAILED,
|
||||
properties: {
|
||||
@@ -797,6 +803,7 @@ export class TelemetryService {
|
||||
errorType,
|
||||
modelId,
|
||||
provider,
|
||||
isNativeToolCall,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1160,6 +1167,25 @@ export class TelemetryService {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a feature is enabled/disabled by the user
|
||||
* @param ulid Unique identifier for the task
|
||||
* @param featureName The name of the feature being toggled
|
||||
* @param enabled Whether the feature was enabled (true) or disabled (false)
|
||||
* @param modelId The model ID being used when the toggle occurred
|
||||
*/
|
||||
public captureFeatureToggle(ulid: string, featureName: string, enabled: boolean, modelId: string) {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.FEATURE_TOGGLED,
|
||||
properties: {
|
||||
ulid,
|
||||
featureName,
|
||||
enabled,
|
||||
modelId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when individual Cline rules are toggled on/off
|
||||
* @param ulid Unique identifier for the task (to track rule changes within task context)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Apply Patch constants
|
||||
*/
|
||||
export const PATCH_MARKERS = {
|
||||
BEGIN: "*** Begin Patch",
|
||||
END: "*** End Patch",
|
||||
ADD: "*** Add File: ",
|
||||
UPDATE: "*** Update File: ",
|
||||
DELETE: "*** Delete File: ",
|
||||
MOVE: "*** Move to: ",
|
||||
SECTION: "@@",
|
||||
END_FILE: "*** End of File",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Expected bash wrappers for apply patch content
|
||||
*/
|
||||
export const BASH_WRAPPERS = ["%%bash", "apply_patch", "EOF", "```"] as const
|
||||
|
||||
/**
|
||||
* Domains of patch actions
|
||||
*/
|
||||
export enum PatchActionType {
|
||||
ADD = "add",
|
||||
DELETE = "delete",
|
||||
UPDATE = "update",
|
||||
}
|
||||
|
||||
export interface PatchChunk {
|
||||
origIndex: number // line index in original file where change starts
|
||||
delLines: string[] // Lines to delete (without the "-" prefix)
|
||||
insLines: string[] // Lines to insert (without the "+" prefix)
|
||||
}
|
||||
|
||||
export interface PatchAction {
|
||||
type: PatchActionType
|
||||
newFile?: string
|
||||
chunks: PatchChunk[]
|
||||
movePath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Warning information for skipped/problematic chunks
|
||||
*/
|
||||
export interface PatchWarning {
|
||||
path: string
|
||||
chunkIndex?: number
|
||||
message: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Patch structure
|
||||
*/
|
||||
export interface Patch {
|
||||
actions: Record<string, PatchAction>
|
||||
warnings?: PatchWarning[]
|
||||
}
|
||||
|
||||
export class DiffError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "DiffError"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { canonicalize, preserveEscaping } from "./string"
|
||||
|
||||
describe("String: canonicalize", () => {
|
||||
describe("Unicode normalization", () => {
|
||||
it("should normalize composed and decomposed unicode", () => {
|
||||
// é as single character vs e + combining acute
|
||||
const composed = "café"
|
||||
const decomposed = "cafe\u0301"
|
||||
canonicalize(composed).should.equal(canonicalize(decomposed))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Hyphen and dash normalization", () => {
|
||||
it("should normalize regular hyphen to ASCII hyphen", () => {
|
||||
canonicalize("hello-world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize HYPHEN (U+2010) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2010world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize NO-BREAK HYPHEN (U+2011) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2011world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize FIGURE DASH (U+2012) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2012world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize EN DASH (U+2013) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2013world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize EM DASH (U+2014) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2014world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize MINUS SIGN (U+2212) to ASCII hyphen", () => {
|
||||
canonicalize("hello\u2212world").should.equal("hello-world")
|
||||
})
|
||||
|
||||
it("should normalize multiple different dashes", () => {
|
||||
canonicalize("a\u2013b\u2014c\u2212d").should.equal("a-b-c-d")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Double quote normalization", () => {
|
||||
it("should keep ASCII double quotes unchanged", () => {
|
||||
canonicalize('say "hello"').should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize LEFT DOUBLE QUOTATION MARK (U+201C)", () => {
|
||||
canonicalize("say \u201Chello\u201D").should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize RIGHT DOUBLE QUOTATION MARK (U+201D)", () => {
|
||||
canonicalize("say \u201Chello\u201D").should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize DOUBLE LOW-9 QUOTATION MARK (U+201E)", () => {
|
||||
canonicalize("say \u201Ehello\u201D").should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize LEFT-POINTING DOUBLE ANGLE QUOTATION MARK (U+00AB)", () => {
|
||||
canonicalize("say \u00ABhello\u00BB").should.equal('say "hello"')
|
||||
})
|
||||
|
||||
it("should normalize RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (U+00BB)", () => {
|
||||
canonicalize("say \u00ABhello\u00BB").should.equal('say "hello"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Single quote normalization", () => {
|
||||
it("should keep ASCII apostrophe unchanged", () => {
|
||||
canonicalize("it's here").should.equal("it's here")
|
||||
})
|
||||
|
||||
it("should normalize LEFT SINGLE QUOTATION MARK (U+2018)", () => {
|
||||
canonicalize("it\u2018s here").should.equal("it's here")
|
||||
})
|
||||
|
||||
it("should normalize RIGHT SINGLE QUOTATION MARK (U+2019)", () => {
|
||||
canonicalize("it\u2019s here").should.equal("it's here")
|
||||
})
|
||||
|
||||
it("should normalize SINGLE HIGH-REVERSED-9 QUOTATION MARK (U+201B)", () => {
|
||||
canonicalize("it\u201Bs here").should.equal("it's here")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Space normalization", () => {
|
||||
it("should keep regular spaces unchanged", () => {
|
||||
canonicalize("hello world").should.equal("hello world")
|
||||
})
|
||||
|
||||
it("should normalize NO-BREAK SPACE (U+00A0) to regular space", () => {
|
||||
canonicalize("hello\u00A0world").should.equal("hello world")
|
||||
})
|
||||
|
||||
it("should normalize NARROW NO-BREAK SPACE (U+202F) to regular space", () => {
|
||||
canonicalize("hello\u202Fworld").should.equal("hello world")
|
||||
})
|
||||
|
||||
it("should normalize multiple non-breaking spaces", () => {
|
||||
canonicalize("a\u00A0b\u202Fc").should.equal("a b c")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Escaped quote normalization", () => {
|
||||
it("should normalize escaped backticks to unescaped", () => {
|
||||
canonicalize("\\`code\\`").should.equal("`code`")
|
||||
})
|
||||
|
||||
it("should normalize escaped single quotes to unescaped", () => {
|
||||
canonicalize("\\'hello\\'").should.equal("'hello'")
|
||||
})
|
||||
|
||||
it("should normalize escaped double quotes to unescaped", () => {
|
||||
canonicalize('\\"hello\\"').should.equal('"hello"')
|
||||
})
|
||||
|
||||
it("should handle multiple escaped quotes", () => {
|
||||
canonicalize("\\`code\\` with \\'single\\' and \\\"double\\\"").should.equal("`code` with 'single' and \"double\"")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Combined normalization", () => {
|
||||
it("should normalize unicode punctuation and escaped quotes together", () => {
|
||||
const input = "it\u2019s a \u201Ctest\u201D with\\`backticks\\` and\u2013dashes"
|
||||
const expected = 'it\'s a "test" with`backticks` and-dashes'
|
||||
canonicalize(input).should.equal(expected)
|
||||
})
|
||||
|
||||
it("should handle complex real-world code snippet", () => {
|
||||
const input = "const msg\u00A0=\u00A0\u201CHello\u2014world\u201D;"
|
||||
const expected = 'const msg = "Hello-world";'
|
||||
canonicalize(input).should.equal(expected)
|
||||
})
|
||||
|
||||
it("should be idempotent", () => {
|
||||
const input = "test\u2019s \u201Cvalue\u201D\u2013here"
|
||||
const once = canonicalize(input)
|
||||
const twice = canonicalize(once)
|
||||
once.should.equal(twice)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty strings", () => {
|
||||
canonicalize("").should.equal("")
|
||||
})
|
||||
|
||||
it("should handle strings with no special characters", () => {
|
||||
canonicalize("hello world").should.equal("hello world")
|
||||
})
|
||||
|
||||
it("should handle strings with only special characters", () => {
|
||||
canonicalize("\u2013\u201C\u00A0").should.equal('-" ')
|
||||
})
|
||||
|
||||
it("should handle multiline strings", () => {
|
||||
const input = "line1\u2013test\nline2\u201Cquoted\u201D"
|
||||
const expected = 'line1-test\nline2"quoted"'
|
||||
canonicalize(input).should.equal(expected)
|
||||
})
|
||||
|
||||
it("should handle strings with emoji", () => {
|
||||
const input = "hello \u{1F44B} world"
|
||||
const expected = "hello \u{1F44B} world"
|
||||
canonicalize(input).should.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("preserveEscaping", () => {
|
||||
describe("Backtick escaping", () => {
|
||||
it("should preserve escaped backticks from original text", () => {
|
||||
const original = "1. \\`file_path\\` MUST be an absolute path"
|
||||
const newText = "1. `absolute_path` MUST be an absolute path"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("1. \\`absolute_path\\` MUST be an absolute path")
|
||||
})
|
||||
|
||||
it("should not add escaping if original has no escaped backticks", () => {
|
||||
const original = "1. file_path MUST be an absolute path"
|
||||
const newText = "1. `absolute_path` MUST be an absolute path"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("1. `absolute_path` MUST be an absolute path")
|
||||
})
|
||||
|
||||
it("should not double-escape already escaped backticks", () => {
|
||||
const original = "1. \\`file_path\\` MUST be an absolute path"
|
||||
const newText = "1. \\`absolute_path\\` MUST be an absolute path"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("1. \\`absolute_path\\` MUST be an absolute path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Single quote escaping", () => {
|
||||
it("should preserve escaped single quotes from original text", () => {
|
||||
const original = "const str = \\'hello\\'"
|
||||
const newText = "const str = 'world'"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("const str = \\'world\\'")
|
||||
})
|
||||
|
||||
it("should not add escaping if original has no escaped quotes", () => {
|
||||
const original = "const str = hello"
|
||||
const newText = "const str = 'world'"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("const str = 'world'")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Double quote escaping", () => {
|
||||
it("should preserve escaped double quotes from original text", () => {
|
||||
const original = 'const str = \\"hello\\"'
|
||||
const newText = 'const str = "world"'
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal('const str = \\"world\\"')
|
||||
})
|
||||
|
||||
it("should not add escaping if original has no escaped quotes", () => {
|
||||
const original = "const str = hello"
|
||||
const newText = 'const str = "world"'
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal('const str = "world"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Multiple escape types", () => {
|
||||
it("should preserve multiple escape types from original", () => {
|
||||
const original = "\`code\` with \'single\' and \"double\""
|
||||
const newText = "`test` with 'foo' and \"bar\""
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("\`test\` with \'foo\' and \"bar\"")
|
||||
})
|
||||
|
||||
it("should handle text with only some escape types", () => {
|
||||
const original = "\\`code\\` with 'single'"
|
||||
const newText = "`test` with 'foo' and \"bar\""
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("\\`test\\` with 'foo' and \"bar\"")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Real-world patch scenario", () => {
|
||||
it("should handle the documented use case from markdown files", () => {
|
||||
const original =
|
||||
"Expectation for required parameters:\n1. \\`file_path\\` MUST be an absolute path; otherwise an error will be thrown."
|
||||
const newText =
|
||||
"Expectation for required parameters:\n1. `absolute_path` MUST be an absolute path; otherwise an error will be thrown."
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal(
|
||||
"Expectation for required parameters:\n1. \\`absolute_path\\` MUST be an absolute path; otherwise an error will be thrown.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should work with multiline patches", () => {
|
||||
const original = "line 1 with \\`code\\`\nline 2 with \\`more\\`"
|
||||
const newText = "line 1 with `test`\nline 2 with `changed`"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("line 1 with \\`test\\`\nline 2 with \\`changed\\`")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty original text", () => {
|
||||
const original = ""
|
||||
const newText = "`code`"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("`code`")
|
||||
})
|
||||
|
||||
it("should handle empty new text", () => {
|
||||
const original = "\\`code\\`"
|
||||
const newText = ""
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("")
|
||||
})
|
||||
|
||||
it("should handle text with no quotes", () => {
|
||||
const original = "hello world"
|
||||
const newText = "goodbye world"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("goodbye world")
|
||||
})
|
||||
|
||||
it("should not affect text without matching escape patterns", () => {
|
||||
const original = "\\`code\\`"
|
||||
const newText = "no quotes here"
|
||||
const result = preserveEscaping(original, newText)
|
||||
result.should.equal("no quotes here")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Unicode punctuation normalisation helpers
|
||||
* Makes patch matching resilient to visually identical but different Unicode code-points
|
||||
*/
|
||||
const PUNCT_EQUIV: Record<string, string> = {
|
||||
// Hyphen / dash variants
|
||||
"-": "-",
|
||||
"\u2010": "-", // HYPHEN
|
||||
"\u2011": "-", // NO-BREAK HYPHEN
|
||||
"\u2012": "-", // FIGURE DASH
|
||||
"\u2013": "-", // EN DASH
|
||||
"\u2014": "-", // EM DASH
|
||||
"\u2212": "-", // MINUS SIGN
|
||||
// Double quotes
|
||||
"\u0022": '"', // QUOTATION MARK
|
||||
"\u201C": '"', // LEFT DOUBLE QUOTATION MARK
|
||||
"\u201D": '"', // RIGHT DOUBLE QUOTATION MARK
|
||||
"\u201E": '"', // DOUBLE LOW-9 QUOTATION MARK
|
||||
"\u00AB": '"', // LEFT-POINTING DOUBLE ANGLE QUOTATION MARK
|
||||
"\u00BB": '"', // RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK
|
||||
// Single quotes
|
||||
"\u0027": "'", // APOSTROPHE
|
||||
"\u2018": "'", // LEFT SINGLE QUOTATION MARK
|
||||
"\u2019": "'", // RIGHT SINGLE QUOTATION MARK
|
||||
"\u201B": "'", // SINGLE HIGH-REVERSED-9 QUOTATION MARK
|
||||
// Spaces
|
||||
"\u00A0": " ", // NO-BREAK SPACE
|
||||
"\u202F": " ", // NARROW NO-BREAK SPACE
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a string by normalizing unicode punctuation and quotes
|
||||
*/
|
||||
export function canonicalize(s: string): string {
|
||||
// First normalize unicode and punctuation
|
||||
let normalized = s.normalize("NFC").replace(/./gu, (c) => PUNCT_EQUIV[c] ?? c)
|
||||
|
||||
// Then normalize escaped/unescaped quotes to handle cases where:
|
||||
// - patch has ` but file has \`
|
||||
// - patch has ' but file has \'
|
||||
// - patch has " but file has \"
|
||||
normalized = normalized
|
||||
.replace(/\\`/g, "`") // \` -> `
|
||||
.replace(/\\'/g, "'") // \' -> '
|
||||
.replace(/\\"/g, '"') // \" -> "
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the escaping style from original text when applying new text
|
||||
* If original has \`, preserve that style in the replacement
|
||||
*/
|
||||
export function preserveEscaping(originalText: string, newText: string): string {
|
||||
// Check if original has escaped backticks, quotes, or apostrophes
|
||||
const hasEscapedBacktick = originalText.includes("\\`")
|
||||
const hasEscapedSingleQuote = originalText.includes("\\'")
|
||||
const hasEscapedDoubleQuote = originalText.includes('\\"')
|
||||
|
||||
let result = newText
|
||||
|
||||
// Apply escaping to match original style
|
||||
if (hasEscapedBacktick && !newText.includes("\\`")) {
|
||||
// Escape backslashes first, then backticks
|
||||
result = result.replace(/\\/g, "\\\\").replace(/`/g, "\\`")
|
||||
}
|
||||
if (hasEscapedSingleQuote && !newText.includes("\\'")) {
|
||||
// Escape backslashes first, then single quotes
|
||||
result = result.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
|
||||
}
|
||||
if (hasEscapedDoubleQuote && !newText.includes('\\"')) {
|
||||
// Escape backslashes first, then double quotes
|
||||
result = result.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user