Skip to main content

spool/
session_sources.rs

1use crate::desktop::{DesktopSessionItem, DesktopSessionMessage};
2use crate::lifecycle_store::LedgerEntry;
3use serde::Deserialize;
4use serde_json::Value;
5use std::collections::{BTreeMap, BTreeSet};
6use std::fs;
7use std::io::{BufRead, BufReader};
8use std::path::PathBuf;
9use walkdir::WalkDir;
10
11#[derive(Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13struct ClaudeSessionsIndexFile {
14    entries: Vec<ClaudeSessionIndexEntry>,
15}
16
17#[derive(Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19struct ClaudeSessionIndexEntry {
20    session_id: String,
21    full_path: String,
22    summary: Option<String>,
23    first_prompt: Option<String>,
24    message_count: Option<usize>,
25    created: Option<String>,
26    modified: Option<String>,
27    project_path: Option<String>,
28}
29
30#[derive(Debug, Deserialize)]
31struct CodexSessionIndexEntry {
32    id: String,
33    thread_name: Option<String>,
34    updated_at: String,
35}
36
37#[derive(Default)]
38struct CodexSessionMeta {
39    cwd: Option<String>,
40    prompt_preview: Option<String>,
41    updated_at: Option<String>,
42}
43
44pub struct ProviderSessionMessages {
45    pub messages: Vec<DesktopSessionMessage>,
46    pub total_messages: usize,
47    pub has_more_messages: bool,
48}
49
50const SESSION_MESSAGE_MAX_CHARS: usize = 2400;
51/// Maximum lines to read from a bare .jsonl file for metadata extraction.
52const BARE_FILE_HEAD_LINES: usize = 20;
53
54pub fn raw_session_id(session_id: &str) -> String {
55    session_id
56        .split_once(':')
57        .map(|(_, raw)| raw.to_string())
58        .unwrap_or_else(|| session_id.to_string())
59}
60
61pub fn build_memory_session_items(entries: &[LedgerEntry]) -> Vec<DesktopSessionItem> {
62    #[derive(Default)]
63    struct Aggregate {
64        last_recorded_at: String,
65        record_count: usize,
66        pending_review_count: usize,
67        wakeup_ready_count: usize,
68        titles: BTreeSet<String>,
69        memory_types: BTreeSet<String>,
70    }
71
72    let mut grouped: BTreeMap<String, Aggregate> = BTreeMap::new();
73    for entry in entries {
74        for session_id in entry_session_refs(entry) {
75            let aggregate = grouped.entry(session_id).or_default();
76            if entry.recorded_at > aggregate.last_recorded_at {
77                aggregate.last_recorded_at = entry.recorded_at.clone();
78            }
79            aggregate.record_count += 1;
80            if entry.record.requires_review() {
81                aggregate.pending_review_count += 1;
82            }
83            if entry.record.can_be_returned_in_wakeup() {
84                aggregate.wakeup_ready_count += 1;
85            }
86            aggregate.titles.insert(entry.record.title.clone());
87            aggregate
88                .memory_types
89                .insert(entry.record.memory_type.clone());
90        }
91    }
92
93    let mut sessions: Vec<DesktopSessionItem> = grouped
94        .into_iter()
95        .map(|(session_id, aggregate)| DesktopSessionItem {
96            provider: "spool".to_string(),
97            session_id: format!("spool:{session_id}"),
98            title: aggregate
99                .titles
100                .iter()
101                .next()
102                .cloned()
103                .unwrap_or_else(|| session_id.clone()),
104            summary: Some("来自 spool memory 记录的会话聚合".to_string()),
105            prompt_preview: aggregate.titles.iter().next().cloned(),
106            cwd: None,
107            source_path: None,
108            project_path: None,
109            updated_at: aggregate.last_recorded_at.clone(),
110            record_count: aggregate.record_count,
111            pending_review_count: aggregate.pending_review_count,
112            wakeup_ready_count: aggregate.wakeup_ready_count,
113            titles: aggregate.titles.into_iter().take(4).collect(),
114            memory_types: aggregate.memory_types.into_iter().collect(),
115        })
116        .collect();
117
118    sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
119    sessions
120}
121
122/// Load provider sessions with an optional provider filter.
123/// When `filter` is `Some("claude")`, only Claude sessions are loaded, etc.
124/// When `filter` is `None`, all providers are loaded.
125pub fn load_provider_sessions(filter: Option<&str>) -> anyhow::Result<Vec<DesktopSessionItem>> {
126    let mut sessions = Vec::new();
127    if filter.is_none() || filter == Some("claude") {
128        sessions.extend(load_claude_sessions()?);
129    }
130    if filter.is_none() || filter == Some("codex") {
131        sessions.extend(load_codex_sessions()?);
132    }
133    if filter.is_none() || filter == Some("gemini") {
134        sessions.extend(load_gemini_sessions()?);
135    }
136    sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
137    Ok(sessions)
138}
139
140/// Load messages for a provider session with pagination support.
141/// `offset` is the number of messages to skip from the beginning.
142/// `limit` is the maximum number of messages to return (0 means all).
143pub fn load_provider_messages(
144    session: &DesktopSessionItem,
145    offset: usize,
146    limit: usize,
147) -> anyhow::Result<ProviderSessionMessages> {
148    match session.provider.as_str() {
149        "claude" => load_claude_messages(session, offset, limit),
150        "codex" => load_codex_messages(session, offset, limit),
151        _ => Ok(ProviderSessionMessages {
152            messages: Vec::new(),
153            total_messages: 0,
154            has_more_messages: false,
155        }),
156    }
157}
158
159pub fn entry_session_refs(entry: &LedgerEntry) -> BTreeSet<String> {
160    let mut refs = BTreeSet::new();
161    collect_session_ref(entry.record.origin.source_ref.as_str(), &mut refs);
162    for evidence in &entry.metadata.evidence_refs {
163        collect_session_ref(evidence.as_str(), &mut refs);
164    }
165    refs
166}
167
168fn home_dir() -> Option<PathBuf> {
169    crate::support::home_dir()
170}
171
172// ---- Claude Code sessions ----
173
174fn load_claude_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
175    let Some(home) = home_dir() else {
176        return Ok(Vec::new());
177    };
178    let projects_root = home.join(".claude/projects");
179    if !projects_root.exists() {
180        return Ok(Vec::new());
181    }
182
183    let mut sessions = Vec::new();
184    let mut indexed_paths: BTreeSet<String> = BTreeSet::new();
185
186    // Phase 1: Load from sessions-index.json files (backward compatible)
187    for entry in WalkDir::new(&projects_root).min_depth(1).max_depth(2) {
188        let entry = match entry {
189            Ok(entry) => entry,
190            Err(_) => continue,
191        };
192        if entry.file_name() != "sessions-index.json" {
193            continue;
194        }
195        let parsed: ClaudeSessionsIndexFile =
196            match serde_json::from_str(&fs::read_to_string(entry.path())?) {
197                Ok(value) => value,
198                Err(_) => continue,
199            };
200        for item in parsed.entries {
201            if fs::metadata(&item.full_path).is_err() {
202                continue;
203            }
204            indexed_paths.insert(item.full_path.clone());
205            let updated_at = item
206                .modified
207                .clone()
208                .or(item.created.clone())
209                .unwrap_or_else(|| "unknown".to_string());
210            let title = item
211                .summary
212                .clone()
213                .or(item.first_prompt.clone())
214                .unwrap_or_else(|| item.session_id.clone());
215            sessions.push(DesktopSessionItem {
216                provider: "claude".to_string(),
217                session_id: format!("claude:{}", item.session_id),
218                title,
219                summary: item.summary.clone().or(item.first_prompt.clone()),
220                prompt_preview: item.first_prompt.clone(),
221                cwd: item.project_path.clone(),
222                source_path: Some(item.full_path.clone()),
223                project_path: item.project_path,
224                updated_at,
225                record_count: item.message_count.unwrap_or(0),
226                pending_review_count: 0,
227                wakeup_ready_count: 0,
228                titles: Vec::new(),
229                memory_types: Vec::new(),
230            });
231        }
232    }
233
234    // Phase 2: Scan bare .jsonl files not covered by any index
235    sessions.extend(load_claude_bare_sessions(&projects_root, &indexed_paths)?);
236
237    Ok(sessions)
238}
239
240/// Scan `~/.claude/projects/*/` for bare .jsonl files not already in the index.
241/// Only reads the first N lines of each file for metadata extraction.
242fn load_claude_bare_sessions(
243    projects_root: &std::path::Path,
244    indexed_paths: &BTreeSet<String>,
245) -> anyhow::Result<Vec<DesktopSessionItem>> {
246    let mut sessions = Vec::new();
247
248    for entry in WalkDir::new(projects_root)
249        .min_depth(2)
250        .max_depth(2)
251        .into_iter()
252        .filter_map(Result::ok)
253    {
254        if !entry.file_type().is_file() {
255            continue;
256        }
257        let path = entry.path();
258        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
259            continue;
260        }
261        // Skip files already covered by sessions-index.json
262        let path_str = path.display().to_string();
263        if indexed_paths.contains(&path_str) {
264            continue;
265        }
266        // Skip the index file itself
267        if path
268            .file_name()
269            .map(|n| n == "sessions-index.json")
270            .unwrap_or(false)
271        {
272            continue;
273        }
274
275        let session_id = path
276            .file_stem()
277            .and_then(|s| s.to_str())
278            .unwrap_or("unknown")
279            .to_string();
280
281        // Derive project_path from parent directory name
282        // e.g., "-Users-long-Work-spool" → "/Users/long/Work/spool"
283        let project_path = path
284            .parent()
285            .and_then(|p| p.file_name())
286            .and_then(|n| n.to_str())
287            .map(decode_claude_project_slug);
288
289        let meta = extract_claude_bare_meta(path);
290
291        let title = meta.title.unwrap_or_else(|| {
292            meta.first_prompt
293                .clone()
294                .unwrap_or_else(|| session_id.clone())
295        });
296        let updated_at = meta.last_timestamp.unwrap_or_else(|| {
297            // Fallback to file mtime
298            fs::metadata(path)
299                .and_then(|m| m.modified())
300                .ok()
301                .and_then(|t| {
302                    let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
303                    Some(format_unix_timestamp(duration.as_secs()))
304                })
305                .unwrap_or_else(|| "unknown".to_string())
306        });
307
308        sessions.push(DesktopSessionItem {
309            provider: "claude".to_string(),
310            session_id: format!("claude:{}", session_id),
311            title,
312            summary: meta.first_prompt.clone(),
313            prompt_preview: meta.first_prompt,
314            cwd: meta.cwd.or(project_path.clone()),
315            source_path: Some(path_str),
316            project_path,
317            updated_at,
318            record_count: 0,
319            pending_review_count: 0,
320            wakeup_ready_count: 0,
321            titles: Vec::new(),
322            memory_types: Vec::new(),
323        });
324    }
325
326    Ok(sessions)
327}
328
329/// Metadata extracted from the head of a bare Claude .jsonl file.
330#[derive(Default)]
331struct ClaudeBareFileMeta {
332    title: Option<String>,
333    first_prompt: Option<String>,
334    cwd: Option<String>,
335    last_timestamp: Option<String>,
336}
337
338/// Read the first N lines of a bare Claude .jsonl to extract metadata.
339fn extract_claude_bare_meta(path: &std::path::Path) -> ClaudeBareFileMeta {
340    let mut meta = ClaudeBareFileMeta::default();
341    let file = match fs::File::open(path) {
342        Ok(f) => f,
343        Err(_) => return meta,
344    };
345    let reader = BufReader::new(file);
346
347    for (idx, line) in reader.lines().enumerate() {
348        if idx >= BARE_FILE_HEAD_LINES {
349            break;
350        }
351        let line = match line {
352            Ok(l) => l,
353            Err(_) => break,
354        };
355        if line.trim().is_empty() {
356            continue;
357        }
358        let value: Value = match serde_json::from_str(&line) {
359            Ok(v) => v,
360            Err(_) => continue,
361        };
362
363        // Track latest timestamp seen
364        if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
365            meta.last_timestamp = Some(ts.to_string());
366        }
367
368        match value.get("type").and_then(Value::as_str) {
369            Some("ai-title") if meta.title.is_none() => {
370                meta.title = value
371                    .get("aiTitle")
372                    .and_then(Value::as_str)
373                    .map(truncate_for_preview);
374            }
375            Some("user") => {
376                if meta.first_prompt.is_none() {
377                    meta.first_prompt =
378                        extract_content_text(&value).map(|s| truncate_for_preview(&s));
379                }
380                if meta.cwd.is_none() {
381                    meta.cwd = value
382                        .get("cwd")
383                        .and_then(Value::as_str)
384                        .map(ToString::to_string);
385                }
386            }
387            _ => {}
388        }
389
390        if meta.title.is_some() && meta.first_prompt.is_some() && meta.cwd.is_some() {
391            break;
392        }
393    }
394
395    meta
396}
397
398/// Decode a Claude project slug back to a path.
399/// e.g., "-Users-long-Work-spool" → "/Users/long/Work/spool"
400fn decode_claude_project_slug(slug: &str) -> String {
401    if slug.starts_with('-') {
402        slug.replacen('-', "/", 1).replace('-', "/")
403    } else {
404        slug.replace('-', "/")
405    }
406}
407
408// ---- Codex sessions ----
409
410/// Load Codex sessions by directly scanning the file tree.
411/// Falls back to session_index.jsonl metadata when available for enrichment.
412fn load_codex_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
413    let Some(home) = home_dir() else {
414        return Ok(Vec::new());
415    };
416    let sessions_root = home.join(".codex/sessions");
417
418    // Build a lookup from index for enrichment (thread_name, updated_at)
419    let index_lookup = load_codex_index_lookup(&home);
420
421    // Directly scan the file tree
422    if !sessions_root.exists() {
423        return Ok(Vec::new());
424    }
425
426    let mut sessions = Vec::new();
427    for entry in WalkDir::new(&sessions_root)
428        .into_iter()
429        .filter_map(Result::ok)
430    {
431        if !entry.file_type().is_file() {
432            continue;
433        }
434        let path = entry.path();
435        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
436            continue;
437        }
438        let Some(name) = path.file_stem().and_then(|name| name.to_str()) else {
439            continue;
440        };
441        // Extract UUID from filename like "rollout-2026-05-08T15-58-31-019e0698-6647-7681-8fe3-bafa985c83df"
442        // The UUID is the last 36 characters (8-4-4-4-12 format with hyphens)
443        let id = extract_codex_uuid(name);
444        let Some(id) = id else {
445            continue;
446        };
447
448        let path_str = path.display().to_string();
449        let meta = load_codex_session_meta(&path_str);
450
451        // Enrich from index if available
452        let index_entry = index_lookup.get(id);
453        let thread_name = index_entry.and_then(|e| e.thread_name.clone());
454        let index_updated_at = index_entry.map(|e| e.updated_at.clone());
455
456        let updated_at = meta.updated_at.or(index_updated_at).unwrap_or_else(|| {
457            fs::metadata(path)
458                .and_then(|m| m.modified())
459                .ok()
460                .and_then(|t| {
461                    let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
462                    Some(format_unix_timestamp(duration.as_secs()))
463                })
464                .unwrap_or_else(|| "unknown".to_string())
465        });
466
467        let title = meta
468            .prompt_preview
469            .clone()
470            .or(thread_name.clone())
471            .unwrap_or_else(|| id.to_string());
472
473        sessions.push(DesktopSessionItem {
474            provider: "codex".to_string(),
475            session_id: format!("codex:{}", id),
476            title,
477            summary: thread_name.or(meta.prompt_preview.clone()),
478            prompt_preview: meta.prompt_preview,
479            cwd: meta.cwd,
480            source_path: Some(path_str),
481            project_path: None,
482            updated_at,
483            record_count: 0,
484            pending_review_count: 0,
485            wakeup_ready_count: 0,
486            titles: Vec::new(),
487            memory_types: Vec::new(),
488        });
489    }
490
491    Ok(sessions)
492}
493
494/// Extract UUID from a Codex session filename stem.
495/// Filename pattern: "rollout-2026-05-08T15-58-31-019e0698-6647-7681-8fe3-bafa985c83df"
496/// The UUID is the last 36 characters in 8-4-4-4-12 format.
497fn extract_codex_uuid(name: &str) -> Option<&str> {
498    if name.len() < 36 {
499        return None;
500    }
501    let candidate = &name[name.len() - 36..];
502    // Verify it looks like a UUID: 8-4-4-4-12 with exactly 4 hyphens
503    let parts: Vec<&str> = candidate.split('-').collect();
504    if parts.len() != 5 {
505        return None;
506    }
507    let expected_lens = [8, 4, 4, 4, 12];
508    for (part, &expected) in parts.iter().zip(&expected_lens) {
509        if part.len() != expected || !part.chars().all(|c| c.is_ascii_hexdigit()) {
510            return None;
511        }
512    }
513    Some(candidate)
514}
515
516/// Load the codex session_index.jsonl as a lookup map for enrichment.
517fn load_codex_index_lookup(home: &std::path::Path) -> BTreeMap<String, CodexSessionIndexEntry> {
518    let index_path = home.join(".codex/session_index.jsonl");
519    let mut lookup = BTreeMap::new();
520    let content = match fs::read_to_string(index_path) {
521        Ok(c) => c,
522        Err(_) => return lookup,
523    };
524    for line in content.lines().filter(|l| !l.trim().is_empty()) {
525        if let Ok(entry) = serde_json::from_str::<CodexSessionIndexEntry>(line) {
526            lookup.insert(entry.id.clone(), entry);
527        }
528    }
529    lookup
530}
531
532fn load_codex_session_meta(path: &str) -> CodexSessionMeta {
533    let mut meta = CodexSessionMeta::default();
534    let file = match fs::File::open(path) {
535        Ok(f) => f,
536        Err(_) => return meta,
537    };
538    let reader = BufReader::new(file);
539
540    for line in reader.lines() {
541        let line = match line {
542            Ok(l) => l,
543            Err(_) => break,
544        };
545        if line.trim().is_empty() {
546            continue;
547        }
548        let value: Value = match serde_json::from_str(&line) {
549            Ok(value) => value,
550            Err(_) => continue,
551        };
552
553        // Track timestamp for updated_at
554        if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
555            meta.updated_at = Some(ts.to_string());
556        }
557
558        match value.get("type").and_then(Value::as_str) {
559            Some("session_meta") => {
560                meta.cwd = value
561                    .get("payload")
562                    .and_then(|payload| payload.get("cwd"))
563                    .and_then(Value::as_str)
564                    .map(ToString::to_string);
565            }
566            Some("response_item") => {
567                let payload = value.get("payload");
568                let role = payload
569                    .and_then(|payload| payload.get("role"))
570                    .and_then(Value::as_str);
571                if role == Some("user") && meta.prompt_preview.is_none() {
572                    meta.prompt_preview = payload
573                        .and_then(|payload| payload.get("content"))
574                        .and_then(Value::as_array)
575                        .and_then(|items| {
576                            items.iter().find_map(|item| {
577                                item.get("text")
578                                    .and_then(Value::as_str)
579                                    .map(truncate_for_preview)
580                            })
581                        });
582                }
583            }
584            _ => {}
585        }
586
587        if meta.cwd.is_some() && meta.prompt_preview.is_some() {
588            break;
589        }
590    }
591
592    meta
593}
594
595// ---- Gemini CLI sessions (skeleton) ----
596
597fn load_gemini_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
598    let Some(home) = home_dir() else {
599        return Ok(Vec::new());
600    };
601    let history_root = home.join(".gemini/history");
602    if !history_root.exists() {
603        return Ok(Vec::new());
604    }
605
606    let mut sessions = Vec::new();
607    for entry in WalkDir::new(&history_root)
608        .min_depth(1)
609        .max_depth(3)
610        .into_iter()
611        .filter_map(Result::ok)
612    {
613        if !entry.file_type().is_file() {
614            continue;
615        }
616        let path = entry.path();
617        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
618        if ext != "json" && ext != "jsonl" {
619            continue;
620        }
621        let session_id = path
622            .file_stem()
623            .and_then(|s| s.to_str())
624            .unwrap_or("unknown")
625            .to_string();
626
627        let updated_at = fs::metadata(path)
628            .and_then(|m| m.modified())
629            .ok()
630            .and_then(|t| {
631                let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
632                Some(format_unix_timestamp(duration.as_secs()))
633            })
634            .unwrap_or_else(|| "unknown".to_string());
635
636        sessions.push(DesktopSessionItem {
637            provider: "gemini".to_string(),
638            session_id: format!("gemini:{}", session_id),
639            title: session_id.clone(),
640            summary: None,
641            prompt_preview: None,
642            cwd: None,
643            source_path: Some(path.display().to_string()),
644            project_path: None,
645            updated_at,
646            record_count: 0,
647            pending_review_count: 0,
648            wakeup_ready_count: 0,
649            titles: Vec::new(),
650            memory_types: Vec::new(),
651        });
652    }
653
654    Ok(sessions)
655}
656
657// ---- Message loading with pagination ----
658
659fn load_claude_messages(
660    session: &DesktopSessionItem,
661    offset: usize,
662    limit: usize,
663) -> anyhow::Result<ProviderSessionMessages> {
664    let Some(path) = session.source_path.as_deref() else {
665        return Ok(ProviderSessionMessages {
666            messages: Vec::new(),
667            total_messages: 0,
668            has_more_messages: false,
669        });
670    };
671    let mut messages = Vec::new();
672    for line in fs::read_to_string(path)?
673        .lines()
674        .filter(|line| !line.trim().is_empty())
675    {
676        let value: Value = match serde_json::from_str(line) {
677            Ok(value) => value,
678            Err(_) => continue,
679        };
680
681        // New format: type-based messages
682        let msg_type = value.get("type").and_then(Value::as_str);
683        match msg_type {
684            Some("user") | Some("assistant") => {
685                let role = msg_type.unwrap();
686                let content_text = extract_content_text(&value).unwrap_or_default();
687                if content_text.is_empty() {
688                    continue;
689                }
690                messages.push(DesktopSessionMessage {
691                    role: role.to_string(),
692                    timestamp: value
693                        .get("timestamp")
694                        .and_then(Value::as_str)
695                        .unwrap_or("unknown")
696                        .to_string(),
697                    content: truncate_for_detail(&content_text),
698                    truncated: is_content_truncated(&content_text),
699                });
700            }
701            _ => {
702                // Legacy format: message.role + message.content (string)
703                let role = value
704                    .get("message")
705                    .and_then(|message| message.get("role"))
706                    .and_then(Value::as_str);
707                let content = value
708                    .get("message")
709                    .and_then(|message| message.get("content"))
710                    .and_then(Value::as_str);
711                if let (Some(role), Some(content)) = (role, content) {
712                    messages.push(DesktopSessionMessage {
713                        role: role.to_string(),
714                        timestamp: value
715                            .get("timestamp")
716                            .and_then(Value::as_str)
717                            .unwrap_or("unknown")
718                            .to_string(),
719                        content: truncate_for_detail(content),
720                        truncated: is_content_truncated(content),
721                    });
722                }
723            }
724        }
725    }
726    Ok(paginate_messages(messages, offset, limit))
727}
728
729fn load_codex_messages(
730    session: &DesktopSessionItem,
731    offset: usize,
732    limit: usize,
733) -> anyhow::Result<ProviderSessionMessages> {
734    let Some(path) = session.source_path.as_deref() else {
735        return Ok(ProviderSessionMessages {
736            messages: Vec::new(),
737            total_messages: 0,
738            has_more_messages: false,
739        });
740    };
741    let mut messages = Vec::new();
742    for line in fs::read_to_string(path)?
743        .lines()
744        .filter(|line| !line.trim().is_empty())
745    {
746        let value: Value = match serde_json::from_str(line) {
747            Ok(value) => value,
748            Err(_) => continue,
749        };
750        let message_payload = value
751            .get("payload")
752            .filter(|_| value.get("type").and_then(Value::as_str) == Some("response_item"));
753        let Some(payload) = message_payload else {
754            continue;
755        };
756        if payload.get("type").and_then(Value::as_str) != Some("message") {
757            continue;
758        }
759        let Some(role) = payload.get("role").and_then(Value::as_str) else {
760            continue;
761        };
762        let text = payload
763            .get("content")
764            .and_then(Value::as_array)
765            .and_then(|items| {
766                items.iter().find_map(|item| {
767                    item.get("text")
768                        .and_then(Value::as_str)
769                        .map(ToString::to_string)
770                })
771            });
772        if let Some(text) = text {
773            messages.push(DesktopSessionMessage {
774                role: role.to_string(),
775                timestamp: value
776                    .get("timestamp")
777                    .and_then(Value::as_str)
778                    .unwrap_or("unknown")
779                    .to_string(),
780                content: truncate_for_detail(&text),
781                truncated: is_content_truncated(&text),
782            });
783        }
784    }
785    Ok(paginate_messages(messages, offset, limit))
786}
787
788// ---- Helpers ----
789
790fn collect_session_ref(value: &str, refs: &mut BTreeSet<String>) {
791    if value.starts_with("session:") {
792        refs.insert(value.to_string());
793    }
794}
795
796/// Extract text content from a new-format Claude message.
797/// Handles both `message.content` as array of content blocks and as a plain string.
798fn extract_content_text(value: &Value) -> Option<String> {
799    let message = value.get("message")?;
800    let content = message.get("content")?;
801
802    // Array of content blocks: [{"type": "text", "text": "..."}, ...]
803    if let Some(arr) = content.as_array() {
804        let texts: Vec<&str> = arr
805            .iter()
806            .filter_map(|item| item.get("text").and_then(Value::as_str))
807            .collect();
808        if texts.is_empty() {
809            return None;
810        }
811        return Some(texts.join("\n"));
812    }
813
814    // Plain string content (legacy)
815    content.as_str().map(ToString::to_string)
816}
817
818fn truncate_for_preview(value: &str) -> String {
819    let trimmed = value.trim();
820    if trimmed.chars().count() <= 360 {
821        return trimmed.to_string();
822    }
823    trimmed.chars().take(360).collect::<String>() + "..."
824}
825
826fn truncate_for_detail(value: &str) -> String {
827    let trimmed = value.trim();
828    if trimmed.chars().count() <= SESSION_MESSAGE_MAX_CHARS {
829        return trimmed.to_string();
830    }
831    trimmed
832        .chars()
833        .take(SESSION_MESSAGE_MAX_CHARS)
834        .collect::<String>()
835        + "\n\n...[truncated]"
836}
837
838fn is_content_truncated(value: &str) -> bool {
839    value.trim().chars().count() > SESSION_MESSAGE_MAX_CHARS
840}
841
842/// Apply offset/limit pagination to a collected message list.
843fn paginate_messages(
844    messages: Vec<DesktopSessionMessage>,
845    offset: usize,
846    limit: usize,
847) -> ProviderSessionMessages {
848    let total_messages = messages.len();
849    let effective_limit = if limit == 0 { total_messages } else { limit };
850    let start = offset.min(total_messages);
851    let end = (start + effective_limit).min(total_messages);
852    let paged: Vec<DesktopSessionMessage> =
853        messages.into_iter().skip(start).take(end - start).collect();
854    let has_more_messages = end < total_messages;
855    ProviderSessionMessages {
856        messages: paged,
857        total_messages,
858        has_more_messages,
859    }
860}
861
862fn format_unix_timestamp(secs: u64) -> String {
863    // Simple ISO-ish format without pulling in chrono
864    let days_since_epoch = secs / 86400;
865    let time_of_day = secs % 86400;
866    let hours = time_of_day / 3600;
867    let minutes = (time_of_day % 3600) / 60;
868    let seconds = time_of_day % 60;
869
870    // Approximate year/month/day from days since epoch (1970-01-01)
871    let mut remaining_days = days_since_epoch as i64;
872    let mut year = 1970i64;
873    loop {
874        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
875        if remaining_days < days_in_year {
876            break;
877        }
878        remaining_days -= days_in_year;
879        year += 1;
880    }
881    let days_in_months: [i64; 12] = if is_leap_year(year) {
882        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
883    } else {
884        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
885    };
886    let mut month = 1u32;
887    for &days_in_month in &days_in_months {
888        if remaining_days < days_in_month {
889            break;
890        }
891        remaining_days -= days_in_month;
892        month += 1;
893    }
894    let day = remaining_days + 1;
895
896    format!(
897        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
898        year, month, day, hours, minutes, seconds
899    )
900}
901
902fn is_leap_year(year: i64) -> bool {
903    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
904}
905
906// ---- Tests ----
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911    use std::fs;
912    use tempfile::tempdir;
913
914    fn test_session(provider: &str, path: &std::path::Path) -> DesktopSessionItem {
915        DesktopSessionItem {
916            provider: provider.to_string(),
917            session_id: format!("{provider}:demo"),
918            title: "demo".to_string(),
919            summary: None,
920            prompt_preview: None,
921            cwd: Some("/tmp/demo".to_string()),
922            source_path: Some(path.display().to_string()),
923            project_path: Some("/tmp/demo".to_string()),
924            updated_at: "2026-04-16T12:00:00Z".to_string(),
925            record_count: 0,
926            pending_review_count: 0,
927            wakeup_ready_count: 0,
928            titles: Vec::new(),
929            memory_types: Vec::new(),
930        }
931    }
932
933    #[test]
934    fn claude_message_loader_should_paginate_with_offset_and_limit() {
935        let temp = tempdir().unwrap();
936        let path = temp.path().join("claude-session.jsonl");
937        let mut lines = Vec::new();
938        for index in 0..30 {
939            lines.push(format!(
940                "{{\"timestamp\":\"2026-04-16T12:{index:02}:00Z\",\"message\":{{\"role\":\"user\",\"content\":\"message {index} {}\"}}}}",
941                "x".repeat(32)
942            ));
943        }
944        fs::write(&path, lines.join("\n")).unwrap();
945
946        // Default: offset=0, limit=0 returns all
947        let response = load_claude_messages(&test_session("claude", &path), 0, 0).unwrap();
948        assert_eq!(response.total_messages, 30);
949        assert!(!response.has_more_messages);
950        assert_eq!(response.messages.len(), 30);
951
952        // With limit
953        let response = load_claude_messages(&test_session("claude", &path), 0, 10).unwrap();
954        assert_eq!(response.total_messages, 30);
955        assert!(response.has_more_messages);
956        assert_eq!(response.messages.len(), 10);
957        assert_eq!(
958            response.messages.first().unwrap().content,
959            format!("message 0 {}", "x".repeat(32))
960        );
961
962        // With offset + limit
963        let response = load_claude_messages(&test_session("claude", &path), 25, 10).unwrap();
964        assert_eq!(response.total_messages, 30);
965        assert!(!response.has_more_messages);
966        assert_eq!(response.messages.len(), 5);
967        assert_eq!(
968            response.messages.first().unwrap().content,
969            format!("message 25 {}", "x".repeat(32))
970        );
971    }
972
973    #[test]
974    fn claude_message_loader_should_handle_new_format() {
975        let temp = tempdir().unwrap();
976        let path = temp.path().join("claude-new.jsonl");
977        let lines = [
978            r#"{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"content":[{"type":"text","text":"hello world"}]},"cwd":"/tmp","sessionId":"abc123"}"#,
979            r#"{"type":"assistant","timestamp":"2026-05-01T10:01:00Z","message":{"content":[{"type":"text","text":"hi there"}]},"sessionId":"abc123"}"#,
980            r#"{"type":"ai-title","aiTitle":"Test Session","sessionId":"abc123"}"#,
981        ];
982        fs::write(&path, lines.join("\n")).unwrap();
983
984        let response = load_claude_messages(&test_session("claude", &path), 0, 0).unwrap();
985        assert_eq!(response.total_messages, 2);
986        assert_eq!(response.messages[0].role, "user");
987        assert_eq!(response.messages[0].content, "hello world");
988        assert_eq!(response.messages[1].role, "assistant");
989        assert_eq!(response.messages[1].content, "hi there");
990    }
991
992    #[test]
993    fn codex_message_loader_should_mark_truncated_detail_content() {
994        let temp = tempdir().unwrap();
995        let path = temp.path().join("codex-session.jsonl");
996        let long_text = "y".repeat(3000);
997        fs::write(
998            &path,
999            format!(
1000                "{{\"timestamp\":\"2026-04-16T12:00:00Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"text\":\"{}\"}}]}}}}",
1001                long_text
1002            ),
1003        )
1004        .unwrap();
1005
1006        let response = load_codex_messages(&test_session("codex", &path), 0, 0).unwrap();
1007        assert_eq!(response.total_messages, 1);
1008        assert!(!response.has_more_messages);
1009        assert_eq!(response.messages.len(), 1);
1010        assert!(response.messages[0].truncated);
1011        assert!(response.messages[0].content.ends_with("...[truncated]"));
1012    }
1013
1014    #[test]
1015    fn extract_claude_bare_meta_should_parse_new_format() {
1016        let temp = tempdir().unwrap();
1017        let path = temp.path().join("session.jsonl");
1018        let lines = [
1019            r#"{"type":"ai-title","aiTitle":"My Session Title","sessionId":"abc"}"#,
1020            r#"{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"content":[{"type":"text","text":"first prompt here"}]},"cwd":"/home/user/project","sessionId":"abc"}"#,
1021        ];
1022        fs::write(&path, lines.join("\n")).unwrap();
1023
1024        let meta = extract_claude_bare_meta(&path);
1025        assert_eq!(meta.title.as_deref(), Some("My Session Title"));
1026        assert_eq!(meta.first_prompt.as_deref(), Some("first prompt here"));
1027        assert_eq!(meta.cwd.as_deref(), Some("/home/user/project"));
1028        assert_eq!(meta.last_timestamp.as_deref(), Some("2026-05-01T10:00:00Z"));
1029    }
1030
1031    #[test]
1032    fn decode_claude_project_slug_should_restore_path() {
1033        assert_eq!(
1034            decode_claude_project_slug("-Users-long-Work-spool"),
1035            "/Users/long/Work/spool"
1036        );
1037        assert_eq!(
1038            decode_claude_project_slug("home-user-project"),
1039            "home/user/project"
1040        );
1041    }
1042
1043    #[test]
1044    fn paginate_messages_should_handle_edge_cases() {
1045        let msgs: Vec<DesktopSessionMessage> = (0..5)
1046            .map(|i| DesktopSessionMessage {
1047                role: "user".to_string(),
1048                timestamp: format!("t{i}"),
1049                content: format!("msg{i}"),
1050                truncated: false,
1051            })
1052            .collect();
1053
1054        // offset beyond total
1055        let result = paginate_messages(msgs.clone(), 10, 5);
1056        assert_eq!(result.messages.len(), 0);
1057        assert!(!result.has_more_messages);
1058        assert_eq!(result.total_messages, 5);
1059
1060        // limit=0 means all
1061        let result = paginate_messages(msgs.clone(), 0, 0);
1062        assert_eq!(result.messages.len(), 5);
1063        assert!(!result.has_more_messages);
1064
1065        // partial page
1066        let result = paginate_messages(msgs, 3, 10);
1067        assert_eq!(result.messages.len(), 2);
1068        assert!(!result.has_more_messages);
1069    }
1070
1071    #[test]
1072    fn codex_session_scan_should_find_files_without_index() {
1073        let temp = tempdir().unwrap();
1074        let sessions_dir = temp.path().join("sessions/2026/05/01");
1075        fs::create_dir_all(&sessions_dir).unwrap();
1076
1077        let uuid = "019e0698-6647-7681-8fe3-bafa985c83df";
1078        let filename = format!("rollout-2026-05-01T10-00-00-{uuid}.jsonl");
1079        let session_path = sessions_dir.join(&filename);
1080        fs::write(
1081            &session_path,
1082            r#"{"type":"session_meta","payload":{"cwd":"/tmp/test"}}
1083{"type":"response_item","timestamp":"2026-05-01T10:00:00Z","payload":{"type":"message","role":"user","content":[{"text":"hello codex"}]}}"#,
1084        )
1085        .unwrap();
1086
1087        let meta = load_codex_session_meta(&session_path.display().to_string());
1088        assert_eq!(meta.cwd.as_deref(), Some("/tmp/test"));
1089        assert_eq!(meta.prompt_preview.as_deref(), Some("hello codex"));
1090    }
1091
1092    #[test]
1093    fn extract_codex_uuid_should_parse_real_filenames() {
1094        // Real filename pattern from ~/.codex/sessions/
1095        assert_eq!(
1096            extract_codex_uuid("rollout-2026-03-26T15-18-28-019d2902-48c6-71f1-a107-6cb56512de80"),
1097            Some("019d2902-48c6-71f1-a107-6cb56512de80")
1098        );
1099        assert_eq!(
1100            extract_codex_uuid("rollout-2026-05-08T15-58-31-019e0698-6647-7681-8fe3-bafa985c83df"),
1101            Some("019e0698-6647-7681-8fe3-bafa985c83df")
1102        );
1103        // Too short
1104        assert_eq!(extract_codex_uuid("short"), None);
1105        // Not a valid UUID at the end
1106        assert_eq!(
1107            extract_codex_uuid("rollout-2026-05-08T15-58-31-not-a-valid-uuid-at-all-here"),
1108            None
1109        );
1110    }
1111}