22package tui
33
44import (
5+ "bufio"
56 "encoding/json"
67 "fmt"
78 "os"
89 "path/filepath"
910
1011 "github.com/gleanwork/glean-cli/internal/debug"
11- "github.com/gleanwork/glean-cli/internal/fileutil"
1212)
1313
1414var sessionLog = debug .New ("session:persist" )
@@ -31,6 +31,7 @@ type Source struct {
3131// Session holds conversation history and can be persisted to disk.
3232type Session struct {
3333 Turns []Turn `json:"turns"`
34+ path string // resolved path to the session file
3435}
3536
3637// sessionsDir returns ~/.glean/sessions/.
@@ -43,55 +44,144 @@ func sessionsDir() (string, error) {
4344}
4445
4546// LoadLatest loads the last saved session, or returns an empty session if none exists.
47+ // It reads JSONL format first, falling back to legacy JSON if no JSONL file exists.
4648func LoadLatest () * Session {
4749 dir , err := sessionsDir ()
4850 if err != nil {
4951 sessionLog .Log ("load: sessions dir error: %v" , err )
5052 return & Session {}
5153 }
52- path := filepath .Join (dir , "latest.json" )
53- data , err := os .ReadFile (path )
54+
55+ jsonlPath := filepath .Join (dir , "latest.jsonl" )
56+ if s , ok := loadJSONL (jsonlPath ); ok {
57+ return s
58+ }
59+
60+ jsonPath := filepath .Join (dir , "latest.json" )
61+ if s , ok := migrateFromJSON (jsonPath , jsonlPath ); ok {
62+ return s
63+ }
64+
65+ return & Session {}
66+ }
67+
68+ func loadJSONL (path string ) (* Session , bool ) {
69+ f , err := os .Open (path )
5470 if err != nil {
55- sessionLog .Log ("load: %v" , err )
56- return & Session {}
71+ return nil , false
5772 }
58- var s Session
59- if err := json .Unmarshal (data , & s ); err != nil {
60- sessionLog .Log ("load: parse error: %v" , err )
61- return & Session {}
73+ defer f .Close ()
74+
75+ var turns []Turn
76+ scanner := bufio .NewScanner (f )
77+ for scanner .Scan () {
78+ var turn Turn
79+ if err := json .Unmarshal (scanner .Bytes (), & turn ); err != nil {
80+ sessionLog .Log ("load: skipping malformed line: %v" , err )
81+ continue
82+ }
83+ turns = append (turns , turn )
84+ }
85+ if err := scanner .Err (); err != nil {
86+ sessionLog .Log ("load: scanner error: %v" , err )
6287 }
63- sessionLog .Log ("loaded %d turns from %s" , len (s . Turns ), path )
64- return & s
88+ sessionLog .Log ("loaded %d turns from %s" , len (turns ), path )
89+ return & Session { Turns : turns , path : path }, true
6590}
6691
67- // Save persists the session to ~/.glean/sessions/latest.json.
68- func (s * Session ) Save () error {
69- dir , err := sessionsDir ()
92+ func migrateFromJSON (jsonPath , jsonlPath string ) (* Session , bool ) {
93+ data , err := os .ReadFile (jsonPath )
7094 if err != nil {
71- return fmt . Errorf ( "could not locate sessions dir: %w" , err )
95+ return nil , false
7296 }
73- if err := os .MkdirAll (dir , 0700 ); err != nil {
74- return fmt .Errorf ("could not create sessions dir: %w" , err )
97+ var legacy Session
98+ if err := json .Unmarshal (data , & legacy ); err != nil {
99+ sessionLog .Log ("load: legacy parse error: %v" , err )
100+ return nil , false
75101 }
76- data , err := json .MarshalIndent (s , "" , " " )
102+ sessionLog .Log ("migrating %d turns from %s to %s" , len (legacy .Turns ), jsonPath , jsonlPath )
103+
104+ legacy .path = jsonlPath
105+ for _ , turn := range legacy .Turns {
106+ if err := appendTurnToFile (jsonlPath , turn ); err != nil {
107+ sessionLog .Log ("migration write error: %v" , err )
108+ return & legacy , true
109+ }
110+ }
111+ os .Remove (jsonPath )
112+ return & legacy , true
113+ }
114+
115+ func appendTurnToFile (path string , turn Turn ) error {
116+ data , err := json .Marshal (turn )
77117 if err != nil {
78118 return err
79119 }
80- return fileutil .WriteFileAtomic (filepath .Join (dir , "latest.json" ), data , 0600 )
120+ f , err := os .OpenFile (path , os .O_APPEND | os .O_CREATE | os .O_WRONLY , 0600 )
121+ if err != nil {
122+ return err
123+ }
124+ defer f .Close ()
125+ _ , err = f .Write (append (data , '\n' ))
126+ return err
127+ }
128+
129+ // ensurePath resolves and caches the session file path.
130+ func (s * Session ) ensurePath () (string , error ) {
131+ if s .path != "" {
132+ return s .path , nil
133+ }
134+ dir , err := sessionsDir ()
135+ if err != nil {
136+ return "" , fmt .Errorf ("could not locate sessions dir: %w" , err )
137+ }
138+ if err := os .MkdirAll (dir , 0700 ); err != nil {
139+ return "" , fmt .Errorf ("could not create sessions dir: %w" , err )
140+ }
141+ s .path = filepath .Join (dir , "latest.jsonl" )
142+ return s .path , nil
81143}
82144
83- // AddTurn appends a turn to the session and saves immediately.
145+ // AddTurn appends a turn to the session and persists it immediately.
84146func (s * Session ) AddTurn (role , content string , sources []Source ) error {
85147 return s .AppendTurn (Turn {Role : role , Content : content , Sources : sources })
86148}
87149
88- // AppendTurn appends a complete Turn (including Elapsed and any other fields)
89- // to the session and saves immediately.
150+ // AppendTurn appends a complete Turn to the session and persists it immediately.
90151func (s * Session ) AppendTurn (turn Turn ) error {
91152 s .Turns = append (s .Turns , turn )
92- if err := s .Save (); err != nil {
153+ path , err := s .ensurePath ()
154+ if err != nil {
155+ sessionLog .Log ("save failed: %v" , err )
156+ return err
157+ }
158+ if err := appendTurnToFile (path , turn ); err != nil {
93159 sessionLog .Log ("save failed: %v" , err )
94160 return err
95161 }
96162 return nil
97163}
164+
165+ // Save rewrites the entire session to disk. Used only for migration or
166+ // exceptional cases — normal operation uses AppendTurn for O(1) writes.
167+ func (s * Session ) Save () error {
168+ path , err := s .ensurePath ()
169+ if err != nil {
170+ return err
171+ }
172+ f , err := os .Create (path )
173+ if err != nil {
174+ return err
175+ }
176+ defer f .Close ()
177+ for _ , turn := range s .Turns {
178+ data , err := json .Marshal (turn )
179+ if err != nil {
180+ return err
181+ }
182+ if _ , err := f .Write (append (data , '\n' )); err != nil {
183+ return err
184+ }
185+ }
186+ return nil
187+ }
0 commit comments