use super::{MessageRole, SessionImporter, SessionMessage, SessionProvider, SessionSummary, ShareError, SharedSession};
use super::import_session::ImportOptions;
use serde_json::Value;
use std::path::{Path, PathBuf};
const PROVIDER: &str = "goose";
#[derive(Default)]
pub struct GooseProvider {
root_override: Option<PathBuf>,
}
impl GooseProvider {
pub fn with_root(root: PathBuf) -> Self {
Self { root_override: Some(root) }
}
fn root(&self) -> Option<PathBuf> {
self.root_override
.clone()
.or_else(|| std::env::var("ISELF_GOOSE_DIR").ok().map(PathBuf::from))
.or_else(|| dirs::config_dir().map(|d| d.join("goose").join("sessions")))
}
}
impl SessionProvider for GooseProvider {
fn name(&self) -> &str {
PROVIDER
}
fn list_sessions(&self) -> Result<Vec<SessionSummary>, ShareError> {
let root = match self.root() {
Some(r) if r.is_dir() => r,
_ => return Ok(Vec::new()),
};
let mut out = Vec::new();
for entry in std::fs::read_dir(&root)?.filter_map(|e| e.ok()) {
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let id = match p.file_stem().and_then(|s| s.to_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
if let Some(s) = summarize(&p, &id) {
out.push(s);
}
}
Ok(out)
}
fn load_session(&self, id: &str) -> Result<SharedSession, ShareError> {
let root = self
.root()
.ok_or_else(|| ShareError::NotFound(id.to_string()))?;
let path = root.join(format!("{}.jsonl", id));
if !path.exists() {
return Err(ShareError::NotFound(id.to_string()));
}
parse_jsonl(&path, id)
}
}
fn summarize(path: &Path, id: &str) -> Option<SessionSummary> {
let content = std::fs::read_to_string(path).ok()?;
let mut msg_count = 0usize;
let mut started_at: Option<chrono::DateTime<chrono::Utc>> = None;
let mut title_hint: Option<String> = None;
for line in content.lines() {
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let role = v.get("role").and_then(|r| r.as_str()).unwrap_or("");
if role.is_empty() {
continue;
}
msg_count += 1;
if started_at.is_none() {
started_at = parse_created(&v);
}
if title_hint.is_none() && role == "user" {
if let Some(t) = extract_text(&v) {
title_hint = Some(t.chars().take(80).collect());
}
}
}
Some(SessionSummary {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path: None, started_at,
message_count: msg_count,
title_hint,
imported: false,
})
}
fn parse_jsonl(path: &Path, id: &str) -> Result<SharedSession, ShareError> {
let content = std::fs::read_to_string(path)?;
let mut messages = Vec::new();
let mut started_at: Option<chrono::DateTime<chrono::Utc>> = None;
for line in content.lines() {
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let role_raw = v.get("role").and_then(|r| r.as_str()).unwrap_or("");
if role_raw.is_empty() {
continue;
}
let timestamp = parse_created(&v);
if started_at.is_none() {
started_at = timestamp;
}
let role = match role_raw {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"tool" => MessageRole::ToolResult,
"system" => MessageRole::System,
_ => continue,
};
let content_val = v.get("content");
let primary_text = stringify_content(content_val);
let mut metadata = std::collections::HashMap::new();
if let Some(model) = v.get("model").and_then(|m| m.as_str()) {
metadata.insert("model".to_string(), model.to_string());
}
messages.push(SessionMessage {
role,
content: primary_text,
timestamp,
metadata: metadata.clone(),
});
if role == MessageRole::Assistant {
if let Some(arr) = content_val.and_then(|c| c.as_array()) {
for block in arr {
let bt = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
if matches!(bt, "tool_request" | "tool_call" | "tool_use") {
let tc = block
.get("tool_call")
.or_else(|| block.get("tool_use"))
.or(Some(block));
let name = tc
.and_then(|t| t.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("?");
let args = tc
.and_then(|t| t.get("arguments").or_else(|| t.get("input")))
.map(|i| match i {
Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
})
.unwrap_or_default();
let mut tu_meta = std::collections::HashMap::new();
tu_meta.insert("tool_name".to_string(), name.to_string());
messages.push(SessionMessage {
role: MessageRole::ToolUse,
content: format!("{}({})", name, args),
timestamp,
metadata: tu_meta,
});
}
}
}
}
}
Ok(SharedSession {
provider: PROVIDER.to_string(),
id: id.to_string(),
project_path: None,
started_at,
messages,
})
}
fn parse_created(v: &Value) -> Option<chrono::DateTime<chrono::Utc>> {
v.get("created")
.and_then(|c| c.as_i64())
.and_then(|secs| chrono::DateTime::from_timestamp(secs, 0))
.or_else(|| {
v.get("timestamp")
.and_then(|t| t.as_str())
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|d| d.with_timezone(&chrono::Utc))
})
}
fn stringify_content(v: Option<&Value>) -> String {
let v = match v {
Some(v) => v,
None => return String::new(),
};
if let Some(s) = v.as_str() {
return s.to_string();
}
if let Some(arr) = v.as_array() {
let mut parts = Vec::new();
for block in arr {
let bt = block.get("type").and_then(|t| t.as_str()).unwrap_or("");
match bt {
"text" => {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
parts.push(t.to_string());
}
}
"tool_response" => {
if let Some(t) = block.get("output").and_then(|t| t.as_str()) {
parts.push(t.to_string());
} else if let Some(t) = block.get("content").and_then(|t| t.as_str()) {
parts.push(t.to_string());
}
}
_ => {}
}
}
return parts.join("\n");
}
String::new()
}
fn extract_text(v: &Value) -> Option<String> {
let c = v.get("content")?;
if let Some(s) = c.as_str() {
return Some(s.to_string());
}
for block in c.as_array()? {
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
if let Some(t) = block.get("text").and_then(|t| t.as_str()) {
return Some(t.to_string());
}
}
}
None
}
#[derive(Default)]
pub struct GooseImporter;
impl SessionImporter for GooseImporter {
fn name(&self) -> &str {
PROVIDER
}
fn import(&self, session: &SharedSession, opts: &ImportOptions) -> Result<String, ShareError> {
let root = opts
.dest_root_override
.clone()
.or_else(|| std::env::var("ISELF_GOOSE_DIR").ok().map(PathBuf::from))
.or_else(|| dirs::config_dir().map(|d| d.join("goose").join("sessions")))
.ok_or_else(|| ShareError::Parse("no config directory".into()))?;
std::fs::create_dir_all(&root).map_err(ShareError::Io)?;
let new_id = uuid::Uuid::new_v4().to_string();
let path = root.join(format!("{}.jsonl", new_id));
let mut out = String::new();
let now = chrono::Utc::now().timestamp();
out.push_str(
&serde_json::json!({
"role": "user",
"created": now,
"content": [{"type": "text", "text": format!(
"[i-self import] Continued from {} session {}. {} prior messages follow.",
session.provider, session.id, session.messages.len()
)}]
})
.to_string(),
);
out.push('\n');
for msg in &session.messages {
let envelope = encode_goose(msg);
out.push_str(&envelope.to_string());
out.push('\n');
}
std::fs::write(&path, out).map_err(ShareError::Io)?;
Ok(format!(
"Wrote {} messages to {}. Run `goose session resume {}` to continue.",
session.messages.len() + 1,
path.display(),
new_id
))
}
}
fn encode_goose(msg: &SessionMessage) -> Value {
let created = msg
.timestamp
.map(|t| t.timestamp())
.unwrap_or_else(|| chrono::Utc::now().timestamp());
let role = match msg.role {
MessageRole::User => "user",
MessageRole::Assistant => "assistant",
MessageRole::ToolUse => "assistant", MessageRole::ToolResult => "tool",
MessageRole::System => "system",
};
let block = match msg.role {
MessageRole::ToolUse => {
let name = msg
.metadata
.get("tool_name")
.map(|s| s.as_str())
.unwrap_or("imported_tool");
serde_json::json!({
"type": "tool_request",
"tool_call": {
"name": name,
"arguments": msg.content,
}
})
}
MessageRole::ToolResult => {
serde_json::json!({"type": "tool_response", "output": msg.content})
}
_ => serde_json::json!({"type": "text", "text": msg.content}),
};
serde_json::json!({
"role": role,
"created": created,
"content": [block],
})
}
#[cfg(test)]
mod tests {
use super::*;
fn write_session(root: &Path, id: &str, content: &str) -> PathBuf {
std::fs::create_dir_all(root).unwrap();
let p = root.join(format!("{}.jsonl", id));
std::fs::write(&p, content).unwrap();
p
}
#[test]
fn parses_block_array_content() {
let tmp = tempfile::tempdir().unwrap();
write_session(
tmp.path(),
"g1",
r#"{"role":"user","created":1715000000,"content":[{"type":"text","text":"hi"}]}
{"role":"assistant","created":1715000005,"content":[{"type":"text","text":"hello"},{"type":"tool_request","tool_call":{"name":"bash","arguments":"ls"}}]}
{"role":"tool","created":1715000010,"content":[{"type":"tool_response","output":"file.txt"}]}
"#,
);
let p = GooseProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("g1").unwrap();
assert_eq!(s.messages.len(), 4);
assert!(matches!(s.messages[0].role, MessageRole::User));
assert_eq!(s.messages[0].content, "hi");
assert!(matches!(s.messages[1].role, MessageRole::Assistant));
assert_eq!(s.messages[1].content, "hello");
assert!(matches!(s.messages[2].role, MessageRole::ToolUse));
assert!(s.messages[2].content.contains("bash"));
assert!(matches!(s.messages[3].role, MessageRole::ToolResult));
assert_eq!(s.messages[3].content, "file.txt");
}
#[test]
fn parses_legacy_string_content() {
let tmp = tempfile::tempdir().unwrap();
write_session(
tmp.path(),
"g2",
r#"{"role":"user","content":"plain string content"}
{"role":"assistant","content":"reply"}
"#,
);
let p = GooseProvider::with_root(tmp.path().to_path_buf());
let s = p.load_session("g2").unwrap();
assert_eq!(s.messages.len(), 2);
assert_eq!(s.messages[0].content, "plain string content");
}
#[test]
fn list_sessions_finds_jsonl_files() {
let tmp = tempfile::tempdir().unwrap();
write_session(tmp.path(), "alpha", r#"{"role":"user","content":"x"}"#);
write_session(tmp.path(), "beta", r#"{"role":"user","content":"y"}"#);
let p = GooseProvider::with_root(tmp.path().to_path_buf());
let sessions = p.list_sessions().unwrap();
assert_eq!(sessions.len(), 2);
assert!(sessions.iter().any(|s| s.id == "alpha"));
assert!(sessions.iter().any(|s| s.id == "beta"));
}
#[test]
fn round_trip_through_importer() {
let src_dir = tempfile::tempdir().unwrap();
write_session(
src_dir.path(),
"src",
r#"{"role":"user","created":1715000000,"content":[{"type":"text","text":"refactor auth"}]}
{"role":"assistant","content":[{"type":"text","text":"ok"}]}
"#,
);
let session = GooseProvider::with_root(src_dir.path().to_path_buf())
.load_session("src")
.unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let opts = ImportOptions {
dest_root_override: Some(dest_dir.path().to_path_buf()),
..ImportOptions::default()
};
GooseImporter.import(&session, &opts).unwrap();
let written: Vec<_> = std::fs::read_dir(dest_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().map(|x| x == "jsonl").unwrap_or(false))
.collect();
assert_eq!(written.len(), 1);
let content = std::fs::read_to_string(written[0].path()).unwrap();
assert!(content.contains("[i-self import]"));
assert!(content.contains("refactor auth"));
let new_id = written[0]
.path()
.file_stem()
.unwrap()
.to_str()
.unwrap()
.to_string();
let reloaded = GooseProvider::with_root(dest_dir.path().to_path_buf())
.load_session(&new_id)
.unwrap();
assert_eq!(reloaded.messages.len(), 3);
}
}