kward 0.81.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +1 -1
  5. data/Rakefile +44 -4
  6. data/doc/composer.md +2 -2
  7. data/doc/files.md +8 -2
  8. data/doc/releasing.md +71 -38
  9. data/doc/security.md +1 -1
  10. data/doc/shell.md +3 -3
  11. data/kward.gemspec +1 -1
  12. data/lib/kward/adaptive_pty_output_sink.rb +183 -0
  13. data/lib/kward/cli/git.rb +5 -1
  14. data/lib/kward/cli/rendering.rb +14 -2
  15. data/lib/kward/cli/runtime_helpers.rb +103 -39
  16. data/lib/kward/cli/settings.rb +5 -13
  17. data/lib/kward/cli/tabs.rb +59 -4
  18. data/lib/kward/cli.rb +2 -0
  19. data/lib/kward/image_attachments.rb +98 -15
  20. data/lib/kward/interactive_pty_runner.rb +25 -19
  21. data/lib/kward/prompt_interface/composer_controller.rb +3 -3
  22. data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
  23. data/lib/kward/prompt_interface/editor/controller.rb +6 -6
  24. data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
  25. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  26. data/lib/kward/prompt_interface/git_prompt.rb +1 -1
  27. data/lib/kward/prompt_interface/key_handler.rb +22 -0
  28. data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
  29. data/lib/kward/prompt_interface/project_browser.rb +162 -3
  30. data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
  31. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  32. data/lib/kward/prompt_interface/screen.rb +40 -20
  33. data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
  34. data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
  35. data/lib/kward/prompt_interface.rb +83 -32
  36. data/lib/kward/pty_output_sink.rb +45 -0
  37. data/lib/kward/pty_transcript_normalizer.rb +93 -0
  38. data/lib/kward/session_catalog.rb +87 -0
  39. data/lib/kward/session_store.rb +94 -5
  40. data/lib/kward/terminal_image_support.rb +116 -0
  41. data/lib/kward/terminal_sequences.rb +43 -0
  42. data/lib/kward/version.rb +1 -1
  43. metadata +7 -4
  44. data/.github/workflows/ci.yml +0 -48
  45. data/.github/workflows/pages.yml +0 -48
@@ -4,6 +4,8 @@ require "pathname"
4
4
  require "rbconfig"
5
5
  require "thread"
6
6
  require_relative "project_files"
7
+ require_relative "image_attachments"
8
+ require_relative "terminal_image_support"
7
9
  require_relative "prompt_history"
8
10
  require "tty-cursor"
9
11
  require "tty-reader"
@@ -68,6 +70,7 @@ module Kward
68
70
  FOOTER_REFRESH_INTERVAL = 1.0
69
71
  COMPOSER_STATUS_REFRESH_INTERVAL = 1.0
70
72
  COMPOSER_MAX_INPUT_ROWS = 6
73
+ IMAGE_VIEWER_MAX_ROWS = 8
71
74
  TRANSCRIPT_BUFFER_LIMIT = 200_000
72
75
  BANNER_MESSAGE = Banner::MESSAGE
73
76
 
@@ -131,6 +134,9 @@ module Kward
131
134
  def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil, redraw_handler: nil)
132
135
  @input_io = input
133
136
  @output_io = output
137
+ # Logical state may change during a child handoff, but only the owner may render.
138
+ @terminal_owner = :kward
139
+ @terminal_handoff_mode = nil
134
140
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
135
141
  @mutex = Mutex.new
136
142
  @prompt_history = prompt_history
@@ -178,6 +184,8 @@ module Kward
178
184
  @file_editor_open_status = nil
179
185
  @file_mention_paths = nil
180
186
  @project_browser_state = nil
187
+ @image_viewer_state = nil
188
+ @terminal_image_protocol = nil
181
189
  @project_browser_restore_after_editor = false
182
190
  @editor_state = nil
183
191
  @interactive_state = nil
