1use std::fs;
2use std::io::{self, Write};
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use chrono::DateTime;
7use serde_json::Value;
8
9use claudex::index::{IndexStore, IndexedSession};
10use claudex::parser::{parse_session, stream_records};
11use claudex::providers::enabled_default;
12use claudex::store::{
13 SessionStore, decode_project_name, display_project_name, find_matching_sessions,
14};
15
16pub fn run(
17 selector: &str,
18 format: &str,
19 output: Option<&str>,
20 project_filter: Option<&str>,
21) -> Result<()> {
22 if !["markdown", "json"].contains(&format) {
23 anyhow::bail!("unknown format {:?}; expected markdown or json", format);
24 }
25
26 let indexed = resolve_indexed(selector, project_filter).unwrap_or_default();
27 if !indexed.is_empty() {
28 let mut buf = Vec::new();
29 if format == "json" {
30 let mut payload = Vec::new();
31 for row in &indexed {
32 payload.push(build_indexed_json_value(row)?);
33 }
34 let output = if payload.len() == 1 {
35 payload.into_iter().next().unwrap_or(Value::Null)
36 } else {
37 Value::Array(payload)
38 };
39 writeln!(buf, "{}", serde_json::to_string_pretty(&output)?)?;
40 } else {
41 for row in &indexed {
42 buf.write_all(build_indexed_markdown(row)?.as_bytes())?;
43 }
44 }
45 write_output(output, &buf)?;
46 return Ok(());
47 }
48
49 let store = SessionStore::new()?;
50 let all_files = store.all_session_files(project_filter)?;
51 let matching = find_matching_sessions(&all_files, selector);
52
53 if matching.is_empty() {
54 anyhow::bail!("no sessions found matching {:?}", selector);
55 }
56
57 let mut buf = Vec::new();
58 if format == "json" {
59 let mut payload = Vec::new();
60 for (project_raw, path) in &matching {
61 let project = display_project_name(&decode_project_name(project_raw));
62 payload.push(build_json_value(&project, path)?);
63 }
64 let output = if payload.len() == 1 {
65 payload.into_iter().next().unwrap_or(Value::Null)
66 } else {
67 Value::Array(payload)
68 };
69 writeln!(buf, "{}", serde_json::to_string_pretty(&output)?)?;
70 } else {
71 for (project_raw, path) in &matching {
72 let project = display_project_name(&decode_project_name(project_raw));
73 buf.write_all(build_markdown(&project, path)?.as_bytes())?;
74 }
75 }
76 write_output(output, &buf)?;
77
78 Ok(())
79}
80
81fn write_output(output: Option<&str>, bytes: &[u8]) -> Result<()> {
82 match output {
83 Some(path) => {
84 fs::write(path, bytes).with_context(|| format!("creating output file {path}"))
85 }
86 None => io::stdout().write_all(bytes).map_err(Into::into),
87 }
88}
89
90fn resolve_indexed(selector: &str, project_filter: Option<&str>) -> Result<Vec<IndexedSession>> {
91 let providers = enabled_default()?;
92 if providers.is_empty() {
93 return Ok(Vec::new());
94 }
95 let mut idx = IndexStore::open()?;
96 idx.ensure_fresh(&providers)?;
97 Ok(idx
98 .query_session_matches(selector, project_filter)?
99 .into_iter()
100 .filter(|row| row.present_on_disk && Path::new(&row.file_path).exists())
101 .collect())
102}
103
104fn build_indexed_markdown(row: &IndexedSession) -> Result<String> {
105 if row.provider == "claude" {
106 return build_markdown(&row.project_name, Path::new(&row.file_path));
107 }
108
109 let mut buf = String::new();
110 let sid = row
111 .session_id
112 .as_deref()
113 .or_else(|| {
114 Path::new(&row.file_path)
115 .file_stem()
116 .and_then(|s| s.to_str())
117 })
118 .unwrap_or("unknown");
119 buf.push_str(&format!(
120 "# Session: {}\n\n",
121 sid.chars().take(8).collect::<String>()
122 ));
123 buf.push_str(&format!("**Provider:** {}\n", row.provider));
124 buf.push_str(&format!("**Project:** {}\n", row.project_name));
125 buf.push_str(&format!("**File:** {}\n", row.file_path));
126 if let Some(dt) = row
127 .first_timestamp_ms
128 .and_then(DateTime::from_timestamp_millis)
129 {
130 buf.push_str(&format!("**Date:** {}\n", dt.format("%Y-%m-%d %H:%M UTC")));
131 }
132 if let Some(model) = &row.model {
133 buf.push_str(&format!(
134 "**Model:** {}\n",
135 model.trim_start_matches("claude-")
136 ));
137 }
138 if let Some(extras) = &row.extras {
139 buf.push_str(&format!("**Metadata:** `{}`\n", extras));
140 }
141 buf.push_str("\n---\n\n");
142
143 stream_records(Path::new(&row.file_path), |record| {
144 if let Some((role, text)) = generic_message_text(&row.provider, record) {
145 let ts = record["timestamp"]
146 .as_str()
147 .map(|s| &s[..19.min(s.len())])
148 .unwrap_or("");
149 buf.push_str(&format!("## {}\n", title_case(&role)));
150 if !ts.is_empty() {
151 buf.push_str(&format!("*{}*\n\n", ts));
152 }
153 buf.push_str(&text);
154 buf.push_str("\n\n---\n\n");
155 }
156 true
157 })?;
158 Ok(buf)
159}
160
161fn build_indexed_json_value(row: &IndexedSession) -> Result<Value> {
162 let mut records = Vec::new();
163 let mut messages = Vec::new();
164 stream_records(Path::new(&row.file_path), |record| {
165 if let Some((role, text)) = generic_message_text(&row.provider, record) {
166 messages.push(serde_json::json!({
167 "role": role,
168 "timestamp": record["timestamp"].as_str(),
169 "text": text,
170 }));
171 }
172 records.push(record.clone());
173 true
174 })?;
175 Ok(serde_json::json!({
176 "provider": row.provider,
177 "project": row.project_name,
178 "session_id": row.session_id,
179 "file_path": row.file_path,
180 "date": row.first_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
181 "last_activity": row.last_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
182 "model": row.model,
183 "message_count": row.message_count,
184 "duration_ms": row.duration_ms,
185 "present_on_disk": row.present_on_disk,
186 "archived_at": row.archived_at.and_then(|s| DateTime::from_timestamp(s, 0)).map(|d| d.to_rfc3339()),
187 "extras": row.extras.as_deref().and_then(|s| serde_json::from_str::<Value>(s).ok()),
188 "messages": records.clone(),
189 "normalized_messages": messages,
190 "records": records,
191 }))
192}
193
194fn generic_message_text(provider: &str, record: &Value) -> Option<(String, String)> {
195 match provider {
196 "claude" => match record["type"].as_str()? {
197 "user" => {
198 text_from_value(&record["message"]["content"]).map(|t| ("user".to_string(), t))
199 }
200 "assistant" => {
201 text_from_value(&record["message"]["content"]).map(|t| ("assistant".to_string(), t))
202 }
203 _ => None,
204 },
205 "codex" => {
206 let payload = if matches!(record["type"].as_str(), Some("response_item" | "event_msg"))
207 {
208 &record["payload"]
209 } else {
210 record
211 };
212 match payload["type"].as_str()? {
213 "message" => {
214 let role = payload["role"].as_str()?.to_string();
215 text_from_value(&payload["content"])
216 .or_else(|| payload["message"].as_str().map(str::to_string))
217 .map(|t| (role, t))
218 }
219 "user_message" => payload["message"]
220 .as_str()
221 .map(|t| ("user".to_string(), t.to_string())),
222 "agent_message" => payload["message"]
223 .as_str()
224 .map(|t| ("assistant".to_string(), t.to_string())),
225 _ => None,
226 }
227 }
228 "pi" | "openclaw" => {
229 let msg = if record["type"].as_str() == Some("message") {
230 &record["message"]
231 } else {
232 record
233 };
234 let role = msg["role"].as_str().or_else(|| record["role"].as_str())?;
235 if role == "user" || role == "assistant" {
236 text_from_value(&msg["content"])
237 .or_else(|| text_from_value(&record["content"]))
238 .map(|t| (role.to_string(), t))
239 } else {
240 None
241 }
242 }
243 _ => None,
244 }
245}
246
247fn text_from_value(value: &Value) -> Option<String> {
248 if let Some(s) = value.as_str().filter(|s| !s.is_empty()) {
249 return Some(s.to_string());
250 }
251 let parts: Vec<String> = value
252 .as_array()?
253 .iter()
254 .filter_map(|b| {
255 b["text"]
256 .as_str()
257 .or_else(|| b["content"].as_str())
258 .filter(|s| !s.is_empty())
259 .map(str::to_string)
260 })
261 .collect();
262 (!parts.is_empty()).then(|| parts.join("\n"))
263}
264
265fn title_case(role: &str) -> String {
266 let mut chars = role.chars();
267 match chars.next() {
268 Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
269 None => "Message".to_string(),
270 }
271}
272
273fn build_markdown(project: &str, path: &Path) -> Result<String> {
274 let stats = parse_session(path)?;
275 let mut buf = String::new();
276
277 let sid: String = stats
278 .session_id
279 .as_deref()
280 .unwrap_or_else(|| {
281 path.file_stem()
282 .and_then(|s| s.to_str())
283 .unwrap_or("unknown")
284 })
285 .chars()
286 .take(8)
287 .collect();
288
289 buf.push_str(&format!("# Session: {}\n\n", sid));
290 buf.push_str(&format!("**Project:** {}\n", project));
291 if let Some(dt) = stats.first_timestamp {
292 buf.push_str(&format!("**Date:** {}\n", dt.format("%Y-%m-%d %H:%M UTC")));
293 }
294 if let Some(m) = &stats.model {
295 buf.push_str(&format!("**Model:** {}\n", m.trim_start_matches("claude-")));
296 }
297 buf.push('\n');
298 buf.push_str("---\n\n");
299
300 stream_records(path, |record| {
301 let ts = record["timestamp"]
302 .as_str()
303 .map(|s| &s[..19.min(s.len())])
304 .unwrap_or("");
305
306 match record["type"].as_str().unwrap_or("") {
307 "user" => {
308 buf.push_str("## User\n");
309 if !ts.is_empty() {
310 buf.push_str(&format!("*{}*\n\n", ts));
311 }
312 push_user_content(&mut buf, &record["message"]["content"]);
313 buf.push_str("\n---\n\n");
314 }
315 "assistant" => {
316 buf.push_str("## Assistant\n");
317 if !ts.is_empty() {
318 buf.push_str(&format!("*{}*\n\n", ts));
319 }
320 push_assistant_content(&mut buf, &record["message"]["content"]);
321 buf.push_str("\n---\n\n");
322 }
323 _ => {}
324 }
325 true
326 })?;
327
328 Ok(buf)
329}
330
331fn push_user_content(buf: &mut String, content: &Value) {
332 if let Some(text) = content.as_str() {
333 buf.push_str(text);
334 buf.push('\n');
335 } else if let Some(arr) = content.as_array() {
336 for block in arr {
337 match block["type"].as_str().unwrap_or("") {
338 "text" => {
339 if let Some(text) = block["text"].as_str() {
340 buf.push_str(text);
341 buf.push('\n');
342 }
343 }
344 "tool_result" => {
345 let id = block["tool_use_id"].as_str().unwrap_or("");
346 buf.push_str(&format!("\n**Tool result** ({})\n", id));
347 match &block["content"] {
348 Value::Array(arr) => {
349 for c in arr {
350 if let Some(text) = c["text"].as_str() {
351 buf.push_str("```\n");
352 buf.push_str(text);
353 buf.push_str("\n```\n");
354 }
355 }
356 }
357 Value::String(s) => {
358 buf.push_str("```\n");
359 buf.push_str(s);
360 buf.push_str("\n```\n");
361 }
362 _ => {}
363 }
364 }
365 _ => {}
366 }
367 }
368 }
369}
370
371fn push_assistant_content(buf: &mut String, content: &Value) {
372 if let Some(arr) = content.as_array() {
373 for block in arr {
374 match block["type"].as_str().unwrap_or("") {
375 "text" => {
376 if let Some(text) = block["text"].as_str() {
377 buf.push_str(text);
378 buf.push('\n');
379 }
380 }
381 "tool_use" => {
382 let name = block["name"].as_str().unwrap_or("unknown");
383 buf.push_str(&format!("\n**Tool: {}**\n", name));
384 if !block["input"].is_null() {
385 buf.push_str("```json\n");
386 if let Ok(json) = serde_json::to_string_pretty(&block["input"]) {
387 buf.push_str(&json);
388 buf.push('\n');
389 }
390 buf.push_str("```\n");
391 }
392 }
393 _ => {}
394 }
395 }
396 }
397}
398
399fn build_json_value(project: &str, path: &Path) -> Result<Value> {
400 let stats = parse_session(path)?;
401 let mut messages: Vec<Value> = Vec::new();
402
403 stream_records(path, |record| {
404 if matches!(record["type"].as_str(), Some("user") | Some("assistant")) {
405 messages.push(record.clone());
406 }
407 true
408 })?;
409
410 Ok(serde_json::json!({
411 "project": project,
412 "session_id": stats.session_id,
413 "date": stats.first_timestamp.map(|d| d.to_rfc3339()),
414 "model": stats.model,
415 "message_count": stats.message_count,
416 "messages": messages,
417 }))
418}