支持Cline Agent
This commit is contained in:
@@ -739,20 +739,29 @@ async fn run_connection(
|
||||
.await
|
||||
}
|
||||
Err(e) => {
|
||||
// session/load failed (e.g. ephemeral forked session).
|
||||
// session/load failed (e.g. agent doesn't support resume,
|
||||
// or ephemeral forked session).
|
||||
// Fall back to session/new so the tab still works.
|
||||
let err_str = e.to_string();
|
||||
eprintln!(
|
||||
"[ACP] session/load failed ({}), falling back to session/new",
|
||||
e
|
||||
);
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.clone(),
|
||||
message: format!("Failed to load session, starting new: {e}"),
|
||||
},
|
||||
err_str
|
||||
);
|
||||
// Only emit a visible error for unexpected failures;
|
||||
// "Method not found" is expected for agents that don't
|
||||
// support session resume (e.g. Cline).
|
||||
if !err_str.contains("Method not found") {
|
||||
crate::web::event_bridge::emit_event(
|
||||
&handle,
|
||||
"acp://event",
|
||||
AcpEvent::Error {
|
||||
connection_id: conn_id.clone(),
|
||||
message: format!(
|
||||
"Failed to load session, starting new: {e}"
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
let new_resp = cx
|
||||
.send_request_to(Agent, NewSessionRequest::new(cwd.clone()))
|
||||
.block_task()
|
||||
|
||||
@@ -78,6 +78,7 @@ pub fn all_acp_agents() -> Vec<AgentType> {
|
||||
AgentType::Gemini,
|
||||
AgentType::OpenClaw,
|
||||
AgentType::OpenCode,
|
||||
AgentType::Cline,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -88,6 +89,7 @@ pub fn registry_id_for(agent_type: AgentType) -> &'static str {
|
||||
AgentType::Gemini => "gemini",
|
||||
AgentType::OpenClaw => "openclaw-acp",
|
||||
AgentType::OpenCode => "opencode",
|
||||
AgentType::Cline => "cline",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +100,7 @@ pub fn from_registry_id(id: &str) -> Option<AgentType> {
|
||||
"gemini" => Some(AgentType::Gemini),
|
||||
"openclaw-acp" => Some(AgentType::OpenClaw),
|
||||
"opencode" => Some(AgentType::OpenCode),
|
||||
"cline" => Some(AgentType::Cline),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -184,6 +187,19 @@ pub fn get_agent_meta(agent_type: AgentType) -> AcpAgentMeta {
|
||||
node_required: Some("22.12.0"),
|
||||
},
|
||||
},
|
||||
AgentType::Cline => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "Cline",
|
||||
description: "Autonomous coding agent CLI",
|
||||
distribution: AgentDistribution::Npx {
|
||||
version: "2.11.0",
|
||||
package: "cline@2.11.0",
|
||||
cmd: "cline",
|
||||
args: &["--acp"],
|
||||
env: &[],
|
||||
node_required: None,
|
||||
},
|
||||
},
|
||||
AgentType::OpenCode => AcpAgentMeta {
|
||||
agent_type,
|
||||
name: "OpenCode",
|
||||
|
||||
@@ -242,6 +242,7 @@ pub struct AcpAgentInfo {
|
||||
pub opencode_auth_json: Option<String>,
|
||||
pub codex_auth_json: Option<String>,
|
||||
pub codex_config_toml: Option<String>,
|
||||
pub cline_secrets_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Lightweight status info for a single agent, used by connect() pre-check.
|
||||
|
||||
@@ -298,6 +298,297 @@ fn load_opencode_auth_json_raw() -> Option<String> {
|
||||
fs::read_to_string(opencode_auth_json_path()).ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cline config helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cline_data_dir() -> PathBuf {
|
||||
if let Ok(custom) = std::env::var("CLINE_DIR") {
|
||||
let trimmed = custom.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
home_dir_or_default().join(".cline").join("data")
|
||||
}
|
||||
|
||||
fn cline_global_state_path() -> PathBuf {
|
||||
cline_data_dir().join("globalState.json")
|
||||
}
|
||||
|
||||
fn cline_secrets_path() -> PathBuf {
|
||||
cline_data_dir().join("secrets.json")
|
||||
}
|
||||
|
||||
fn load_cline_secrets_json_raw() -> Option<String> {
|
||||
fs::read_to_string(cline_secrets_path()).ok()
|
||||
}
|
||||
|
||||
/// Cline provider → secrets.json field name for the API key.
|
||||
fn cline_api_key_field_for_provider(provider: &str) -> &'static str {
|
||||
match provider {
|
||||
"anthropic" => "apiKey",
|
||||
"openrouter" => "openRouterApiKey",
|
||||
"openai-native" => "openAiNativeApiKey",
|
||||
"openai" => "openAiApiKey",
|
||||
"gemini" => "geminiApiKey",
|
||||
"deepseek" => "deepSeekApiKey",
|
||||
"mistral" => "mistralApiKey",
|
||||
"xai" => "xaiApiKey",
|
||||
_ => "openAiApiKey",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cline provider → globalState model ID key suffix.
|
||||
/// Providers in ProviderKeyMap use `actMode{Suffix}` / `planMode{Suffix}`,
|
||||
/// others use `actModeApiModelId` / `planModeApiModelId`.
|
||||
fn cline_model_id_keys_for_provider(provider: &str) -> (&'static str, &'static str) {
|
||||
match provider {
|
||||
"openrouter" | "cline" => ("actModeOpenRouterModelId", "planModeOpenRouterModelId"),
|
||||
"openai" => ("actModeOpenAiModelId", "planModeOpenAiModelId"),
|
||||
"ollama" => ("actModeOllamaModelId", "planModeOllamaModelId"),
|
||||
"lmstudio" => ("actModeLmStudioModelId", "planModeLmStudioModelId"),
|
||||
"litellm" => ("actModeLiteLlmModelId", "planModeLiteLlmModelId"),
|
||||
"requesty" => ("actModeRequestyModelId", "planModeRequestyModelId"),
|
||||
"groq" => ("actModeGroqModelId", "planModeGroqModelId"),
|
||||
_ => ("actModeApiModelId", "planModeApiModelId"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read globalState.json + secrets.json and merge into a unified config JSON
|
||||
/// with keys: apiProvider, model, apiKey, apiBaseUrl.
|
||||
fn load_cline_local_config_json() -> Option<String> {
|
||||
let mut merged = serde_json::Map::new();
|
||||
|
||||
if let Ok(raw) = fs::read_to_string(cline_global_state_path()) {
|
||||
if let Ok(state) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
// Cline uses actModeApiProvider / planModeApiProvider (prefer actMode)
|
||||
let provider = state
|
||||
.get("actModeApiProvider")
|
||||
.or_else(|| state.get("planModeApiProvider"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or("anthropic")
|
||||
.to_string();
|
||||
|
||||
merged.insert(
|
||||
"apiProvider".to_string(),
|
||||
serde_json::Value::String(provider.clone()),
|
||||
);
|
||||
|
||||
// Read model from provider-specific key
|
||||
let (act_key, _plan_key) = cline_model_id_keys_for_provider(&provider);
|
||||
if let Some(model_id) = state
|
||||
.get(act_key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
merged.insert(
|
||||
"model".to_string(),
|
||||
serde_json::Value::String(model_id.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// Read provider-specific baseUrl key
|
||||
let base_url_key = match provider.as_str() {
|
||||
"anthropic" => "anthropicBaseUrl",
|
||||
"gemini" => "geminiBaseUrl",
|
||||
"ollama" => "ollamaBaseUrl",
|
||||
"lmstudio" => "lmStudioBaseUrl",
|
||||
"litellm" => "liteLlmBaseUrl",
|
||||
"requesty" => "requestyBaseUrl",
|
||||
_ => "openAiBaseUrl",
|
||||
};
|
||||
if let Some(base_url) = state
|
||||
.get(base_url_key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
merged.insert(
|
||||
"apiBaseUrl".to_string(),
|
||||
serde_json::Value::String(base_url.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read API key from secrets.json based on provider
|
||||
if let Ok(raw) = fs::read_to_string(cline_secrets_path()) {
|
||||
if let Ok(secrets) = serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
let provider = merged
|
||||
.get("apiProvider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("anthropic");
|
||||
let key_field = cline_api_key_field_for_provider(provider);
|
||||
if let Some(api_key) = secrets
|
||||
.get(key_field)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
merged.insert(
|
||||
"apiKey".to_string(),
|
||||
serde_json::Value::String(api_key.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if merged.is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::to_string_pretty(&serde_json::Value::Object(merged)).ok()
|
||||
}
|
||||
|
||||
/// Split merged config back into globalState.json + secrets.json.
|
||||
/// Writes `actModeApiProvider`, `planModeApiProvider`, provider-specific model keys,
|
||||
/// `openAiBaseUrl`, and `welcomeViewCompleted` to globalState.json,
|
||||
/// and the provider-specific API key to secrets.json.
|
||||
fn persist_cline_local_config(config_patch_json: Option<&str>) -> Result<(), AcpError> {
|
||||
let Some(raw_patch) = config_patch_json else {
|
||||
return Ok(());
|
||||
};
|
||||
let runtime = serde_json::from_str::<AgentRuntimeConfig>(raw_patch)
|
||||
.map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?;
|
||||
let patch = serde_json::from_str::<serde_json::Value>(raw_patch)
|
||||
.map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?;
|
||||
|
||||
let provider = patch
|
||||
.get("apiProvider")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("anthropic")
|
||||
.to_string();
|
||||
|
||||
// --- Update globalState.json (merge) ---
|
||||
let gs_path = cline_global_state_path();
|
||||
let mut gs = if gs_path.exists() {
|
||||
match fs::read_to_string(&gs_path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||
{
|
||||
Some(existing) if existing.is_object() => existing,
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let gs_obj = gs
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| AcpError::protocol("globalState root must be object"))?;
|
||||
|
||||
// Cline checks welcomeViewCompleted first in isAuthConfigured()
|
||||
gs_obj.insert(
|
||||
"welcomeViewCompleted".to_string(),
|
||||
serde_json::Value::Bool(true),
|
||||
);
|
||||
|
||||
// Set both act/plan mode providers
|
||||
gs_obj.insert(
|
||||
"actModeApiProvider".to_string(),
|
||||
serde_json::Value::String(provider.clone()),
|
||||
);
|
||||
gs_obj.insert(
|
||||
"planModeApiProvider".to_string(),
|
||||
serde_json::Value::String(provider.clone()),
|
||||
);
|
||||
|
||||
// Set provider-specific model ID keys
|
||||
let (act_model_key, plan_model_key) = cline_model_id_keys_for_provider(&provider);
|
||||
match trim_non_empty(runtime.model) {
|
||||
Some(model) => {
|
||||
gs_obj.insert(
|
||||
act_model_key.to_string(),
|
||||
serde_json::Value::String(model.clone()),
|
||||
);
|
||||
gs_obj.insert(
|
||||
plan_model_key.to_string(),
|
||||
serde_json::Value::String(model),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
gs_obj.remove(act_model_key);
|
||||
gs_obj.remove(plan_model_key);
|
||||
}
|
||||
}
|
||||
|
||||
// Each provider uses its own baseUrl key in globalState
|
||||
let base_url_key = match provider.as_str() {
|
||||
"anthropic" => "anthropicBaseUrl",
|
||||
"gemini" => "geminiBaseUrl",
|
||||
"ollama" => "ollamaBaseUrl",
|
||||
"lmstudio" => "lmStudioBaseUrl",
|
||||
"litellm" => "liteLlmBaseUrl",
|
||||
"requesty" => "requestyBaseUrl",
|
||||
_ => "openAiBaseUrl",
|
||||
};
|
||||
match trim_non_empty(runtime.api_base_url) {
|
||||
Some(base_url) => {
|
||||
gs_obj.insert(
|
||||
base_url_key.to_string(),
|
||||
serde_json::Value::String(base_url),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
gs_obj.remove(base_url_key);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = gs_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
AcpError::protocol(format!("create cline data directory failed: {e}"))
|
||||
})?;
|
||||
}
|
||||
let serialized_gs = serde_json::to_string_pretty(&gs)
|
||||
.map_err(|e| AcpError::protocol(format!("serialize cline globalState failed: {e}")))?;
|
||||
fs::write(&gs_path, format!("{serialized_gs}\n"))
|
||||
.map_err(|e| AcpError::protocol(format!("write cline globalState failed: {e}")))?;
|
||||
|
||||
// --- Update secrets.json ---
|
||||
let secrets_path = cline_secrets_path();
|
||||
let mut secrets = if secrets_path.exists() {
|
||||
match fs::read_to_string(&secrets_path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||
{
|
||||
Some(existing) if existing.is_object() => existing,
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let secrets_obj = secrets
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| AcpError::protocol("secrets root must be object"))?;
|
||||
|
||||
let key_field = cline_api_key_field_for_provider(&provider);
|
||||
match trim_non_empty(runtime.api_key) {
|
||||
Some(api_key) => {
|
||||
secrets_obj.insert(
|
||||
key_field.to_string(),
|
||||
serde_json::Value::String(api_key),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
secrets_obj.remove(key_field);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(parent) = secrets_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
AcpError::protocol(format!("create cline data directory failed: {e}"))
|
||||
})?;
|
||||
}
|
||||
let serialized_secrets = serde_json::to_string_pretty(&secrets)
|
||||
.map_err(|e| AcpError::protocol(format!("serialize cline secrets failed: {e}")))?;
|
||||
fs::write(&secrets_path, format!("{serialized_secrets}\n"))
|
||||
.map_err(|e| AcpError::protocol(format!("write cline secrets failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_codex_auth_json_raw() -> Option<String> {
|
||||
fs::read_to_string(codex_auth_json_path()).ok()
|
||||
}
|
||||
@@ -620,6 +911,7 @@ fn agent_local_config_path(agent_type: AgentType) -> Option<PathBuf> {
|
||||
AgentType::ClaudeCode => Some(home_dir_or_default().join(".claude").join("settings.json")),
|
||||
AgentType::Gemini => Some(home_dir_or_default().join(".gemini").join("settings.json")),
|
||||
AgentType::OpenCode => Some(resolve_opencode_config_path()),
|
||||
AgentType::Cline => Some(cline_global_state_path()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -628,6 +920,9 @@ pub(crate) fn load_agent_local_config_json(agent_type: AgentType) -> Option<Stri
|
||||
if agent_type == AgentType::Codex {
|
||||
return load_codex_local_config_json();
|
||||
}
|
||||
if agent_type == AgentType::Cline {
|
||||
return load_cline_local_config_json();
|
||||
}
|
||||
|
||||
let path = agent_local_config_path(agent_type)?;
|
||||
if !path.exists() {
|
||||
@@ -665,6 +960,9 @@ fn persist_agent_local_config_json(
|
||||
if agent_type == AgentType::Codex {
|
||||
return persist_codex_local_config(config_patch_json);
|
||||
}
|
||||
if agent_type == AgentType::Cline {
|
||||
return persist_cline_local_config(config_patch_json);
|
||||
}
|
||||
|
||||
let Some(path) = agent_local_config_path(agent_type) else {
|
||||
return Ok(());
|
||||
@@ -755,6 +1053,7 @@ fn skill_storage_spec(agent_type: AgentType) -> Option<SkillStorageSpec> {
|
||||
global_dirs: vec![home_dir_or_default().join(".openclaw").join("skills")],
|
||||
project_rel_dirs: vec!["skills"],
|
||||
}),
|
||||
AgentType::Cline => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1325,6 +1624,11 @@ pub async fn acp_list_agents(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cline_secrets_json = if agent_type == AgentType::Cline {
|
||||
load_cline_secrets_json_raw()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
agents.push(AcpAgentInfo {
|
||||
agent_type,
|
||||
@@ -1344,6 +1648,7 @@ pub async fn acp_list_agents(
|
||||
opencode_auth_json,
|
||||
codex_auth_json,
|
||||
codex_config_toml,
|
||||
cline_secrets_json,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1447,6 +1752,14 @@ pub(crate) async fn acp_update_agent_preferences_core(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if agent_type == AgentType::Cline {
|
||||
if let Some(raw) = config_json.as_deref() {
|
||||
persist_cline_local_config(Some(raw))?;
|
||||
}
|
||||
emit_acp_agents_updated(app, "preferences_updated", Some(agent_type));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut local_patch_value = config_json
|
||||
.as_deref()
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok())
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::db::service::{conversation_service, folder_service, import_service};
|
||||
use crate::db::AppDatabase;
|
||||
use crate::models::*;
|
||||
use crate::parsers::claude::ClaudeParser;
|
||||
use crate::parsers::cline::ClineParser;
|
||||
use crate::parsers::codex::CodexParser;
|
||||
use crate::parsers::gemini::GeminiParser;
|
||||
use crate::parsers::openclaw::OpenClawParser;
|
||||
@@ -42,6 +43,7 @@ fn list_conversations_sync(
|
||||
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
|
||||
(AgentType::Gemini, Box::new(GeminiParser::new())),
|
||||
(AgentType::OpenClaw, Box::new(OpenClawParser::new())),
|
||||
(AgentType::Cline, Box::new(ClineParser::new())),
|
||||
];
|
||||
|
||||
for (at, parser) in &parsers {
|
||||
@@ -142,6 +144,7 @@ pub async fn get_conversation(
|
||||
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||
AgentType::OpenClaw => Box::new(OpenClawParser::new()),
|
||||
AgentType::Cline => Box::new(ClineParser::new()),
|
||||
};
|
||||
|
||||
parser
|
||||
@@ -275,14 +278,16 @@ pub async fn get_folder_conversation_core(
|
||||
AgentType::OpenCode => Box::new(OpenCodeParser::new()),
|
||||
AgentType::Gemini => Box::new(GeminiParser::new()),
|
||||
AgentType::OpenClaw => Box::new(OpenClawParser::new()),
|
||||
AgentType::Cline => Box::new(ClineParser::new()),
|
||||
};
|
||||
match parser.get_conversation(&eid) {
|
||||
Ok(d) => Ok((d.turns, d.session_stats, None)),
|
||||
Err(crate::parsers::ParseError::ConversationNotFound(_)) => {
|
||||
// For OpenClaw, the external_id may be an ACP session UUID that
|
||||
// doesn't correspond to any JSONL file. Fall back to matching
|
||||
// by title and folder_path from the parsed conversation list.
|
||||
if at == AgentType::OpenClaw {
|
||||
// For agents like OpenClaw and Cline, the external_id is an
|
||||
// ACP session UUID that doesn't correspond to any local file.
|
||||
// Fall back to matching by folder_path and started_at from
|
||||
// the parsed conversation list.
|
||||
if at == AgentType::OpenClaw || at == AgentType::Cline {
|
||||
if let Ok(all) = parser.list_conversations() {
|
||||
// Filter by folder_path first, then find the closest
|
||||
// started_at match within 300 seconds of db_created_at.
|
||||
|
||||
@@ -32,6 +32,7 @@ fn default_enabled(agent_type: AgentType) -> bool {
|
||||
| AgentType::Gemini
|
||||
| AgentType::OpenCode
|
||||
| AgentType::OpenClaw
|
||||
| AgentType::Cline
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::db::entities::conversation;
|
||||
use crate::db::error::DbError;
|
||||
use crate::models::{AgentType, ImportResult};
|
||||
use crate::parsers::claude::ClaudeParser;
|
||||
use crate::parsers::cline::ClineParser;
|
||||
use crate::parsers::codex::CodexParser;
|
||||
use crate::parsers::gemini::GeminiParser;
|
||||
use crate::parsers::openclaw::OpenClawParser;
|
||||
@@ -28,6 +29,7 @@ pub async fn import_local_conversations(
|
||||
(AgentType::OpenCode, Box::new(OpenCodeParser::new())),
|
||||
(AgentType::Gemini, Box::new(GeminiParser::new())),
|
||||
(AgentType::OpenClaw, Box::new(OpenClawParser::new())),
|
||||
(AgentType::Cline, Box::new(ClineParser::new())),
|
||||
];
|
||||
|
||||
let mut matched = Vec::new();
|
||||
|
||||
@@ -9,6 +9,7 @@ pub enum AgentType {
|
||||
OpenCode,
|
||||
Gemini,
|
||||
OpenClaw,
|
||||
Cline,
|
||||
}
|
||||
|
||||
impl fmt::Display for AgentType {
|
||||
@@ -19,6 +20,7 @@ impl fmt::Display for AgentType {
|
||||
AgentType::OpenCode => write!(f, "OpenCode"),
|
||||
AgentType::Gemini => write!(f, "Gemini CLI"),
|
||||
AgentType::OpenClaw => write!(f, "OpenClaw"),
|
||||
AgentType::Cline => write!(f, "Cline"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
634
src-tauri/src/parsers/cline.rs
Normal file
634
src-tauri/src/parsers/cline.rs
Normal file
@@ -0,0 +1,634 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::{
|
||||
AgentType, ContentBlock, ConversationDetail, ConversationSummary, MessageTurn, TurnRole,
|
||||
TurnUsage,
|
||||
};
|
||||
|
||||
use super::{compute_session_stats, folder_name_from_path, truncate_str, AgentParser, ParseError};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// On-disk JSON structures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One entry in `~/.cline/data/state/taskHistory.json`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskHistoryEntry {
|
||||
id: String,
|
||||
ts: i64,
|
||||
task: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
tokens_in: Option<u64>,
|
||||
#[allow(dead_code)]
|
||||
tokens_out: Option<u64>,
|
||||
#[allow(dead_code)]
|
||||
total_cost: Option<f64>,
|
||||
cwd_on_task_initialization: Option<String>,
|
||||
#[serde(default)]
|
||||
model_id: Option<String>,
|
||||
}
|
||||
|
||||
/// `task_metadata.json` – we only need `model_usage`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TaskMetadata {
|
||||
#[serde(default)]
|
||||
model_usage: Vec<ModelUsageEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ModelUsageEntry {
|
||||
model_id: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
model_provider_id: Option<String>,
|
||||
}
|
||||
|
||||
/// One message in `api_conversation_history.json`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiMessage {
|
||||
role: String,
|
||||
#[serde(default)]
|
||||
content: serde_json::Value,
|
||||
ts: Option<i64>,
|
||||
#[serde(default, rename = "modelInfo")]
|
||||
model_info: Option<ApiModelInfo>,
|
||||
#[serde(default)]
|
||||
metrics: Option<ApiMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiModelInfo {
|
||||
model_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiMetrics {
|
||||
tokens: Option<ApiTokenMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiTokenMetrics {
|
||||
#[serde(default)]
|
||||
prompt: Option<u64>,
|
||||
#[serde(default)]
|
||||
completion: Option<u64>,
|
||||
#[serde(default)]
|
||||
cached: Option<u64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cline_data_dir() -> PathBuf {
|
||||
if let Ok(custom) = std::env::var("CLINE_DIR") {
|
||||
let trimmed = custom.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return PathBuf::from(trimmed);
|
||||
}
|
||||
}
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".cline")
|
||||
.join("data")
|
||||
}
|
||||
|
||||
fn ts_to_datetime(ts: i64) -> DateTime<Utc> {
|
||||
Utc.timestamp_millis_opt(ts).single().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub struct ClineParser;
|
||||
|
||||
impl ClineParser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentParser for ClineParser {
|
||||
fn list_conversations(&self) -> Result<Vec<ConversationSummary>, ParseError> {
|
||||
let history_path = cline_data_dir().join("state").join("taskHistory.json");
|
||||
if !history_path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&history_path)?;
|
||||
let entries: Vec<TaskHistoryEntry> = serde_json::from_str(&raw)?;
|
||||
|
||||
let mut summaries = Vec::new();
|
||||
for entry in entries {
|
||||
let tasks_dir = cline_data_dir().join("tasks").join(&entry.id);
|
||||
if !tasks_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read model from task_metadata.json or taskHistory entry
|
||||
let model = entry.model_id.clone().or_else(|| {
|
||||
let meta_path = tasks_dir.join("task_metadata.json");
|
||||
fs::read_to_string(meta_path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok())
|
||||
.and_then(|meta| {
|
||||
meta.model_usage
|
||||
.first()
|
||||
.and_then(|u| u.model_id.clone())
|
||||
})
|
||||
});
|
||||
|
||||
let folder_path = entry.cwd_on_task_initialization.clone();
|
||||
let folder_name = folder_path.as_deref().map(folder_name_from_path);
|
||||
|
||||
let title = entry
|
||||
.task
|
||||
.as_deref()
|
||||
.map(|t| truncate_str(t.trim(), 100));
|
||||
|
||||
// Count messages from api_conversation_history.json
|
||||
let api_path = tasks_dir.join("api_conversation_history.json");
|
||||
let message_count = fs::read_to_string(&api_path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<Vec<serde_json::Value>>(&raw).ok())
|
||||
.map(|msgs| msgs.len() as u32)
|
||||
.unwrap_or(0);
|
||||
|
||||
// started_at from task id (which is a timestamp), ended_at from ts
|
||||
let started_at = ts_to_datetime(entry.id.parse::<i64>().unwrap_or(entry.ts));
|
||||
let ended_at = if entry.ts > 0 {
|
||||
Some(ts_to_datetime(entry.ts))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
summaries.push(ConversationSummary {
|
||||
id: entry.id,
|
||||
agent_type: AgentType::Cline,
|
||||
folder_path,
|
||||
folder_name: folder_name.map(String::from),
|
||||
title,
|
||||
started_at,
|
||||
ended_at,
|
||||
message_count,
|
||||
model,
|
||||
git_branch: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
fn get_conversation(&self, conversation_id: &str) -> Result<ConversationDetail, ParseError> {
|
||||
let tasks_dir = cline_data_dir().join("tasks").join(conversation_id);
|
||||
if !tasks_dir.exists() {
|
||||
return Err(ParseError::ConversationNotFound(
|
||||
conversation_id.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let api_path = tasks_dir.join("api_conversation_history.json");
|
||||
if !api_path.exists() {
|
||||
return Err(ParseError::ConversationNotFound(
|
||||
conversation_id.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let raw = fs::read_to_string(&api_path)?;
|
||||
let messages: Vec<ApiMessage> = serde_json::from_str(&raw)?;
|
||||
|
||||
// Read metadata for model/cwd
|
||||
let meta_path = tasks_dir.join("task_metadata.json");
|
||||
let metadata = fs::read_to_string(&meta_path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<TaskMetadata>(&raw).ok());
|
||||
|
||||
let default_model = metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.model_usage.first())
|
||||
.and_then(|u| u.model_id.clone());
|
||||
|
||||
// Read taskHistory for cwd and title
|
||||
let history_path = cline_data_dir().join("state").join("taskHistory.json");
|
||||
let history_entry = fs::read_to_string(&history_path)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<Vec<TaskHistoryEntry>>(&raw).ok())
|
||||
.and_then(|entries| entries.into_iter().find(|e| e.id == conversation_id));
|
||||
|
||||
let folder_path = history_entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.cwd_on_task_initialization.clone());
|
||||
let folder_name = folder_path.as_deref().map(folder_name_from_path);
|
||||
let title = history_entry
|
||||
.as_ref()
|
||||
.and_then(|e| e.task.as_deref())
|
||||
.map(|t| truncate_str(t.trim(), 100));
|
||||
|
||||
let mut turns: Vec<MessageTurn> = Vec::new();
|
||||
let mut turn_counter = 0u32;
|
||||
|
||||
for msg in &messages {
|
||||
let ts = msg.ts.unwrap_or(0);
|
||||
let timestamp = if ts > 0 {
|
||||
ts_to_datetime(ts)
|
||||
} else {
|
||||
Utc::now()
|
||||
};
|
||||
|
||||
let model = msg
|
||||
.model_info
|
||||
.as_ref()
|
||||
.and_then(|info| info.model_id.clone())
|
||||
.or_else(|| default_model.clone());
|
||||
|
||||
let usage = msg.metrics.as_ref().and_then(|m| {
|
||||
m.tokens.as_ref().map(|t| TurnUsage {
|
||||
input_tokens: t.prompt.unwrap_or(0),
|
||||
output_tokens: t.completion.unwrap_or(0),
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: t.cached.unwrap_or(0),
|
||||
})
|
||||
});
|
||||
|
||||
match msg.role.as_str() {
|
||||
"assistant" => {
|
||||
let blocks = parse_content_blocks(&msg.content);
|
||||
if blocks.is_empty() {
|
||||
continue;
|
||||
}
|
||||
turn_counter += 1;
|
||||
turns.push(MessageTurn {
|
||||
id: format!("{}-{}", conversation_id, turn_counter),
|
||||
role: TurnRole::Assistant,
|
||||
blocks,
|
||||
timestamp,
|
||||
usage,
|
||||
duration_ms: None,
|
||||
model,
|
||||
});
|
||||
}
|
||||
"user" => {
|
||||
// Cline packs tool results, user feedback, and automated
|
||||
// messages into role:"user". Split them into proper turns.
|
||||
let parsed = parse_user_message_parts(&msg.content);
|
||||
|
||||
// Emit tool-result blocks as a system turn so they attach
|
||||
// to the preceding assistant tool_use.
|
||||
if !parsed.tool_results.is_empty() {
|
||||
turn_counter += 1;
|
||||
turns.push(MessageTurn {
|
||||
id: format!("{}-{}", conversation_id, turn_counter),
|
||||
role: TurnRole::System,
|
||||
blocks: parsed.tool_results,
|
||||
timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit real user text (feedback / initial task) as a user turn.
|
||||
if !parsed.user_blocks.is_empty() {
|
||||
turn_counter += 1;
|
||||
turns.push(MessageTurn {
|
||||
id: format!("{}-{}", conversation_id, turn_counter),
|
||||
role: TurnRole::User,
|
||||
blocks: parsed.user_blocks,
|
||||
timestamp,
|
||||
usage: None,
|
||||
duration_ms: None,
|
||||
model: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
|
||||
let started_at = turns
|
||||
.first()
|
||||
.map(|t| t.timestamp)
|
||||
.unwrap_or_else(Utc::now);
|
||||
let ended_at = turns.last().map(|t| t.timestamp);
|
||||
|
||||
let session_stats = compute_session_stats(&turns);
|
||||
|
||||
let summary = ConversationSummary {
|
||||
id: conversation_id.to_string(),
|
||||
agent_type: AgentType::Cline,
|
||||
folder_path,
|
||||
folder_name: folder_name.map(String::from),
|
||||
title,
|
||||
started_at,
|
||||
ended_at,
|
||||
message_count: turns.len() as u32,
|
||||
model: default_model,
|
||||
git_branch: None,
|
||||
};
|
||||
|
||||
Ok(ConversationDetail {
|
||||
summary,
|
||||
turns,
|
||||
session_stats,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content block parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result of splitting a Cline `role:"user"` message.
|
||||
struct UserMessageParts {
|
||||
/// Tool result blocks (e.g. `[read_file for ...] Result:`)
|
||||
tool_results: Vec<ContentBlock>,
|
||||
/// Real user content (initial task text or `<feedback>` text)
|
||||
user_blocks: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
/// Cline puts tool results, feedback, and automated prompts all into
|
||||
/// `role:"user"` messages. This function splits them apart.
|
||||
fn parse_user_message_parts(content: &serde_json::Value) -> UserMessageParts {
|
||||
let texts = collect_text_parts(content);
|
||||
let mut tool_results = Vec::new();
|
||||
let mut user_blocks = Vec::new();
|
||||
|
||||
for text in texts {
|
||||
let cleaned = strip_environment_details(&text);
|
||||
if cleaned.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tool result pattern: `[tool_name ...] Result:`
|
||||
if is_tool_result_text(&cleaned) {
|
||||
let (tool_name, output, is_error) = parse_tool_result_text(&cleaned);
|
||||
tool_results.push(ContentBlock::ToolResult {
|
||||
tool_use_id: None,
|
||||
output_preview: Some(truncate_str(&output, 2000)),
|
||||
is_error,
|
||||
});
|
||||
|
||||
// If the tool result also contains <feedback>, extract it
|
||||
if let Some(feedback) = extract_feedback(&text) {
|
||||
let fb = feedback.trim();
|
||||
if !fb.is_empty() {
|
||||
user_blocks.push(ContentBlock::Text {
|
||||
text: fb.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// After extracting tool result, also check for non-feedback user
|
||||
// text following the result (e.g. "The user has provided feedback...")
|
||||
// — we intentionally skip these automated bridging messages.
|
||||
let _ = tool_name;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pure feedback without tool result prefix
|
||||
if let Some(feedback) = extract_feedback(&cleaned) {
|
||||
let fb = feedback.trim();
|
||||
if !fb.is_empty() {
|
||||
user_blocks.push(ContentBlock::Text {
|
||||
text: fb.to_string(),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular user text (initial task, etc.)
|
||||
user_blocks.push(ContentBlock::Text { text: cleaned });
|
||||
}
|
||||
|
||||
UserMessageParts {
|
||||
tool_results,
|
||||
user_blocks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all text strings from a content value (string or array of text blocks).
|
||||
fn collect_text_parts(content: &serde_json::Value) -> Vec<String> {
|
||||
match content {
|
||||
serde_json::Value::String(s) => vec![s.clone()],
|
||||
serde_json::Value::Array(arr) => arr
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let t = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if t == "text" || t.is_empty() {
|
||||
item.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.or_else(|| {
|
||||
if t.is_empty() {
|
||||
item.as_str().map(String::from)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if text looks like a Cline tool result: `[tool_name ...] Result:`
|
||||
fn is_tool_result_text(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
trimmed.starts_with('[')
|
||||
&& trimmed.contains("] Result:")
|
||||
}
|
||||
|
||||
/// Parse `[tool_name for 'arg'] Result:\ncontent` into (tool_name, output, is_error).
|
||||
fn parse_tool_result_text(text: &str) -> (String, String, bool) {
|
||||
let trimmed = text.trim_start();
|
||||
// Extract tool name from [tool_name ...] or [tool_name] prefix
|
||||
let tool_name = trimmed
|
||||
.strip_prefix('[')
|
||||
.and_then(|s| {
|
||||
s.find(|c: char| c == ']' || c == ' ')
|
||||
.map(|i| s[..i].to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_error = trimmed.contains("[ERROR]") || trimmed.contains("Error:");
|
||||
|
||||
// Extract the content after "Result:\n"
|
||||
let output = trimmed
|
||||
.find("] Result:")
|
||||
.map(|i| {
|
||||
let after = &trimmed[i + "] Result:".len()..];
|
||||
after.trim().to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Strip automated bridging text that follows some results
|
||||
let output = strip_automated_bridging(&output);
|
||||
|
||||
(tool_name, output, is_error)
|
||||
}
|
||||
|
||||
/// Remove automated bridging messages that Cline appends after tool results.
|
||||
fn strip_automated_bridging(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove "The user has provided feedback..." bridging
|
||||
if let Some(pos) = result.find("The user has provided feedback") {
|
||||
result = result[..pos].to_string();
|
||||
}
|
||||
|
||||
// Remove "(This is an automated message...)" blocks
|
||||
if let Some(pos) = result.find("(This is an automated message") {
|
||||
result = result[..pos].to_string();
|
||||
}
|
||||
|
||||
// Remove "# Next Steps" blocks
|
||||
if let Some(pos) = result.find("# Next Steps") {
|
||||
result = result[..pos].to_string();
|
||||
}
|
||||
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
/// Extract text from `<feedback>...</feedback>` tags.
|
||||
fn extract_feedback(text: &str) -> Option<String> {
|
||||
let start = text.find("<feedback>")?;
|
||||
let inner_start = start + "<feedback>".len();
|
||||
let end = text.find("</feedback>")?;
|
||||
if end > inner_start {
|
||||
Some(text[inner_start..end].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_content_blocks(content: &serde_json::Value) -> Vec<ContentBlock> {
|
||||
match content {
|
||||
serde_json::Value::String(text) => {
|
||||
let cleaned = strip_environment_details(text);
|
||||
if cleaned.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![ContentBlock::Text { text: cleaned }]
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
let mut blocks = Vec::new();
|
||||
for item in arr {
|
||||
let block_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
||||
let cleaned = strip_environment_details(text);
|
||||
if !cleaned.is_empty() {
|
||||
blocks.push(ContentBlock::Text { text: cleaned });
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let tool_name = item
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let tool_use_id =
|
||||
item.get("id").and_then(|v| v.as_str()).map(String::from);
|
||||
let input_preview = item.get("input").map(|v| {
|
||||
let s = v.to_string();
|
||||
truncate_str(&s, 2000)
|
||||
});
|
||||
blocks.push(ContentBlock::ToolUse {
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
input_preview,
|
||||
});
|
||||
}
|
||||
"tool_result" => {
|
||||
let tool_use_id = item
|
||||
.get("tool_use_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let is_error = item
|
||||
.get("is_error")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let output_preview = item
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| truncate_str(s, 500));
|
||||
blocks.push(ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
output_preview,
|
||||
is_error,
|
||||
});
|
||||
}
|
||||
"thinking" => {
|
||||
if let Some(text) = item.get("thinking").and_then(|v| v.as_str()) {
|
||||
if !text.is_empty() {
|
||||
blocks.push(ContentBlock::Thinking {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
blocks
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip Cline's `<environment_details>...</environment_details>` blocks and
|
||||
/// `<task>...</task>` wrappers from user messages to keep content clean.
|
||||
fn strip_environment_details(text: &str) -> String {
|
||||
let mut result = text.to_string();
|
||||
|
||||
// Remove <environment_details>...</environment_details>
|
||||
while let Some(start) = result.find("<environment_details>") {
|
||||
if let Some(end) = result.find("</environment_details>") {
|
||||
let end = end + "</environment_details>".len();
|
||||
result = format!("{}{}", &result[..start], &result[end..]);
|
||||
} else {
|
||||
// Unclosed tag — remove from start to end
|
||||
result = result[..start].to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove <task>...</task> wrappers, keeping inner content
|
||||
while let Some(start) = result.find("<task>") {
|
||||
let tag_end = start + "<task>".len();
|
||||
if let Some(close) = result.find("</task>") {
|
||||
let inner = result[tag_end..close].to_string();
|
||||
let after = &result[close + "</task>".len()..];
|
||||
result = format!("{}{}{}", &result[..start], inner, after);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove task_progress RECOMMENDED blocks
|
||||
while let Some(start) = result.find("# task_progress RECOMMENDED") {
|
||||
// Find the end: next section or end of string
|
||||
let rest = &result[start..];
|
||||
let end = rest
|
||||
.find("\n<")
|
||||
.or_else(|| rest.find("\n#"))
|
||||
.map(|i| start + i)
|
||||
.unwrap_or(result.len());
|
||||
result = format!("{}{}", &result[..start], &result[end..]);
|
||||
}
|
||||
|
||||
// Remove [ERROR] automated retry messages
|
||||
if result.contains("[ERROR] You did not use a tool") {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
result.trim().to_string()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod claude;
|
||||
pub mod cline;
|
||||
pub mod codex;
|
||||
pub mod gemini;
|
||||
pub mod openclaw;
|
||||
|
||||
Reference in New Issue
Block a user