@@ -237,8 +245,8 @@ module Kward
237
245
  @started = true
238
246
  @asking = true
239
247
  disable_editor_mouse_reporting(force: true) unless editor_active?
240
- @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
241
- @output_io.print(BRACKETED_PASTE_ENABLE)
248
+ print_output_locked(KEYBOARD_PROTOCOL_ENABLE)
249
+ print_output_locked(BRACKETED_PASTE_ENABLE)
242
250
  render_prompt_locked if render
243
251
  end
244
252
  end
@@ -247,15 +255,16 @@ module Kward
247
255
  @mutex.synchronize do
248
256
  return unless @started
249
257
 
258
+ clear_image_viewer_output_locked if image_viewer_active?
250
259
  clear_prompt_for_output_locked
251
260
  restore_scroll_region_locked
252
261
  disable_editor_mouse_reporting(force: true)
253
- @output_io.print(BRACKETED_PASTE_RESTORE)
254
- @output_io.print(KEYBOARD_PROTOCOL_RESTORE)
262
+ print_output_locked(BRACKETED_PASTE_RESTORE)
263
+ print_output_locked(KEYBOARD_PROTOCOL_RESTORE)
255
264
  restore_editor_cursor_shape_locked
256
265
  set_cursor_visible_locked(true, force: true)
257
- @output_io.puts
258
- @output_io.flush
266
+ puts_output_locked
267
+ flush_output_locked
259
268
  @started = false
260
269
  restore_console_mode_locked
261
270
  end
@@ -281,14 +290,14 @@ module Kward
281
290
  @stream_state.finish_block
282
291
  render_prompt_after_output_locked
283
292
  end
284
- @output_io.flush
293
+ flush_output_locked
285
294
  end
286
295
  end
287
296
 
288
297
  def restore_transcript
289
298
  start(render: false) unless @started
290
299
  @mutex.synchronize do
291
- @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
300
+ print_output_locked(SYNCHRONIZED_OUTPUT_ENABLE)
292
301
  clear_prompt_for_output_locked unless @rendered_rows.zero? && @last_composer_rows.empty?
293
302
  @transcript_buffer.clear
294
303
  @transcript_viewport_rows = 0
@@ -303,8 +312,8 @@ module Kward
303
312
  @restoring_transcript = false
304
313
  width, height = screen_size
305
314
  redraw_screen_locked(width: width, height: height)
306
- @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE)
307
- @output_io.flush
315
+ print_output_locked(SYNCHRONIZED_OUTPUT_DISABLE)
316
+ flush_output_locked
308
317
  end
309
318
  end
310
319
 
@@ -343,6 +352,10 @@ module Kward
343
352
  @mutex.synchronize { editor_active? }
344
353
  end
345
354
 
355
+ def inline_image_protocol
356
+ @mutex.synchronize { terminal_image_protocol }
357
+ end
358
+
346
359
  def update_workspace_root(root, prompt_history: nil)
347
360
  @mutex.synchronize do
348
361
  expanded_root = File.expand_path(root.to_s.empty? ? Dir.pwd : root)
@@ -575,16 +588,21 @@ module Kward
575
588
  @interactive_state = nil
576
589
  restore_composer_snapshot_locked(snapshot)
577
590
  redraw_screen_locked if @started
578
- @output_io.flush
591
+ flush_output_locked
579
592
  end
580
593
  end
581
594
 
582
- def with_terminal_handoff(preserve_composer: false)
595
+ def with_inline_terminal_handoff(&block)
596
+ with_terminal_handoff(inline: true, &block)
597
+ end
598
+
599
+ def with_terminal_handoff(inline: false)
583
600
  start
584
601
  input = nil
585
602
  output = nil
603
+ transition = nil
586
604
  @mutex.synchronize do
587
- if preserve_composer
605
+ if inline
588
606
  handle_resize_locked
589
607
  reserve_composer_region_locked
590
608
  move_to_transcript_cursor_locked
