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::{copilot_vscode, 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 if row.provider == "copilot-vscode" {
146 let session = copilot_vscode::load_session_value(Path::new(&row.file_path))?;
147 for msg in copilot_vscode::session_messages(&session) {
148 buf.push_str(&format!("## {}\n", title_case(msg.role)));
149 if let Some(dt) = msg.timestamp_ms.and_then(DateTime::from_timestamp_millis) {
150 buf.push_str(&format!("*{}*\n\n", dt.format("%Y-%m-%dT%H:%M:%S")));
151 }
152 buf.push_str(&msg.text);
153 buf.push_str("\n\n---\n\n");
154 }
155 return Ok(buf);
156 }
157
158 stream_records(Path::new(&row.file_path), |record| {
159 if let Some((role, text)) = generic_message_text(&row.provider, record) {
160 let ts = record["timestamp"]
161 .as_str()
162 .map(|s| &s[..19.min(s.len())])
163 .unwrap_or("");
164 buf.push_str(&format!("## {}\n", title_case(&role)));
165 if !ts.is_empty() {
166 buf.push_str(&format!("*{}*\n\n", ts));
167 }
168 buf.push_str(&text);
169 buf.push_str("\n\n---\n\n");
170 }
171 true
172 })?;
173 Ok(buf)
174}
175
176fn build_indexed_json_value(row: &IndexedSession) -> Result<Value> {
177 let mut records = Vec::new();
178 let mut messages = Vec::new();
179 if row.provider == "copilot-vscode" {
180 let session = copilot_vscode::load_session_value(Path::new(&row.file_path))?;
181 for msg in copilot_vscode::session_messages(&session) {
182 messages.push(serde_json::json!({
183 "role": msg.role,
184 "timestamp": msg
185 .timestamp_ms
186 .and_then(DateTime::from_timestamp_millis)
187 .map(|d| d.to_rfc3339()),
188 "text": msg.text,
189 }));
190 }
191 records.push(session);
192 } else {
193 stream_records(Path::new(&row.file_path), |record| {
194 if let Some((role, text)) = generic_message_text(&row.provider, record) {
195 messages.push(serde_json::json!({
196 "role": role,
197 "timestamp": record["timestamp"].as_str(),
198 "text": text,
199 }));
200 }
201 records.push(record.clone());
202 true
203 })?;
204 }
205 Ok(serde_json::json!({
206 "provider": row.provider,
207 "project": row.project_name,
208 "session_id": row.session_id,
209 "file_path": row.file_path,
210 "date": row.first_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
211 "last_activity": row.last_timestamp_ms.and_then(DateTime::from_timestamp_millis).map(|d| d.to_rfc3339()),
212 "model": row.model,
213 "message_count": row.message_count,
214 "duration_ms": row.duration_ms,
215 "present_on_disk": row.present_on_disk,
216 "archived_at": row.archived_at.and_then(|s| DateTime::from_timestamp(s, 0)).map(|d| d.to_rfc3339()),
217 "extras": row.extras.as_deref().and_then(|s| serde_json::from_str::<Value>(s).ok()),
218 "messages": records.clone(),
219 "normalized_messages": messages,
220 "records": records,
221 }))
222}
223
224fn generic_message_text(provider: &str, record: &Value) -> Option<(String, String)> {
225 match provider {
226 "claude" => match record["type"].as_str()? {
227 "user" => {
228 text_from_value(&record["message"]["content"]).map(|t| ("user".to_string(), t))
229 }
230 "assistant" => {
231 text_from_value(&record["message"]["content"]).map(|t| ("assistant".to_string(), t))
232 }
233 _ => None,
234 },
235 "codex" => {
236 let payload = if matches!(record["type"].as_str(), Some("response_item" | "event_msg"))
237 {
238 &record["payload"]
239 } else {
240 record
241 };
242 match payload["type"].as_str()? {
243 "message" => {
244 let role = payload["role"].as_str()?.to_string();
245 text_from_value(&payload["content"])
246 .or_else(|| payload["message"].as_str().map(str::to_string))
247 .map(|t| (role, t))
248 }
249 "user_message" => payload["message"]
250 .as_str()
251 .map(|t| ("user".to_string(), t.to_string())),
252 "agent_message" => payload["message"]
253 .as_str()
254 .map(|t| ("assistant".to_string(), t.to_string())),
255 _ => None,
256 }
257 }
258 "copilot" => {
259 let text = record["data"]["content"]
260 .as_str()
261 .filter(|t| !t.is_empty())?;
262 match record["type"].as_str()? {
263 "user.message" => Some(("user".to_string(), text.to_string())),
264 "assistant.message" => Some(("assistant".to_string(), text.to_string())),
265 _ => None,
266 }
267 }
268 "pi" | "openclaw" => {
269 let msg = if record["type"].as_str() == Some("message") {
270 &record["message"]
271 } else {
272 record
273 };
274 let role = msg["role"].as_str().or_else(|| record["role"].as_str())?;
275 if role == "user" || role == "assistant" {
276 text_from_value(&msg["content"])
277 .or_else(|| text_from_value(&record["content"]))
278 .map(|t| (role.to_string(), t))
279 } else {
280 None
281 }
282 }
283 _ => None,
284 }
285}
286
287fn text_from_value(value: &Value) -> Option<String> {
288 if let Some(s) = value.as_str().filter(|s| !s.is_empty()) {
289 return Some(s.to_string());
290 }
291 let parts: Vec<String> = value
292 .as_array()?
293 .iter()
294 .filter_map(|b| {
295 b["text"]
296 .as_str()
297 .or_else(|| b["content"].as_str())
298 .filter(|s| !s.is_empty())
299 .map(str::to_string)
300 })
301 .collect();
302 (!parts.is_empty()).then(|| parts.join("\n"))
303}
304
305fn title_case(role: &str) -> String {
306 let mut chars = role.chars();
307 match chars.next() {
308 Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
309 None => "Message".to_string(),
310 }
311}
312
313fn build_markdown(project: &str, path: &Path) -> Result<String> {
314 let stats = parse_session(path)?;
315 let mut buf = String::new();
316
317 let sid: String = stats
318 .session_id
319 .as_deref()
320 .unwrap_or_else(|| {
321 path.file_stem()
322 .and_then(|s| s.to_str())
323 .unwrap_or("unknown")
324 })
325 .chars()
326 .take(8)
327 .collect();
328
329 buf.push_str(&format!("# Session: {}\n\n", sid));
330 buf.push_str(&format!("**Project:** {}\n", project));
331 if let Some(dt) = stats.first_timestamp {
332 buf.push_str(&format!("**Date:** {}\n", dt.format("%Y-%m-%d %H:%M UTC")));
333 }
334 if let Some(m) = &stats.model {
335 buf.push_str(&format!("**Model:** {}\n", m.trim_start_matches("claude-")));
336 }
337 buf.push('\n');
338 buf.push_str("---\n\n");
339
340 stream_records(path, |record| {
341 let ts = record["timestamp"]
342 .as_str()
343 .map(|s| &s[..19.min(s.len())])
344 .unwrap_or("");
345
346 match record["type"].as_str().unwrap_or("") {
347 "user" => {
348 buf.push_str("## User\n");
349 if !ts.is_empty() {
350 buf.push_str(&format!("*{}*\n\n", ts));
351 }
352 push_user_content(&mut buf, &record["message"]["content"]);
353 buf.push_str("\n---\n\n");
354 }
355 "assistant" => {
356 buf.push_str("## Assistant\n");
357 if !ts.is_empty() {
358 buf.push_str(&format!("*{}*\n\n", ts));
359 }
360 push_assistant_content(&mut buf, &record["message"]["content"]);
361 buf.push_str("\n---\n\n");
362 }
363 _ => {}
364 }
365 true
366 })?;
367
368 Ok(buf)
369}
370
371fn push_user_content(buf: &mut String, content: &Value) {
372 if let Some(text) = content.as_str() {
373 buf.push_str(text);
374 buf.push('\n');
375 } else if let Some(arr) = content.as_array() {
376 for block in arr {
377 match block["type"].as_str().unwrap_or("") {
378 "text" => {
379 if let Some(text) = block["text"].as_str() {
380 buf.push_str(text);
381 buf.push('\n');
382 }
383 }
384 "tool_result" => {
385 let id = block["tool_use_id"].as_str().unwrap_or("");
386 buf.push_str(&format!("\n**Tool result** ({})\n", id));
387 match &block["content"] {
388 Value::Array(arr) => {
389 for c in arr {
390 if let Some(text) = c["text"].as_str() {
391 buf.push_str("```\n");
392 buf.push_str(text);
393 buf.push_str("\n```\n");
394 }
395 }
396 }
397 Value::String(s) => {
398 buf.push_str("```\n");
399 buf.push_str(s);
400 buf.push_str("\n```\n");
401 }
402 _ => {}
403 }
404 }
405 _ => {}
406 }
407 }
408 }
409}
410
411fn push_assistant_content(buf: &mut String, content: &Value) {
412 if let Some(arr) = content.as_array() {
413 for block in arr {
414 match block["type"].as_str().unwrap_or("") {
415 "text" => {
416 if let Some(text) = block["text"].as_str() {
417 buf.push_str(text);
418 buf.push('\n');
419 }
420 }
421 "tool_use" => {
422 let name = block["name"].as_str().unwrap_or("unknown");
423 buf.push_str(&format!("\n**Tool: {}**\n", name));
424 if !block["input"].is_null() {
425 buf.push_str("```json\n");
426 if let Ok(json) = serde_json::to_string_pretty(&block["input"]) {
427 buf.push_str(&json);
428 buf.push('\n');
429 }
430 buf.push_str("```\n");
431 }
432 }
433 _ => {}
434 }
435 }
436 }
437}
438
439fn build_json_value(project: &str, path: &Path) -> Result<Value> {
440 let stats = parse_session(path)?;
441 let mut messages: Vec<Value> = Vec::new();
442
443 stream_records(path, |record| {
444 if matches!(record["type"].as_str(), Some("user") | Some("assistant")) {
445 messages.push(record.clone());
446 }
447 true
448 })?;
449
450 Ok(serde_json::json!({
451 "project": project,
452 "session_id": stats.session_id,
453 "date": stats.first_timestamp.map(|d| d.to_rfc3339()),
454 "model": stats.model,
455 "message_count": stats.message_count,
456 "messages": messages,
457 }))
458}