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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -1
- data/Rakefile +44 -4
- data/doc/composer.md +2 -2
- data/doc/files.md +8 -2
- data/doc/releasing.md +71 -38
- data/doc/security.md +1 -1
- data/doc/shell.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/adaptive_pty_output_sink.rb +183 -0
- data/lib/kward/cli/git.rb +5 -1
- data/lib/kward/cli/rendering.rb +14 -2
- data/lib/kward/cli/runtime_helpers.rb +103 -39
- data/lib/kward/cli/settings.rb +5 -13
- data/lib/kward/cli/tabs.rb +59 -4
- data/lib/kward/cli.rb +2 -0
- data/lib/kward/image_attachments.rb +98 -15
- data/lib/kward/interactive_pty_runner.rb +25 -19
- data/lib/kward/prompt_interface/composer_controller.rb +3 -3
- data/lib/kward/prompt_interface/composer_renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/controller.rb +6 -6
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +2 -2
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
- data/lib/kward/prompt_interface/git_prompt.rb +1 -1
- data/lib/kward/prompt_interface/key_handler.rb +22 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +1 -0
- data/lib/kward/prompt_interface/project_browser.rb +162 -3
- data/lib/kward/prompt_interface/prompt_renderer.rb +15 -6
- data/lib/kward/prompt_interface/question_prompt.rb +1 -1
- data/lib/kward/prompt_interface/screen.rb +40 -20
- data/lib/kward/prompt_interface/selection_prompt.rb +5 -5
- data/lib/kward/prompt_interface/transcript_renderer.rb +4 -4
- data/lib/kward/prompt_interface.rb +83 -32
- data/lib/kward/pty_output_sink.rb +45 -0
- data/lib/kward/pty_transcript_normalizer.rb +93 -0
- data/lib/kward/session_catalog.rb +87 -0
- data/lib/kward/session_store.rb +94 -5
- data/lib/kward/terminal_image_support.rb +116 -0
- data/lib/kward/terminal_sequences.rb +43 -0
- data/lib/kward/version.rb +1 -1
- metadata +7 -4
- data/.github/workflows/ci.yml +0 -48
- 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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
591
|
+
flush_output_locked
|
|
579
592
|
end
|
|
580
593
|
end
|
|
581
594
|
|
|
582
|
-
def
|
|
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
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -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"))
|
|
853
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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)
|