@@ -593,27 +611,36 @@ module Kward
593
611
  restore_scroll_region_locked
594
612
  end
595
613
  disable_editor_mouse_reporting(force: true)
596
- @output_io.print(BRACKETED_PASTE_RESTORE)
597
- @output_io.print(KEYBOARD_PROTOCOL_RESTORE)
614
+ print_output_locked(BRACKETED_PASTE_RESTORE)
615
+ print_output_locked(KEYBOARD_PROTOCOL_RESTORE)
598
616
  restore_editor_cursor_shape_locked
599
617
  set_cursor_visible_locked(true, force: true)
600
- @output_io.flush
618
+ flush_output_locked
601
619
  restore_console_mode_locked
602
620
  input = @input_io
603
621
  output = @output_io
622
+ transition = method(:switch_terminal_handoff_to_exclusive)
623
+ @terminal_handoff_mode = inline ? :inline : :exclusive
624
+ @terminal_owner = :child
604
625
  end
605
626
 
606
- yield(input, output)
627
+ yield(input, output, transition)
607
628
  ensure
608
629
  @mutex.synchronize do
609
- restore_scroll_region_locked if preserve_composer
610
- make_room_for_composer_after_handoff_locked if !preserve_composer && @started && @asking
630
+ @terminal_owner = :kward
631
+ @terminal_handoff_mode = nil
632
+ restore_scroll_region_locked
633
+ print_output_locked(TerminalSequences::SGR_RESET)
611
634
  enter_raw_mode_locked
612
- @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
613
- @output_io.print(BRACKETED_PASTE_ENABLE)
635
+ print_output_locked(KEYBOARD_PROTOCOL_ENABLE)
636
+ print_output_locked(BRACKETED_PASTE_ENABLE)
637
+ disable_editor_mouse_reporting(force: true)
638
+ restore_editor_cursor_shape_locked(force: true)
639
+ @cursor_visible = nil
614
640
  @last_composer_rows = []
615
- render_prompt_locked if @started && @asking
616
- @output_io.flush
641
+ width, height = screen_size
642
+ with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
643
+ flush_output_locked
617
644
  end
618
645
  end
619
646
 
@@ -851,7 +878,7 @@ module Kward
851
878
  @stream_state.finish_block
852
879
  restore_composer_cursor_locked
853
880
  end
854
- @output_io.flush
881
+ flush_output_locked
855
882
  end
856
883
  end
857
884
 
@@ -873,7 +900,7 @@ module Kward
873
900
  end
874
901
  end
875
902
 
876
- def record_transient_terminal_output(text)
903
+ def record_transient_terminal_output(text, render: true)
877
904
  value = text.to_s
878
905
  return if value.empty?
879
906
 
@@ -883,8 +910,10 @@ module Kward
883
910
  @stream_state.finish_block
884
911
  width, height = screen_size
885
912
  remember_transcript_viewport_locked(height)
913
+ next unless render
914
+
886
915
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
887
- @output_io.flush
916
+ flush_output_locked
888
917
  end
889
918
  end
890
919
 
@@ -895,7 +924,7 @@ module Kward
895
924
  write_transcript_text_locked(delta.to_s)
896
925
  restore_composer_cursor_locked unless @restoring_transcript
897
926
  end
898
- @output_io.flush unless @restoring_transcript
927
+ flush_output_locked unless @restoring_transcript
899
928
  end
900
929
  end
901
930
 
@@ -909,7 +938,7 @@ module Kward
909
938
  @mutex.synchronize do
910
939
  width, height = screen_size
911
940
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
912
- @output_io.flush
941
+ flush_output_locked
913
942
  end
914
943
  end
915
944
 
@@ -931,12 +960,27 @@ module Kward
931
960
  @stream_state.reset
932
961
  width, height = screen_size
933
962
  with_synchronized_output_locked { redraw_screen_locked(width: width, height: height) }
934
- @output_io.flush
963
+ flush_output_locked
935
964
  end
