use super::{
bounded_redacted_output, dedup_paths, redacted_truncate, title_from_messages, Adapter,
Discovered,
};
use crate::model::{Message, Role, Session, ToolCall, ToolResult};
use crate::util::short_id;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::path::{Path, PathBuf};
pub struct Cline;
pub struct RooCode;
pub struct KiloCode;
impl Adapter for Cline {
fn name(&self) -> &'static str {
"cline"
}
fn root(&self) -> Option<PathBuf> {
primary_root("saoudrizwan.claude-dev")
}
fn discover(&self) -> Discovered {
discover_tasks("saoudrizwan.claude-dev")
}
fn parse(&self, path: &Path) -> Result<Session> {
parse_task(self.name(), path)
}
}
impl Adapter for RooCode {
fn name(&self) -> &'static str {
"roo-code"
}
fn root(&self) -> Option<PathBuf> {
primary_root("rooveterinaryinc.roo-cline")
}
fn discover(&self) -> Discovered {
discover_tasks("rooveterinaryinc.roo-cline")
}
fn parse(&self, path: &Path) -> Result<Session> {
parse_task(self.name(), path)
}
}
impl Adapter for KiloCode {
fn name(&self) -> &'static str {
"kilo-code"
}
fn root(&self) -> Option<PathBuf> {
primary_root("kilocode.kilo-code")
}
fn discover(&self) -> Discovered {
discover_tasks("kilocode.kilo-code")
}
fn parse(&self, path: &Path) -> Result<Session> {
parse_task(self.name(), path)
}
}
const VSCODE_VARIANTS: &[&str] = &["Code", "Code - Insiders", "VSCodium", "Cursor", "Windsurf"];
fn globalstorage_bases() -> Vec<PathBuf> {
let mut bases = Vec::new();
if let Some(config) = dirs::config_dir() {
for v in VSCODE_VARIANTS {
bases.push(config.join(v).join("User").join("globalStorage"));
}
}
if let Some(home) = dirs::home_dir() {
bases.push(
home.join(".vscode-server")
.join("data")
.join("User")
.join("globalStorage"),
);
}
bases
}
fn primary_root(ext_id: &str) -> Option<PathBuf> {
Some(
dirs::config_dir()?
.join("Code")
.join("User")
.join("globalStorage")
.join(ext_id),
)
}
fn discover_tasks(ext_id: &str) -> Discovered {
discover_tasks_in(&globalstorage_bases(), ext_id)
}
fn discover_tasks_in(bases: &[PathBuf], ext_id: &str) -> Discovered {
let mut out = Vec::new();
let mut had_error = false;
for base in bases {
let tasks = base.join(ext_id).join("tasks");
let entries = match std::fs::read_dir(&tasks) {
Ok(entries) => entries,
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
had_error = true;
}
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => {
had_error = true;
continue;
}
};
let is_dir = match entry.file_type() {
Ok(t) => t.is_dir(),
Err(_) => {
had_error = true;
continue;
}
};
if !is_dir {
continue;
}
let dir = entry.path();
let primary = dir.join("api_conversation_history.json");
let legacy = dir.join("claude_messages.json");
if primary.is_file() {
out.push(primary);
} else if legacy.is_file() {
out.push(legacy);
}
}
}
Discovered {
files: out,
had_error,
}
}
const EDIT_TOOLS: &[&str] = &[
"write_to_file",
"replace_in_file",
"apply_diff",
"insert_content",
"search_and_replace",
"edit_file",
"new_rule",
"apply_patch",
];
const TOOL_TAGS: &[&str] = &[
"write_to_file",
"replace_in_file",
"apply_diff",
"insert_content",
"search_and_replace",
"edit_file",
"new_rule",
"apply_patch",
"read_file",
"list_files",
"search_files",
"list_code_definition_names",
"execute_command",
"browser_action",
"use_mcp_tool",
"access_mcp_resource",
"ask_followup_question",
"attempt_completion",
"new_task",
"plan_mode_respond",
];
fn parse_task(tool: &'static str, path: &Path) -> Result<Session> {
let raw = crate::util::read_to_string_capped(path)?;
let history: Value =
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
let entries = history.as_array().cloned().unwrap_or_default();
let task_dir = path.parent();
let mut messages: Vec<Message> = Vec::new();
let mut touched: Vec<String> = Vec::new();
let mut cwd = String::new();
for entry in &entries {
let role = match entry.get("role").and_then(Value::as_str) {
Some("user") => Role::User,
Some("assistant") => Role::Assistant,
_ => continue,
};
let content = entry.get("content");
if role == Role::User {
let (text, native_result) = collect_user_text(content);
if cwd.is_empty() {
cwd = cwd_from_text(&text);
}
if native_result {
for result in user_tool_results(content) {
messages.push(Message::tool_result(None, result));
}
continue;
}
if text.contains("] Result:") {
for output in xml_result_echoes(&text) {
messages.push(Message::tool_result(
None,
ToolResult {
call_id: None,
text: bounded_redacted_output(&output),
is_error: legacy_result_is_error(&output),
},
));
}
continue;
}
push(&mut messages, Role::User, &strip_wrappers(&text));
continue;
}
for block in blocks(content) {
match block.get("type").and_then(Value::as_str) {
Some("text") | None if block.get("text").is_some() => {
let text = block.get("text").and_then(Value::as_str).unwrap_or("");
push(&mut messages, Role::Assistant, prose_before_tools(text));
for (name, mut args) in xml_tool_calls(text) {
if EDIT_TOOLS.contains(&name) {
if let Some(path) = args
.get("path")
.and_then(Value::as_str)
.filter(|path| !path.is_empty())
{
touched.push(path.to_owned());
}
}
redact_json_strings(&mut args);
messages.push(Message::tool_call(
None,
ToolCall {
id: None,
name: name.to_owned(),
args,
},
));
}
}
Some("tool_use") => {
let name = block.get("name").and_then(Value::as_str).unwrap_or("?");
let input = block.get("input");
if EDIT_TOOLS.contains(&name) {
if let Some(p) = input
.and_then(|i| i.get("path"))
.and_then(Value::as_str)
.filter(|p| !p.is_empty())
{
touched.push(p.to_string());
}
}
let mut args = input.cloned().unwrap_or_else(|| serde_json::json!({}));
redact_json_strings(&mut args);
messages.push(Message::tool_call(
None,
ToolCall {
id: block
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_owned),
name: name.to_owned(),
args,
},
));
}
_ => {}
}
}
}
let ui = task_dir
.map(|d| d.join("ui_messages.json"))
.and_then(|p| crate::util::read_to_string_capped(&p).ok())
.and_then(|s| serde_json::from_str::<Value>(&s).ok());
let task_id = task_dir
.and_then(|d| d.file_name())
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let (started, ended, ui_title) = ui_summary(ui.as_ref(), &task_id);
let title = ui_title.unwrap_or_else(|| title_from_messages(&messages));
Ok(Session {
id: short_id(&path.to_string_lossy()),
tool,
path: path.to_path_buf(),
project: cwd,
started,
ended,
title,
subagent: false,
messages,
touched: dedup_paths(touched),
edits: Vec::new(),
})
}
fn blocks(content: Option<&Value>) -> Vec<Value> {
match content {
Some(Value::String(s)) => vec![serde_json::json!({"type": "text", "text": s})],
Some(Value::Array(a)) => a.clone(),
_ => vec![],
}
}
fn collect_user_text(content: Option<&Value>) -> (String, bool) {
match content {
Some(Value::String(s)) => (s.clone(), false),
Some(Value::Array(a)) => {
let mut text = String::new();
let mut native_result = false;
for b in a {
match b.get("type").and_then(Value::as_str) {
Some("tool_result") => native_result = true,
_ => {
if let Some(t) = b.get("text").and_then(Value::as_str) {
text.push_str(t);
text.push('\n');
}
}
}
}
(text, native_result)
}
_ => (String::new(), false),
}
}
fn user_tool_results(content: Option<&Value>) -> Vec<ToolResult> {
content
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
.map(|block| ToolResult {
call_id: block
.get("tool_use_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_owned),
text: bounded_redacted_output(&block_text(block.get("content"))),
is_error: block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
})
.collect()
}
fn block_text(content: Option<&Value>) -> String {
match content {
Some(Value::String(text)) => text.clone(),
Some(Value::Array(blocks)) => blocks
.iter()
.filter_map(|block| block.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn redact_json_strings(value: &mut Value) {
match value {
Value::String(text) => *text = crate::redact::redact(text).into_owned(),
Value::Array(values) => values.iter_mut().for_each(redact_json_strings),
Value::Object(values) => values.values_mut().for_each(redact_json_strings),
_ => {}
}
}
fn xml_result_echoes(text: &str) -> Vec<String> {
const MARKER: &str = "] Result:";
let mut out = Vec::new();
let mut rest = text;
while let Some(marker) = rest.find(MARKER) {
rest = &rest[marker + MARKER.len()..];
let end = rest.find(MARKER).unwrap_or(rest.len());
let output = remove_blocks(
&rest[..end],
"<environment_details>",
"</environment_details>",
);
if !output.trim().is_empty() {
out.push(output.trim().to_owned());
}
rest = &rest[end..];
}
out
}
fn legacy_result_is_error(output: &str) -> bool {
output
.lines()
.find(|line| !line.trim().is_empty())
.is_some_and(|line| {
let line = line.trim_start().to_ascii_lowercase();
line.starts_with("error") || line.starts_with("failed")
})
}
fn prose_before_tools(text: &str) -> &str {
let mut cut = text.len();
for tag in TOOL_TAGS {
if let Some(i) = text.find(&format!("<{tag}>")) {
cut = cut.min(i);
}
}
text[..cut].trim()
}
fn xml_tool_calls(text: &str) -> Vec<(&'static str, Value)> {
const ARG_TAGS: &[&str] = &[
"path",
"file_path",
"filepath",
"command",
"cwd",
"pattern",
"query",
"url",
"content",
"diff",
"patch",
"arguments",
"result",
"text",
];
let mut out = Vec::new();
let mut rest = text;
loop {
let next = TOOL_TAGS
.iter()
.filter_map(|&tool| rest.find(&format!("<{tool}>")).map(|index| (index, tool)))
.min_by_key(|(index, _)| *index);
let Some((index, tool)) = next else {
break;
};
let open = format!("<{tool}>");
let close = format!("</{tool}>");
let after = &rest[index + open.len()..];
let end = after.find(&close).unwrap_or(after.len());
let body = &after[..end];
let mut args = serde_json::Map::new();
for &key in ARG_TAGS {
let field_open = format!("<{key}>");
let field_close = format!("</{key}>");
if let Some(value) = between(body, &field_open, &field_close) {
args.insert(key.to_owned(), Value::String(value.trim().to_owned()));
}
}
out.push((tool, Value::Object(args)));
rest = &after[end.saturating_add(close.len().min(after.len() - end))..];
}
out
}
fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> {
let i = s.find(a)? + a.len();
let j = s[i..].find(b)? + i;
Some(&s[i..j])
}
fn strip_wrappers(text: &str) -> String {
let mut t = remove_blocks(text, "<environment_details>", "</environment_details>");
t = t.replace("<task>", "").replace("</task>", "");
t.trim().to_string()
}
fn remove_blocks(text: &str, open: &str, close: &str) -> String {
let mut out = String::new();
let mut rest = text;
while let Some(i) = rest.find(open) {
out.push_str(&rest[..i]);
let after = &rest[i + open.len()..];
match after.find(close) {
Some(j) => rest = &after[j + close.len()..],
None => return out,
}
}
out.push_str(rest);
out
}
fn cwd_from_text(text: &str) -> String {
let marker = "Current Working Directory (";
let Some(start) = text.find(marker) else {
return String::new();
};
let rest = &text[start + marker.len()..];
match rest.find(')') {
Some(end) => rest[..end].trim().to_string(),
None => String::new(),
}
}
fn ui_summary(
ui: Option<&Value>,
task_id: &str,
) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>, Option<String>) {
let events = ui.and_then(Value::as_array);
let title = events.and_then(|evs| {
evs.iter()
.find(|e| e.get("say").and_then(Value::as_str) == Some("task"))
.and_then(|e| e.get("text").and_then(Value::as_str))
.filter(|t| !t.trim().is_empty())
.map(|t| redacted_truncate(t, 80))
});
let mut stamps: Vec<i64> = events
.map(|evs| {
evs.iter()
.filter_map(|e| e.get("ts").and_then(Value::as_i64))
.collect()
})
.unwrap_or_default();
stamps.sort_unstable();
let started = stamps
.first()
.copied()
.or_else(|| task_id.parse::<i64>().ok())
.and_then(DateTime::from_timestamp_millis);
let ended = stamps
.last()
.copied()
.and_then(DateTime::from_timestamp_millis);
(started, ended, title)
}
fn push(messages: &mut Vec<Message>, role: Role, text: &str) {
let text = text.trim();
if !text.is_empty() {
messages.push(Message {
role,
text: text.to_string(),
ts: None,
tool: None,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(unix)]
fn unreadable_task_store_flags_a_partial_listing() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let base = std::env::temp_dir().join("sessionwiki-test-cline-guard");
let _ = fs::remove_dir_all(&base);
let ext = base.join("ext.id");
let task = ext.join("tasks").join("t1");
fs::create_dir_all(&task).unwrap();
fs::write(task.join("api_conversation_history.json"), "[]").unwrap();
let d = discover_tasks_in(std::slice::from_ref(&base), "ext.id");
assert_eq!(d.files.len(), 1);
assert!(!d.had_error);
fs::set_permissions(&ext, fs::Permissions::from_mode(0o000)).unwrap();
if fs::read_dir(&ext).is_ok() {
fs::set_permissions(&ext, fs::Permissions::from_mode(0o755)).unwrap();
return;
}
let d = discover_tasks_in(std::slice::from_ref(&base), "ext.id");
fs::set_permissions(&ext, fs::Permissions::from_mode(0o755)).unwrap();
assert!(d.had_error, "unreadable ancestor must flag had_error");
assert!(d.files.is_empty());
let d = discover_tasks_in(&[base.join("not-installed")], "ext.id");
assert!(!d.had_error);
assert!(d.files.is_empty());
}
}