use super::{dedup_paths, ok_or_flag, redacted_truncate, title_from_messages, Adapter, Discovered};
use crate::model::{Message, Role, Session};
use crate::util::short_id;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde_json::Value;
use std::path::{Path, PathBuf};
pub struct Continue;
impl Adapter for Continue {
fn name(&self) -> &'static str {
"continue"
}
fn root(&self) -> Option<PathBuf> {
Some(continue_dir()?.join("sessions"))
}
fn discover(&self) -> Discovered {
let Some(dir) = self.root() else {
return Vec::new().into();
};
if !dir.exists() {
return Vec::new().into(); }
let mut had_error = false;
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => {
return Discovered {
files: vec![],
had_error: true,
}
}
};
let files = entries
.filter_map(|e| ok_or_flag(e, &mut had_error))
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "json"))
.filter(|p| p.file_name().is_some_and(|n| n != "sessions.json"))
.collect();
Discovered { files, had_error }
}
fn parse(&self, path: &Path) -> Result<Session> {
parse_session(self.name(), path)
}
}
fn continue_dir() -> Option<PathBuf> {
std::env::var_os("CONTINUE_GLOBAL_DIR")
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
.or_else(|| dirs::home_dir().map(|h| h.join(".continue")))
}
const EDIT_TOOLS: &[&str] = &[
"create_new_file",
"edit_existing_file",
"single_find_and_replace",
"multi_edit",
];
fn parse_session(tool: &'static str, path: &Path) -> Result<Session> {
let raw = crate::util::read_to_string_capped(path)?;
let s: Value =
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
let project = s
.get("workspaceDirectory")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let stored_title = s
.get("title")
.and_then(Value::as_str)
.filter(|t| !t.trim().is_empty() && *t != "New Session")
.map(|t| redacted_truncate(t, 80));
let mut messages: Vec<Message> = Vec::new();
let mut touched: Vec<String> = Vec::new();
if let Some(history) = s.get("history").and_then(Value::as_array) {
for item in history {
let msg = item.get("message");
let role = match msg.and_then(|m| m.get("role")).and_then(Value::as_str) {
Some("user") => Role::User,
Some("assistant") | Some("thinking") => Role::Assistant,
Some("tool") => Role::Tool,
_ => continue,
};
if let Some(text) = msg.and_then(|m| m.get("content")).map(content_text) {
push(&mut messages, role, &text);
}
let calls = tool_calls(item);
for (name, filepath, arg) in calls {
if EDIT_TOOLS.contains(&name.as_str()) && !filepath.is_empty() {
touched.push(filepath.clone());
}
let detail = if filepath.is_empty() { arg } else { filepath };
push(
&mut messages,
Role::Tool,
&format!("{name} {}", redacted_truncate(&detail, 300)),
);
}
}
}
let title = stored_title.unwrap_or_else(|| title_from_messages(&messages));
let started = started_from_index(path);
Ok(Session {
id: short_id(&path.to_string_lossy()),
tool,
path: path.to_path_buf(),
project,
started,
ended: None,
title,
subagent: false,
messages,
touched: dedup_paths(touched),
edits: Vec::new(),
})
}
fn content_text(content: &Value) -> String {
match content {
Value::String(s) => s.clone(),
Value::Array(parts) => parts
.iter()
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn tool_calls(item: &Value) -> Vec<(String, String, String)> {
if let Some(states) = item.get("toolCallStates").and_then(Value::as_array) {
if !states.is_empty() {
return states
.iter()
.map(|st| {
let name = st
.pointer("/toolCall/function/name")
.and_then(Value::as_str)
.unwrap_or("?")
.to_string();
let filepath = st
.pointer("/parsedArgs/filepath")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let arg = st
.get("parsedArgs")
.map(|v| v.to_string())
.unwrap_or_default();
(name, filepath, arg)
})
.collect();
}
}
item.get("message")
.and_then(|m| m.get("toolCalls"))
.and_then(Value::as_array)
.map(|calls| {
calls
.iter()
.map(|c| {
let name = c
.pointer("/function/name")
.and_then(Value::as_str)
.unwrap_or("?")
.to_string();
let arg = c
.pointer("/function/arguments")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let filepath = serde_json::from_str::<Value>(&arg)
.ok()
.and_then(|v| v.get("filepath").and_then(Value::as_str).map(String::from))
.unwrap_or_default();
(name, filepath, arg)
})
.collect()
})
.unwrap_or_default()
}
fn started_from_index(path: &Path) -> Option<DateTime<Utc>> {
let id = path.file_stem()?.to_string_lossy();
let index = path.parent()?.join("sessions.json");
let raw = crate::util::read_to_string_capped(&index).ok()?;
let list: Value = serde_json::from_str(&raw).ok()?;
let created = list.as_array()?.iter().find_map(|e| {
let sid = e.get("sessionId").and_then(Value::as_str)?;
if sid == id {
e.get("dateCreated").and_then(Value::as_str)
} else {
None
}
})?;
DateTime::from_timestamp_millis(created.parse::<i64>().ok()?)
}
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,
});
}
}