936
965
  end
937
966
 
938
967
  private
939
968
 
969
+ def switch_terminal_handoff_to_exclusive
970
+ @mutex.synchronize do
971
+ return unless @terminal_owner == :child && @terminal_handoff_mode == :inline
972
+
973
+ @terminal_owner = :kward
974
+ with_synchronized_output_locked do
975
+ clear_prompt_for_output_locked
976
+ restore_scroll_region_locked
977
+ end
978
+ flush_output_locked
979
+ @terminal_handoff_mode = :exclusive
980
+ @terminal_owner = :child
981
+ end
982
+ end
983
+
940
984
  def write_transcript_message(message, preserve_composer:)
941
985
  @mutex.synchronize do
942
986
  text = message.to_s
@@ -958,12 +1002,11 @@ module Kward
958
1002
  @stream_state.finish_block
959
1003
  preserve_composer ? restore_composer_cursor_locked : render_prompt_after_output_locked
960
1004
  end
961
- @output_io.flush
1005
+ flush_output_locked
962
1006
  end
963
1007
  end
964
1008
 
965
1009
  def request_transcript_redraw
966
- redraw_screen_locked
967
1010
  @transcript_redraw_requested = true
968
1011
  end
969
1012
 
@@ -973,7 +1016,13 @@ module Kward
973
1016
  @transcript_redraw_requested = false
974
1017
  pending
975
1018
  end
976
- @redraw_handler&.call if requested
1019
+ return unless requested
1020
+
1021
+ if @redraw_handler
1022
+ @redraw_handler.call
1023
+ else
1024
+ redraw
1025
+ end
977
1026
  end
978
1027
 
979
1028
  def prompt_workspace_root
@@ -985,6 +1034,8 @@ module Kward
985
1034
  @file_mention_path_entries_paths = nil
986
1035
  @file_mention_path_entries = nil
987
1036
  @project_browser_state = nil
1037
+ @image_viewer_state = nil
1038
+ @terminal_image_protocol = nil
988
1039
  @project_browser_tree_paths = nil
989
1040
  @project_browser_tree = nil
990
1041
  @file_overlay_dismissed_token = nil
