@@ -2,50 +2,181 @@ use std::collections::HashMap;
22use std:: fs:: File ;
33use std:: io:: { BufRead , BufReader } ;
44
5- use serde:: Deserialize ;
6- use walkdir:: WalkDir ;
5+ use rusqlite:: Connection ;
76
87use crate :: error:: AgfError ;
98use crate :: model:: { Agent , Session } ;
109
11- #[ derive( Deserialize ) ]
12- struct SessionMeta {
13- #[ serde( rename = "type" ) ]
14- entry_type : Option < String > ,
15- payload : Option < SessionPayload > ,
10+ pub fn scan ( ) -> Result < Vec < Session > , AgfError > {
11+ let codex_dir = crate :: config:: codex_dir ( ) ?;
12+
13+ // Collect summaries from history.jsonl (keyed by session_id, newest-first)
14+ let summaries = read_history_summaries ( & codex_dir) ;
15+
16+ // Primary: read from SQLite (state_*.sqlite)
17+ let mut sessions = scan_sqlite ( & codex_dir, & summaries) ;
18+
19+ // Fallback: if SQLite found nothing, try JSONL walkdir
20+ if sessions. is_empty ( ) {
21+ sessions = scan_jsonl ( & codex_dir, & summaries) ;
22+ }
23+
24+ sessions. sort_by ( |a, b| b. timestamp . cmp ( & a. timestamp ) ) ;
25+ Ok ( sessions)
1626}
1727
18- #[ derive( Deserialize ) ]
19- struct SessionPayload {
20- id : Option < String > ,
21- cwd : Option < String > ,
22- timestamp : Option < String > ,
23- git : Option < GitInfo > ,
28+ /// Read sessions from Codex SQLite database (state_*.sqlite).
29+ /// This is the primary source — covers CLI, desktop app (vscode), and exec sessions.
30+ fn scan_sqlite (
31+ codex_dir : & std:: path:: Path ,
32+ summaries : & HashMap < String , Vec < String > > ,
33+ ) -> Vec < Session > {
34+ // Find the latest state_*.sqlite file
35+ let db_path = match find_state_db ( codex_dir) {
36+ Some ( p) => p,
37+ None => return Vec :: new ( ) ,
38+ } ;
39+
40+ let conn =
41+ match Connection :: open_with_flags ( & db_path, rusqlite:: OpenFlags :: SQLITE_OPEN_READ_ONLY ) {
42+ Ok ( c) => c,
43+ Err ( _) => return Vec :: new ( ) ,
44+ } ;
45+
46+ let mut stmt = match conn. prepare (
47+ "SELECT id, cwd, title, updated_at, git_branch, first_user_message
48+ FROM threads
49+ WHERE archived = 0 AND cwd != ''
50+ ORDER BY updated_at DESC" ,
51+ ) {
52+ Ok ( s) => s,
53+ Err ( _) => return Vec :: new ( ) ,
54+ } ;
55+
56+ let rows = match stmt. query_map ( [ ] , |row| {
57+ Ok ( (
58+ row. get :: < _ , String > ( 0 ) ?,
59+ row. get :: < _ , String > ( 1 ) ?,
60+ row. get :: < _ , String > ( 2 ) . unwrap_or_default ( ) ,
61+ row. get :: < _ , i64 > ( 3 ) . unwrap_or ( 0 ) ,
62+ row. get :: < _ , Option < String > > ( 4 ) ?,
63+ row. get :: < _ , String > ( 5 ) . unwrap_or_default ( ) ,
64+ ) )
65+ } ) {
66+ Ok ( r) => r,
67+ Err ( _) => return Vec :: new ( ) ,
68+ } ;
69+
70+ let mut sessions = Vec :: new ( ) ;
71+ for row in rows. flatten ( ) {
72+ let ( session_id, cwd, title, updated_at, git_branch, first_msg) = row;
73+
74+ if session_id. is_empty ( ) || cwd. is_empty ( ) {
75+ continue ;
76+ }
77+
78+ let project_name = std:: path:: Path :: new ( & cwd)
79+ . file_name ( )
80+ . and_then ( |n| n. to_str ( ) )
81+ . unwrap_or ( "unknown" )
82+ . to_string ( ) ;
83+
84+ // updated_at is Unix seconds — convert to millis
85+ let timestamp = updated_at * 1000 ;
86+
87+ // Build summaries: prefer history.jsonl, fall back to title/first_msg
88+ let session_summaries = if let Some ( s) = summaries. get ( & session_id) {
89+ s. clone ( )
90+ } else {
91+ // Use first line of title (can be very long), or first_user_message
92+ let summary =
93+ first_line_truncated ( & title, 200 ) . or_else ( || first_line_truncated ( & first_msg, 200 ) ) ;
94+ match summary {
95+ Some ( s) => vec ! [ s] ,
96+ None => Vec :: new ( ) ,
97+ }
98+ } ;
99+
100+ sessions. push ( Session {
101+ agent : Agent :: Codex ,
102+ session_id,
103+ project_name,
104+ project_path : cwd,
105+ summaries : session_summaries,
106+ timestamp,
107+ git_branch,
108+ worktree : None ,
109+ } ) ;
110+ }
111+
112+ sessions
24113}
25114
26- #[ derive( Deserialize ) ]
27- struct GitInfo {
28- branch : Option < String > ,
115+ /// Find the latest state_*.sqlite file in the codex directory.
116+ fn find_state_db ( codex_dir : & std:: path:: Path ) -> Option < std:: path:: PathBuf > {
117+ let entries = std:: fs:: read_dir ( codex_dir) . ok ( ) ?;
118+ let mut candidates: Vec < std:: path:: PathBuf > = entries
119+ . filter_map ( |e| e. ok ( ) )
120+ . map ( |e| e. path ( ) )
121+ . filter ( |p| {
122+ p. file_name ( )
123+ . and_then ( |n| n. to_str ( ) )
124+ . map ( |n| n. starts_with ( "state_" ) && n. ends_with ( ".sqlite" ) )
125+ . unwrap_or ( false )
126+ } )
127+ . collect ( ) ;
128+
129+ // Sort descending by name so state_5 > state_4 etc.
130+ candidates. sort_by ( |a, b| b. cmp ( a) ) ;
131+ candidates. into_iter ( ) . next ( )
29132}
30133
31- #[ derive( Deserialize ) ]
32- struct HistoryEntry {
33- session_id : Option < String > ,
34- ts : Option < f64 > ,
35- text : Option < String > ,
134+ /// Extract first line of text, truncated to max_len chars. Returns None if empty.
135+ fn first_line_truncated ( s : & str , max_len : usize ) -> Option < String > {
136+ let line = s. lines ( ) . next ( ) . unwrap_or ( "" ) . trim ( ) ;
137+ if line. is_empty ( ) {
138+ return None ;
139+ }
140+ if line. len ( ) > max_len {
141+ Some ( format ! ( "{}…" , & line[ ..max_len] ) )
142+ } else {
143+ Some ( line. to_string ( ) )
144+ }
36145}
37146
38- pub fn scan ( ) -> Result < Vec < Session > , AgfError > {
39- let codex_dir = crate :: config:: codex_dir ( ) ?;
40- let sessions_dir = codex_dir. join ( "sessions" ) ;
147+ /// Fallback: scan JSONL session files via walkdir (legacy format).
148+ fn scan_jsonl (
149+ codex_dir : & std:: path:: Path ,
150+ summaries : & HashMap < String , Vec < String > > ,
151+ ) -> Vec < Session > {
152+ use serde:: Deserialize ;
153+ use walkdir:: WalkDir ;
41154
42- // Collect summaries from history.jsonl (keyed by session_id, newest-first)
43- let summaries = read_history_summaries ( & codex_dir) ;
155+ #[ derive( Deserialize ) ]
156+ struct SessionMeta {
157+ #[ serde( rename = "type" ) ]
158+ entry_type : Option < String > ,
159+ payload : Option < SessionPayload > ,
160+ }
44161
162+ #[ derive( Deserialize ) ]
163+ struct SessionPayload {
164+ id : Option < String > ,
165+ cwd : Option < String > ,
166+ timestamp : Option < String > ,
167+ git : Option < GitInfo > ,
168+ }
169+
170+ #[ derive( Deserialize ) ]
171+ struct GitInfo {
172+ branch : Option < String > ,
173+ }
174+
175+ let sessions_dir = codex_dir. join ( "sessions" ) ;
45176 let mut sessions = Vec :: new ( ) ;
46177
47178 if !sessions_dir. exists ( ) {
48- return Ok ( sessions) ;
179+ return sessions;
49180 }
50181
51182 for entry in WalkDir :: new ( & sessions_dir)
@@ -57,7 +188,6 @@ pub fn scan() -> Result<Vec<Session>, AgfError> {
57188 continue ;
58189 }
59190
60- // Only the first line contains session_meta — no need to read the whole file.
61191 let first_line = match read_first_line ( path) {
62192 Some ( line) => line,
63193 None => continue ,
@@ -101,7 +231,6 @@ pub fn scan() -> Result<Vec<Session>, AgfError> {
101231 . unwrap_or ( 0 ) ;
102232
103233 let git_branch = payload. git . and_then ( |g| g. branch ) ;
104-
105234 let session_summaries = summaries. get ( & session_id) . cloned ( ) . unwrap_or_default ( ) ;
106235
107236 sessions. push ( Session {
@@ -116,8 +245,7 @@ pub fn scan() -> Result<Vec<Session>, AgfError> {
116245 } ) ;
117246 }
118247
119- sessions. sort_by ( |a, b| b. timestamp . cmp ( & a. timestamp ) ) ;
120- Ok ( sessions)
248+ sessions
121249}
122250
123251/// Read only the first non-empty line of a file without loading the rest.
@@ -137,6 +265,13 @@ fn read_first_line(path: &std::path::Path) -> Option<String> {
137265 }
138266}
139267
268+ #[ derive( serde:: Deserialize ) ]
269+ struct HistoryEntry {
270+ session_id : Option < String > ,
271+ ts : Option < f64 > ,
272+ text : Option < String > ,
273+ }
274+
140275fn read_history_summaries ( codex_dir : & std:: path:: Path ) -> HashMap < String , Vec < String > > {
141276 let path = codex_dir. join ( "history.jsonl" ) ;
142277 let mut summaries: HashMap < String , Vec < ( f64 , String ) > > = HashMap :: new ( ) ;
0 commit comments