@@ -0,0 +1,45 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Forwards PTY output immediately and optionally keeps a bounded byte copy.
4
+ #
5
+ # The runner only depends on the sink's `write` method and optional `flush`
6
+ # method. Capture is deliberately byte-oriented so PTY chunk boundaries have
7
+ # no effect on the retained output.
8
+ class PassthroughPtyOutputSink
9
+ attr_reader :captured_output
10
+
11
+ def initialize(output:, max_capture_bytes: nil)
12
+ @output = output
13
+ @max_capture_bytes = max_capture_bytes
14
+ @captured_output = max_capture_bytes ? +"".b : nil
15
+ @truncated = false
16
+ end
17
+
18
+ def write(chunk)
19
+ @output.write(chunk)
20
+ capture(chunk)
21
+ end
22
+
23
+ def flush
24
+ @output.flush if @output.respond_to?(:flush)
25
+ end
26
+
27
+ def truncated?
28
+ @truncated
29
+ end
30
+
31
+ private
32
+
33
+ def capture(chunk)
34
+ return unless @captured_output
35
+
36
+ remaining = @max_capture_bytes - @captured_output.bytesize
37
+ if chunk.bytesize > remaining
38
+ @captured_output << chunk.byteslice(0, remaining) if remaining.positive?
39
+ @truncated = true
40
+ else
41
+ @captured_output << chunk
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,93 @@
1
+ require_relative "ansi"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Reduces safe, line-oriented PTY redraws to transcript-friendly text.
6
+ class PtyTranscriptNormalizer
7
+ HORIZONTAL_REDRAW_PATTERN = /\r|\e\[[0-9;]*[CDGK`]/.freeze
8
+
9
+ def self.normalize(text)
10
+ text.split("\n", -1).map do |line|
11
+ if line.match?(HORIZONTAL_REDRAW_PATTERN)
12
+ Line.new(line).render
13
+ else
14
+ ANSI.sanitize_transcript(line)
15
+ end
16
+ end.join("\n")
17
+ end
18
+
19
+ # Applies the horizontal cursor controls accepted by AdaptivePtyOutputSink
20
+ # without modeling a complete terminal screen.
21
+ class Line
22
+ def initialize(text)
23
+ @text = text
24
+ @cells = []
25
+ @cursor = 0
26
+ end
27
+
28
+ def render
29
+ ANSI.scan_escape_tokens(@text).each do |token|
30
+ token[:escape] ? apply_escape(token[:text]) : write_text(token[:text])
31
+ end
32
+ @cells.join.rstrip
33
+ end
34
+
35
+ private
36
+
37
+ def apply_escape(sequence)
38
+ final = sequence[-1]
39
+ return set_column(sequence) if final == "G" || final == "`"
40
+ return move_cursor(sequence, 1) if final == "C"
41
+ return move_cursor(sequence, -1) if final == "D"
42
+ return erase_line(sequence) if final == "K"
43
+ end
44
+
45
+ def set_column(sequence)
46
+ @cursor = [parameter(sequence, default: 1), 1].max - 1
47
+ end
48
+
49
+ def move_cursor(sequence, direction)
50
+ distance = [parameter(sequence, default: 1), 1].max
51
+ @cursor = [@cursor + (distance * direction), 0].max
52
+ end
53
+
54
+ def erase_line(sequence)
55
+ case parameter(sequence, default: 0)
56
+ when 1
57
+ fill_to_cursor
58
+ 0.upto(@cursor) { |index| @cells[index] = " " }
59
+ when 2
60
+ @cells.clear
61
+ else
62
+ @cells.slice!(@cursor..) if @cursor < @cells.length
63
+ end
64
+ end
65
+
66
+ def parameter(sequence, default:)
67
+ value = sequence[2...-1].to_s.split(";", 2).first.to_s
68
+ value.empty? ? default : value.to_i
69
+ end
70
+
71
+ def write_text(text)
72
+ text.each_char do |character|
73
+ case character
74
+ when "\r"
75
+ @cursor = 0
76
+ when "\b"
77
+ @cursor = [@cursor - 1, 0].max
78
+ when "\t"
79
+ @cursor = ((@cursor / 8) + 1) * 8
80
+ else
81
+ fill_to_cursor
82
+ @cells[@cursor] = character
83
+ @cursor += 1
84
+ end
85
+ end
86
+ end
87
+
88
+ def fill_to_cursor
89
+ @cells.concat([" "] * (@cursor - @cells.length)) if @cursor > @cells.length
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,87 @@
1
+ require "json"
2
+ require_relative "private_file"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Rebuildable lightweight summaries for persisted session event logs.
7
+ #
8
+ # Session JSONL files remain authoritative. Catalog entries are accepted only
9
+ # while their source fingerprint matches and may be deleted at any time.
10
+ class SessionCatalog
11
+ VERSION = 1
12
+ INDEX_DIRECTORY = ".index"
13
+ FILENAME = "sessions.json"
14
+
15
+ def initialize(session_dir:)
16
+ @path = File.join(session_dir, INDEX_DIRECTORY, FILENAME)
17
+ @entries = load_entries
18
+ @dirty = false
19
+ end
20
+
21
+ def fetch(path)
22
+ entry = @entries[entry_key(path)]
23
+ return nil unless entry.is_a?(Hash)
24
+ return nil unless entry["source"] == fingerprint(path)
25
+
26
+ entry["summary"] if entry["summary"].is_a?(Hash)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+
31
+ def write(path, summary, fingerprint: self.fingerprint(path))
32
+ @entries[entry_key(path)] = {
33
+ "source" => fingerprint,
34
+ "summary" => summary
35
+ }
36
+ @dirty = true
37
+ end
38
+
39
+ def remove(path)
40
+ @dirty = true if @entries.delete(entry_key(path))
41
+ end
42
+
43
+ def retain(paths)
44
+ keys = paths.to_h { |path| [entry_key(path), true] }
45
+ previous_size = @entries.size
46
+ @entries.delete_if { |key, _entry| !keys[key] }
47
+ @dirty = true if @entries.size != previous_size
48
+ end
49
+
50
+ def flush
51
+ return unless @dirty
52
+
53
+ PrivateFile.write_json(@path, {
54
+ "version" => VERSION,
55
+ "entries" => @entries
56
+ })
57
+ @dirty = false
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def fingerprint(path)
63
+ stat = File.stat(path)
64
+ {
65
+ "size" => stat.size,
66
+ "inode" => stat.ino,
67
+ "mtimeSeconds" => stat.mtime.to_i,
68
+ "mtimeNanoseconds" => stat.mtime.nsec
69
+ }
70
+ end
71
+
72
+ private
73
+
74
+ def load_entries
75
+ data = JSON.parse(File.read(@path))
76
+ return {} unless data["version"] == VERSION && data["entries"].is_a?(Hash)
77
+
78
+ data["entries"]
79
+ rescue StandardError
80
+ {}
81
+ end
82
+
83
+ def entry_key(path)
84
+ File.basename(path)
85
+ end
86
+ end
87
+ end
@@ -9,6 +9,7 @@ require_relative "message_access"
9
9
  require_relative "message_text"
10
10
  require_relative "private_file"
11
11
  require_relative "rpc/tool_event_normalizer"
12
+ require_relative "session_catalog"
12
13
  require_relative "tools/tool_call"
13
14
  require_relative "workspace"
14
15
 
@@ -204,6 +205,7 @@ module Kward
204
205
  def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
205
206
  @config_dir = config_dir
206
207
  @cwd = File.expand_path(cwd)
208
+ @session_catalog = SessionCatalog.new(session_dir: session_dir)
207
209
  end
208
210
 
209
211
  # @return [String] workspace directory this store lists and creates sessions for
@@ -399,6 +401,8 @@ module Kward
399
401
  return false unless unused_session_file?(path)
400
402
 
401
403
  File.delete(path)
404
+ @session_catalog.remove(path)
405
+ @session_catalog.flush
402
406
  true
403
407
  rescue StandardError
404
408
  false
@@ -849,21 +853,43 @@ module Kward
849
853
  keep_empty_paths = Array(keep_empty_path).filter_map do |path|
850
854
  File.expand_path(path) unless path.to_s.empty?
851
855
  end
852
- paths = Dir.glob(File.join(session_dir, "*.jsonl")).sort_by { |path| session_file_activity_time(path) }.reverse
853
- sessions = []
856
+ paths = Dir.glob(File.join(session_dir, "*.jsonl"))
857
+ @session_catalog.retain(paths)
858
+ return all_recent_sessions(paths, keep_empty_paths: keep_empty_paths) if limit.nil?
859
+
860
+ candidates = paths.map do |path|
861
+ info = cataloged_session_info(path)
862
+ [path, info, info&.modified_at || session_file_activity_time(path)]
863
+ end
864
+ candidates.sort_by! { |_path, _info, modified_at| modified_at }.reverse!
854
865
 
855
- paths.each do |path|
856
- if limit.nil? || sessions.length < limit
857
- info = session_info(path)
866
+ sessions = []
867
+ candidates.each do |path, cataloged_info, _modified_at|
868
+ if sessions.length < limit
869
+ info = cataloged_info || cache_session_info(path)
858
870
  next unless info
859
871
  next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
860
872
 
861
873
  sessions << info
874
+ elsif cataloged_info
875
+ delete_empty_unnamed_session_info(cataloged_info, keep_empty_paths: keep_empty_paths)
862
876
  else
863
877
  delete_empty_unnamed_session_path(path, keep_empty_paths: keep_empty_paths)
864
878
  end
865
879
  end
866
880
  sessions
881
+ ensure
882
+ @session_catalog.flush
883
+ end
884
+
885
+ def all_recent_sessions(paths, keep_empty_paths:)
886
+ paths.filter_map do |path|
887
+ info = cataloged_session_info(path) || cache_session_info(path)
888
+ next unless info
889
+ next if delete_empty_unnamed_session_info(info, keep_empty_paths: keep_empty_paths)
890
+
891
+ info
892
+ end.sort_by(&:modified_at).reverse
867
893
  end
868
894
 
869
895
  def delete_empty_unnamed_session_info(info, keep_empty_paths: [])
@@ -871,6 +897,7 @@ module Kward
871
897
  return true if keep_empty_paths.include?(File.expand_path(info.path))
872
898
 
873
899
  File.delete(info.path)
900
+ @session_catalog.remove(info.path)
874
901
  true
875
902
  rescue StandardError
876
903
  false
@@ -895,6 +922,7 @@ module Kward
895
922
  return true if keep_empty_paths.include?(File.expand_path(path))
896
923
 
897
924
  File.delete(path)
925
+ @session_catalog.remove(path)
898
926
  true
899
927
  rescue StandardError
900
928
  false
@@ -944,6 +972,67 @@ module Kward
944
972
  end
945
973
  end
946
974
 
975
+ def cataloged_session_info(path)
976
+ summary = @session_catalog.fetch(path)
977
+ session_info_from_summary(path, summary) if summary
978
+ end
979
+
980
+ def cache_session_info(path)
981
+ fingerprint = @session_catalog.fingerprint(path)
982
+ info = session_info(path)
983
+ return nil unless info
984
+
985
+ current_fingerprint = @session_catalog.fingerprint(path)
986
+ if fingerprint == current_fingerprint
987
+ @session_catalog.write(path, session_info_summary(info), fingerprint: current_fingerprint)
988
+ end
989
+ info
990
+ rescue StandardError
991
+ nil
992
+ end
993
+
994
+ def session_info_summary(info)
995
+ {
996
+ "id" => info.id,
997
+ "cwd" => info.cwd,
998
+ "createdAt" => info.created_at.utc.iso8601(9),
999
+ "modifiedAt" => info.modified_at.utc.iso8601(9),
1000
+ "name" => info.name,
1001
+ "firstMessage" => info.first_message,
1002
+ "messageCount" => info.message_count,
1003
+ "provider" => info.provider,
1004
+ "model" => info.model,
1005
+ "reasoningEffort" => info.reasoning_effort,
1006
+ "parentId" => info.parent_id,
1007
+ "parentPath" => info.parent_path
1008
+ }
1009
+ end
1010
+
1011
+ def session_info_from_summary(path, summary)
1012
+ created_at = parse_time(summary["createdAt"])
1013
+ modified_at = parse_time(summary["modifiedAt"])
1014
+ return nil if summary["id"].to_s.empty? || !created_at || !modified_at
1015
+
1016
+ SessionInfo.new(
1017
+ id: summary["id"],
1018
+ path: path,
1019
+ cwd: summary["cwd"].to_s,
1020
+ created_at: created_at,
1021
+ modified_at: modified_at,
1022
+ name: summary["name"],
1023
+ first_message: summary["firstMessage"].to_s,
1024
+ message_count: summary["messageCount"].to_i,
1025
+ provider: summary["provider"],
1026
+ model: summary["model"],
1027
+ reasoning_effort: summary["reasoningEffort"],
1028
+ parent_id: summary["parentId"],
1029
+ parent_path: summary["parentPath"],
1030
+ depth: 0,
1031
+ is_last: true,
1032
+ ancestor_continues: []
1033
+ )
1034
+ end
1035
+
947
1036
  def session_info(path)
948
1037
  records = records_from_file(path)
949
1038
  header = session_header(records, path)