kward 0.69.1 → 0.71.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +68 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +30 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +43 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +39 -25
  11. data/doc/configuration.md +2 -16
  12. data/doc/context-tools.md +70 -0
  13. data/doc/getting-started.md +3 -1
  14. data/doc/plugins.md +2 -2
  15. data/doc/releasing.md +14 -5
  16. data/doc/rpc.md +3 -11
  17. data/doc/session-management.md +220 -0
  18. data/doc/usage.md +13 -7
  19. data/doc/workspace-tools.md +105 -0
  20. data/lib/kward/cli/commands.rb +8 -0
  21. data/lib/kward/cli/openrouter_commands.rb +55 -0
  22. data/lib/kward/cli/prompt_interface.rb +85 -7
  23. data/lib/kward/cli/rendering.rb +11 -6
  24. data/lib/kward/cli/sessions.rb +454 -15
  25. data/lib/kward/cli/settings.rb +0 -30
  26. data/lib/kward/cli/slash_commands.rb +38 -11
  27. data/lib/kward/cli.rb +14 -0
  28. data/lib/kward/compactor.rb +4 -1
  29. data/lib/kward/config_files.rb +4 -6
  30. data/lib/kward/conversation.rb +49 -5
  31. data/lib/kward/model/client.rb +37 -50
  32. data/lib/kward/model/context_usage.rb +13 -6
  33. data/lib/kward/model/model_info.rb +92 -9
  34. data/lib/kward/model/payloads.rb +2 -0
  35. data/lib/kward/openrouter_model_cache.rb +120 -0
  36. data/lib/kward/plugin_registry.rb +47 -1
  37. data/lib/kward/prompt_interface/banner.rb +16 -51
  38. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  39. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  40. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  41. data/lib/kward/prompt_interface/layout.rb +2 -2
  42. data/lib/kward/prompt_interface/overlay_renderer.rb +24 -0
  43. data/lib/kward/prompt_interface/prompt_renderer.rb +23 -2
  44. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  45. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  46. data/lib/kward/prompt_interface/screen.rb +10 -4
  47. data/lib/kward/prompt_interface/selection_prompt.rb +518 -61
  48. data/lib/kward/prompt_interface/slash_overlay.rb +4 -4
  49. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  50. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  51. data/lib/kward/prompt_interface.rb +31 -32
  52. data/lib/kward/prompts/commands.rb +6 -3
  53. data/lib/kward/prompts.rb +2 -2
  54. data/lib/kward/rpc/server.rb +3 -8
  55. data/lib/kward/rpc/session_manager.rb +19 -8
  56. data/lib/kward/session_diff.rb +106 -9
  57. data/lib/kward/session_store.rb +23 -4
  58. data/lib/kward/session_tree_renderer.rb +2 -1
  59. data/lib/kward/telemetry/logger.rb +5 -3
  60. data/lib/kward/tool_output_compactor.rb +127 -0
  61. data/lib/kward/tools/base.rb +8 -2
  62. data/lib/kward/tools/registry.rb +37 -6
  63. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  64. data/lib/kward/tools/search/web.rb +2 -2
  65. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  66. data/lib/kward/tools/tool_call.rb +2 -0
  67. data/lib/kward/version.rb +1 -1
  68. data/lib/kward/workspace.rb +58 -2
  69. data/templates/default/fulldoc/html/css/kward.css +570 -78
  70. data/templates/default/fulldoc/html/full_list.erb +107 -0
  71. data/templates/default/fulldoc/html/js/kward.js +259 -97
  72. data/templates/default/fulldoc/html/setup.rb +8 -0
  73. data/templates/default/kward_navigation.rb +91 -0
  74. data/templates/default/layout/html/layout.erb +59 -13
  75. data/templates/default/layout/html/setup.rb +34 -39
  76. metadata +13 -3
  77. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  78. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -58,14 +58,14 @@ module Kward
58
58
  matches = slash_overlay_matches
59
59
  return if matches.empty?
60
60
 
61
- @slash_selection_index = (@slash_selection_index - 1) % matches.length
61
+ @slash_selection_index = previous_list_selection_index(@slash_selection_index, matches.length)
62
62
  end
63
63
 
64
64
  def select_next_slash_command
65
65
  matches = slash_overlay_matches
66
66
  return if matches.empty?
67
67
 
68
- @slash_selection_index = (@slash_selection_index + 1) % matches.length
68
+ @slash_selection_index = next_list_selection_index(@slash_selection_index, matches.length)
69
69
  end
70
70
 
71
71
  def complete_selected_slash_command
@@ -92,8 +92,8 @@ module Kward
92
92
  end
93
93
 
94
94
  def visible_slash_overlay_matches(matches, height: screen_height)
95
- max_rows = [[height - 7, 1].max, 8].min
96
- start = [[@slash_selection_index - max_rows + 1, 0].max, [matches.length - max_rows, 0].max].min
95
+ max_rows = max_overlay_list_rows(height)
96
+ start = centered_list_window_start(@slash_selection_index, matches.length, max_rows)
97
97
  { start: start, commands: matches[start, max_rows] || [] }
98
98
  end
99
99
 
@@ -10,7 +10,6 @@ module Kward
10
10
  @limit = limit
11
11
  @text = +""
12
12
  @display_rows_cache_width = nil
13
- @display_rows_cache_banner_count = nil
14
13
  @display_rows_cache = nil
15
14
  end
16
15
 
@@ -42,30 +41,23 @@ module Kward
42
41
  @text
43
42
  end
44
43
 
45
- def viewport_text(row_count, width, visual_banner_count:, banner_rows:)
46
- viewport_rows(row_count, width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).join("\n")
44
+ def viewport_text(row_count, width)
45
+ viewport_rows(row_count, width).join("\n")
47
46
  end
48
47
 
49
- def viewport_rows(row_count, width, visual_banner_count:, banner_rows:)
48
+ def viewport_rows(row_count, width)
50
49
  return [] unless row_count.positive?
51
50
 
52
- rows = display_rows(width, visual_banner_count: visual_banner_count, banner_rows: banner_rows).last(row_count)
51
+ rows = display_rows(width).last(row_count)
53
52
  rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
54
53
  rows
55
54
  end
56
55
 
57
- def display_rows(width, visual_banner_count:, banner_rows:)
58
- if @display_rows_cache_width == width && @display_rows_cache_banner_count == visual_banner_count && @display_rows_cache
59
- return @display_rows_cache
60
- end
56
+ def display_rows(width)
57
+ return @display_rows_cache if @display_rows_cache_width == width && @display_rows_cache
61
58
 
62
- rows = []
63
- visual_banner_count.times { rows.concat(banner_rows.call(width)) }
64
- rows << "" if visual_banner_count.positive? && @text.empty?
65
- rows.concat(text_display_rows(width))
66
59
  @display_rows_cache_width = width
67
- @display_rows_cache_banner_count = visual_banner_count
68
- @display_rows_cache = rows
60
+ @display_rows_cache = text_display_rows(width)
69
61
  end
70
62
 
71
63
  def text_display_rows(width)
@@ -77,7 +69,6 @@ module Kward
77
69
 
78
70
  def invalidate_display_rows_cache
79
71
  @display_rows_cache_width = nil
80
- @display_rows_cache_banner_count = nil
81
72
  @display_rows_cache = nil
82
73
  end
83
74
  end
@@ -11,7 +11,7 @@ module Kward
11
11
  prepare_transcript_output_locked unless @restoring_transcript
12
12
  if label && @stream_state.block != label
13
13
  ensure_transcript_block_separator_locked
14
- write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))}\n")
14
+ write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))} ")
15
15
  @stream_state.start_block(label)
16
16
  end
17
17
  write_transcript_text_locked(delta) unless delta.empty?
@@ -82,11 +82,11 @@ module Kward
82
82
  end
83
83
 
84
84
  def transcript_renderable?
85
- @visual_banner_count.positive? || !@transcript_buffer.empty?
85
+ !@transcript_buffer.empty?
86
86
  end
87
87
 
88
88
  def transcript_display_rows(width)
89
- @transcript_buffer.display_rows(width, visual_banner_count: @visual_banner_count, banner_rows: method(:banner_rows))
89
+ @transcript_buffer.display_rows(width)
90
90
  end
91
91
 
92
92
  def transcript_text_display_rows(width)
@@ -41,7 +41,6 @@ module Kward
41
41
  FOOTER_REFRESH_INTERVAL = 1.0
42
42
  COMPOSER_MAX_INPUT_ROWS = 6
43
43
  TRANSCRIPT_BUFFER_LIMIT = 200_000
44
- BANNER_LOGO_PIXELS = Banner::LOGO_PIXELS
45
44
  BANNER_MESSAGE = Banner::MESSAGE
46
45
 
47
46
  include SlashOverlay
@@ -70,6 +69,8 @@ module Kward
70
69
  EXIT_INPUT = :exit_input
71
70
  CANCEL_INPUT = :cancel_input
72
71
  SELECT_CANCEL = :select_cancel
72
+ SELECT_CONTINUE = :select_continue
73
+ SELECT_ACTION_MINIMUM_BUSY_SECONDS = 1.0
73
74
 
74
75
  # Submitted input string carrying optional display text for transcripts.
75
76
  class SubmittedInput < String
@@ -81,7 +82,7 @@ module Kward
81
82
  end
82
83
  end
83
84
 
84
- def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_pixels: nil, banner_message: nil)
85
+ def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil)
85
86
  @input_io = input
86
87
  @output_io = output
87
88
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
@@ -105,7 +106,6 @@ module Kward
105
106
  @last_composer_rows = []
106
107
  @cursor_rendered_row = 0
107
108
  @transcript_buffer = TranscriptBuffer.new(limit: TRANSCRIPT_BUFFER_LIMIT)
108
- @visual_banner_count = 0
109
109
  @transcript_viewport_rows = 0
110
110
  @restoring_transcript = false
111
111
  @pending_keys = []
@@ -128,10 +128,10 @@ module Kward
128
128
  @busy_help = busy_help
129
129
  @attachment_badges = attachment_badges
130
130
  @attachment_parser = attachment_parser
131
- @banner = Banner.new(message: banner_message, pixels: banner_pixels, screen_height: method(:screen_height))
131
+ @banner = Banner.new(message: banner_message, screen_height: method(:screen_height))
132
132
  end
133
133
 
134
- def start
134
+ def start(render: true)
135
135
  @mutex.synchronize do
136
136
  return if @started
137
137
 
@@ -140,7 +140,7 @@ module Kward
140
140
  @asking = true
141
141
  @output_io.print(KEYBOARD_PROTOCOL_ENABLE)
142
142
  @output_io.print(BRACKETED_PASTE_ENABLE)
143
- render_prompt_locked
143
+ render_prompt_locked if render
144
144
  end
145
145
  end
146
146
 
@@ -198,11 +198,11 @@ module Kward
198
198
  end
199
199
 
200
200
  def restore_transcript
201
+ start(render: false) unless @started
201
202
  @mutex.synchronize do
202
- clear_prompt_for_output_locked
203
203
  @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
204
+ clear_prompt_for_output_locked unless @rendered_rows.zero? && @last_composer_rows.empty?
204
205
  @transcript_buffer.clear
205
- @visual_banner_count = 0
206
206
  @transcript_viewport_rows = 0
207
207
  @stream_state.finish_block
208
208
  @stream_state.reset
@@ -213,9 +213,9 @@ module Kward
213
213
  ensure
214
214
  @mutex.synchronize do
215
215
  @restoring_transcript = false
216
- @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE)
217
216
  width, height = screen_size
218
217
  redraw_screen_locked(width: width, height: height)
218
+ @output_io.print(SYNCHRONIZED_OUTPUT_DISABLE)
219
219
  @output_io.flush
220
220
  end
221
221
  end
@@ -270,23 +270,19 @@ module Kward
270
270
  answer.start_with?("y")
271
271
  end
272
272
 
273
- def select(message, choices, title: "Sessions", custom: false, initial_index: 0)
273
+ def picker_choice_width
274
+ [overlay_card_width(screen_width) - 6, 1].max
275
+ end
276
+
277
+ def select(message, choices, title: "Sessions", custom: false, initial_index: 0, action_keys: {}, action_handlers: {})
274
278
  return nil if choices.empty? && !custom
275
279
 
276
280
  start
277
281
  @mutex.synchronize do
278
- @prompt_label = message.to_s
279
- self.composer_input = ""
280
- self.composer_cursor = 0
281
- @composer.clear_attachments
282
- @pending_keys.clear
283
- @asking = true
284
- @busy = false
285
- @queued_count = 0
282
+ prepare_modal_input_locked(message, clear_attachments: true)
286
283
  choice_labels = choices.map(&:to_s)
287
284
  selection_index = choice_labels.empty? ? 0 : [[initial_index.to_i, 0].max, choice_labels.length - 1].min
288
- @select_state = { choices: choice_labels, selection_index: selection_index, title: title.to_s, custom: custom }
289
- reset_history_navigation
285
+ @select_state = { choices: choice_labels, selection_index: selection_index, title: title.to_s, custom: custom, action_keys: normalized_select_action_keys(action_keys), search_active: false }
290
286
  render_prompt_locked
291
287
  end
292
288
 
@@ -300,12 +296,20 @@ module Kward
300
296
  render_prompt_locked if resized || footer_refreshed
301
297
  else
302
298
  result = handle_select_key(key)
303
- render_prompt_locked unless result.is_a?(String) || result == SELECT_CANCEL
299
+ result = drain_pending_select_keys_locked(result)
300
+ render_prompt_locked unless result.is_a?(String) || select_action_result?(result) || result == SELECT_CANCEL
304
301
  end
305
302
  end
306
303
 
307
- if result.is_a?(String) || result == SELECT_CANCEL
308
- finish_select_prompt
304
+ if select_action_result?(result) && select_action_handler(result, action_handlers)
305
+ action_result = run_select_action(result, select_action_handler(result, action_handlers))
306
+ next if action_result == SELECT_CONTINUE
307
+
308
+ return action_result
309
+ end
310
+
311
+ if result.is_a?(String) || select_action_result?(result) || result == SELECT_CANCEL
312
+ finish_select_prompt(render: !select_deferred_finish_render?(result))
309
313
  return result == SELECT_CANCEL ? nil : result
310
314
  end
311
315
 
@@ -425,20 +429,16 @@ module Kward
425
429
  end
426
430
  end
427
431
 
428
- def print_visual_banner
432
+ def print_visual_banner(message = nil)
429
433
  @mutex.synchronize do
430
434
  width, height = screen_size
431
- rows = banner_rows(width)
435
+ rows = banner_rows(width, message: message)
432
436
  return if rows.empty?
433
437
 
434
438
  with_synchronized_output_locked do
435
439
  prepare_transcript_output_locked
436
- rows.each do |row|
437
- write_visual_transcript_text_locked(row)
438
- write_visual_transcript_text_locked("\n")
439
- end
440
- @visual_banner_count += 1
441
- invalidate_transcript_display_rows_cache
440
+ write_transcript_text_locked(rows.join("\n"))
441
+ write_transcript_text_locked("\n")
442
442
  remember_transcript_viewport_locked(height)
443
443
  @stream_state.finish_block
444
444
  restore_composer_cursor_locked
@@ -482,7 +482,6 @@ module Kward
482
482
  def clear_transcript
483
483
  @mutex.synchronize do
484
484
  @transcript_buffer.clear
485
- @visual_banner_count = 0
486
485
  @transcript_viewport_rows = 0
487
486
  @stream_state.finish_block
488
487
  @stream_state.reset
@@ -8,10 +8,14 @@ module Kward
8
8
  { name: "exit", description: "Exit the interactive session.", argument_hint: "" },
9
9
  { name: "quit", description: "Exit the interactive session.", argument_hint: "" },
10
10
  { name: "new", description: "Start a new session.", argument_hint: "" },
11
- { name: "resume", description: "Resume a saved session.", argument_hint: "[path]" },
11
+ { name: "sessions", description: "Open the saved sessions picker.", argument_hint: "[path]" },
12
+ { name: "resume", description: "Alias for /sessions.", argument_hint: "[path]" },
12
13
  { name: "name", description: "Name or clear the current session.", argument_hint: "[name]" },
14
+ { name: "rename", description: "Rename the current session.", argument_hint: "<name>" },
13
15
  { name: "clone", description: "Clone the current session.", argument_hint: "" },
14
- { name: "tree", description: "Navigate the current session tree.", argument_hint: "" },
16
+ { name: "fork", description: "Fork from an earlier prompt into a new session.", argument_hint: "" },
17
+ { name: "rewind", description: "Revisit an earlier prompt and try a different direction.", argument_hint: "" },
18
+ { name: "tree", description: "Inspect and navigate the full technical session tree.", argument_hint: "" },
15
19
  { name: "copy", description: "Copy clean session text to the clipboard.", argument_hint: "[last|transcript]" },
16
20
  { name: "export", description: "Export the current session as Markdown.", argument_hint: "[path]" },
17
21
  { name: "compact", description: "Compact the current conversation context.", argument_hint: "[instructions]" },
@@ -19,7 +23,6 @@ module Kward
19
23
  { name: "settings", description: "Configure prompt overlays.", argument_hint: "" },
20
24
  { name: "login", description: "Log in with an OAuth provider.", argument_hint: "" },
21
25
  { name: "model", description: "Select the default model.", argument_hint: "" },
22
- { name: "openrouter/catalog", description: "List the full OpenRouter model catalog.", argument_hint: "" },
23
26
  { name: "reasoning", description: "Select reasoning effort.", argument_hint: "" },
24
27
  { name: "reload", description: "Reload installed plugins.", argument_hint: "" },
25
28
  { name: "status", description: "Show the current status message.", argument_hint: "" },
data/lib/kward/prompts.rb CHANGED
@@ -32,9 +32,9 @@ module Kward
32
32
 
33
33
  def base_prompt
34
34
  <<~PROMPT.strip
35
- You are Kward, a concise practical CLI coding agent. You are allowed to use the tools. Help users understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
35
+ You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
36
36
 
37
- For web research, use web_search to discover sources, then fetch_content for important human-readable pages before relying on them. Use fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources when practical, and cite or mention the URLs you relied on.
37
+ For web research, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on.
38
38
  PROMPT
39
39
  end
40
40
 
@@ -49,7 +49,7 @@ module Kward
49
49
  "sessions/tree", "sessions/tree/setLabel", "sessions/tree/navigate",
50
50
  "sessions/export", "sessions/delete", "sessions/close", "sessions/transcript"
51
51
  ].freeze
52
- MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set", "openrouter/catalog"].freeze
52
+ MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
53
53
  AUTH_METHODS = [
54
54
  "auth/status", "auth/providers", "auth/loginWithApiKey", "auth/logoutProvider",
55
55
  "auth/loginWithOAuth", "auth/startOpenAILogin", "auth/submitOpenAICode", "auth/loginStatus"
@@ -166,8 +166,6 @@ module Kward
166
166
  prompts_expand(params)
167
167
  when "models/list"
168
168
  models_list
169
- when "openrouter/catalog"
170
- openrouter_catalog
171
169
  when "models/current"
172
170
  models_current
173
171
  when "models/set"
@@ -362,7 +360,8 @@ module Kward
362
360
  supported: true,
363
361
  methods: MODEL_METHODS,
364
362
  fields: ["provider", "id", "name", "reasoning", "reasoningEffort", "contextWindow"],
365
- scopedModels: false
363
+ scopedModels: false,
364
+ openRouterRefresh: { supported: false, reason: "cliOnlyCacheRefresh" }
366
365
  },
367
366
  runtime: {
368
367
  supported: true,
@@ -458,10 +457,6 @@ module Kward
458
457
  { models: @session_manager.available_models }
459
458
  end
460
459
 
461
- def openrouter_catalog
462
- { models: @session_manager.openrouter_catalog }
463
- end
464
-
465
460
  def config_update(params)
466
461
  config = @config_manager.update(params.fetch("values"))
467
462
  @session_manager.refresh_client_config
@@ -251,8 +251,8 @@ module Kward
251
251
  if summarize
252
252
  summary = summarize_branch(rpc_session, from_id: previous_leaf, to_id: target_leaf, custom_instructions: custom_instructions)
253
253
  target_leaf = rpc_session.session.append_branch_summary(target_leaf, from_id: previous_leaf, summary: summary, details: {})
254
- else
255
- target_leaf ? rpc_session.session.branch(target_leaf) : rpc_session.session.reset_leaf
254
+ elsif target_leaf
255
+ rpc_session.session.branch(target_leaf)
256
256
  end
257
257
 
258
258
  reload_rpc_session(rpc_session)
@@ -485,11 +485,6 @@ module Kward
485
485
  normalized
486
486
  end
487
487
 
488
- def openrouter_catalog
489
- models = @client.respond_to?(:openrouter_catalog) ? Array(@client.openrouter_catalog) : []
490
- models.map { |model| normalize_model(model) }
491
- end
492
-
493
488
  def current_model
494
489
  provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
495
490
  model = @client.respond_to?(:current_model) ? @client.current_model : nil
@@ -503,7 +498,7 @@ module Kward
503
498
  model = rpc_session.conversation.model || current[:id]
504
499
  reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
505
500
  reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
506
- context_window = current[:contextWindow] if provider == current[:provider] && model == current[:id]
501
+ context_window = context_window_for(provider, model)
507
502
  normalize_model(
508
503
  provider: provider,
509
504
  id: model,
@@ -666,6 +661,11 @@ module Kward
666
661
  end
667
662
 
668
663
  def normalize_model(model)
664
+ unless model.key?(:contextWindow) || model.key?("contextWindow")
665
+ provider = model[:provider] || model["provider"]
666
+ id = model[:id] || model["id"] || model[:model] || model["model"]
667
+ model = model.merge(contextWindow: context_window_for(provider, id))
668
+ end
669
669
  ModelInfo.normalize(
670
670
  model,
671
671
  current_provider: (@client.current_provider if @client.respond_to?(:current_provider)),
@@ -674,6 +674,17 @@ module Kward
674
674
  )
675
675
  end
676
676
 
677
+ def context_window_for(provider, model)
678
+ provider = ModelInfo.provider_label(provider)
679
+ return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
680
+
681
+ if @client.respond_to?(:current_context_window) && @client.respond_to?(:current_provider) && @client.respond_to?(:current_model)
682
+ return @client.current_context_window if provider == @client.current_provider && model == @client.current_model
683
+ end
684
+
685
+ ModelInfo.context_window(provider, model)
686
+ end
687
+
677
688
  def active_persona_label(rpc_session)
678
689
  ConfigFiles.active_persona_label(
679
690
  workspace_root: rpc_session.workspace_root,
@@ -4,11 +4,18 @@ require "json"
4
4
  module Kward
5
5
  # Counts unified-diff additions and deletions for summaries.
6
6
  class SessionDiff
7
- attr_reader :additions, :deletions
8
-
9
7
  def initialize(additions: 0, deletions: 0)
10
- @additions = additions.to_i
11
- @deletions = deletions.to_i
8
+ @base_additions = additions.to_i
9
+ @base_deletions = deletions.to_i
10
+ @file_changes = Hash.new { |changes, path| changes[path] = { removed: [], added: [] } }
11
+ end
12
+
13
+ def additions
14
+ @base_additions + @file_changes.values.sum { |changes| changes[:added].length }
15
+ end
16
+
17
+ def deletions
18
+ @base_deletions + @file_changes.values.sum { |changes| changes[:removed].length }
12
19
  end
13
20
 
14
21
  def self.from_session_file(path)
@@ -65,7 +72,7 @@ module Kward
65
72
  end
66
73
 
67
74
  def empty?
68
- @additions.zero? && @deletions.zero?
75
+ additions.zero? && deletions.zero?
69
76
  end
70
77
 
71
78
  def add_tool_result(content)
@@ -76,11 +83,19 @@ module Kward
76
83
  end
77
84
 
78
85
  def add_diff(diff)
79
- counts = self.class.count(diff)
80
- return false if counts[:additions].zero? && counts[:deletions].zero?
86
+ if self.class.truncated_diff_stats(diff) || self.class.truncated_diff?(diff)
87
+ counts = self.class.count(diff)
88
+ return false if counts[:additions].zero? && counts[:deletions].zero?
81
89
 
82
- @additions += counts[:additions]
83
- @deletions += counts[:deletions]
90
+ @base_additions += counts[:additions]
91
+ @base_deletions += counts[:deletions]
92
+ return true
93
+ end
94
+
95
+ changes = self.class.changed_lines_by_file(diff)
96
+ return false if changes.empty?
97
+
98
+ changes.each { |path, lines| apply_file_change(path, lines) }
84
99
  true
85
100
  end
86
101
 
@@ -113,12 +128,94 @@ module Kward
113
128
  previous.last
114
129
  end
115
130
 
131
+ def self.changed_lines_by_file(diff)
132
+ current_path = nil
133
+ changes = Hash.new { |file_changes, path| file_changes[path] = { removed: [], added: [] } }
134
+ removed = []
135
+ added = []
136
+ flush = lambda do
137
+ unmatched = unmatched_lines(removed, added)
138
+ changes[current_path][:removed].concat(unmatched[:removed]) if current_path
139
+ changes[current_path][:added].concat(unmatched[:added]) if current_path
140
+ removed.clear
141
+ added.clear
142
+ end
143
+
144
+ diff.to_s.each_line do |line|
145
+ if line.start_with?("--- ")
146
+ flush.call
147
+ current_path = line[4..].to_s.chomp
148
+ elsif line.start_with?("+") && !line.start_with?("+++")
149
+ added << line[1..]
150
+ elsif line.start_with?("-") && !line.start_with?("---")
151
+ removed << line[1..]
152
+ else
153
+ flush.call
154
+ end
155
+ end
156
+ flush.call
157
+ changes.reject { |_path, lines| lines[:removed].empty? && lines[:added].empty? }
158
+ end
159
+
160
+ def self.unmatched_lines(left, right)
161
+ matches = common_line_indexes(left, right)
162
+ left_matches = matches.map(&:first)
163
+ right_matches = matches.map(&:last)
164
+ {
165
+ removed: left.each_index.reject { |index| left_matches.include?(index) }.map { |index| left[index] },
166
+ added: right.each_index.reject { |index| right_matches.include?(index) }.map { |index| right[index] }
167
+ }
168
+ end
169
+
170
+ def self.common_line_indexes(left, right)
171
+ lengths = Array.new(left.length + 1) { Array.new(right.length + 1, 0) }
172
+ left.each_with_index do |left_line, left_index|
173
+ right.each_with_index do |right_line, right_index|
174
+ lengths[left_index + 1][right_index + 1] = if left_line == right_line
175
+ lengths[left_index][right_index] + 1
176
+ else
177
+ [lengths[left_index + 1][right_index], lengths[left_index][right_index + 1]].max
178
+ end
179
+ end
180
+ end
181
+
182
+ indexes = []
183
+ left_index = left.length
184
+ right_index = right.length
185
+ while left_index.positive? && right_index.positive?
186
+ if left[left_index - 1] == right[right_index - 1]
187
+ indexes.unshift([left_index - 1, right_index - 1])
188
+ left_index -= 1
189
+ right_index -= 1
190
+ elsif lengths[left_index - 1][right_index] >= lengths[left_index][right_index - 1]
191
+ left_index -= 1
192
+ else
193
+ right_index -= 1
194
+ end
195
+ end
196
+ indexes
197
+ end
198
+
116
199
  def self.parse_record(line)
117
200
  JSON.parse(line)
118
201
  rescue JSON::ParserError
119
202
  nil
120
203
  end
121
204
 
205
+ def apply_file_change(path, lines)
206
+ remove_reverted_lines(@file_changes[path][:added], lines[:removed])
207
+ remove_reverted_lines(@file_changes[path][:removed], lines[:added])
208
+ @file_changes[path][:removed].concat(lines[:removed])
209
+ @file_changes[path][:added].concat(lines[:added])
210
+ end
211
+
212
+ def remove_reverted_lines(previous_lines, current_lines)
213
+ current_lines.delete_if do |line|
214
+ index = previous_lines.index(line)
215
+ previous_lines.delete_at(index) if index
216
+ end
217
+ end
218
+
122
219
  def extract_unified_diff(text)
123
220
  index = text.index(/^--- /)
124
221
  index ? text[index..] : nil
@@ -496,9 +496,29 @@ module Kward
496
496
 
497
497
  def session_header(records, path)
498
498
  header = records.find { |record| record["type"] == "session" }
499
- raise "Invalid Kward session file: #{path}" unless header && header["id"].to_s != ""
499
+ return header if header && header["id"].to_s != ""
500
500
 
501
- header
501
+ recovered = recovered_session_header(records, path)
502
+ return recovered if recovered
503
+
504
+ raise "Invalid Kward session file: #{path}"
505
+ end
506
+
507
+ def recovered_session_header(records, path)
508
+ return nil unless records.any? { |record| ["message", "session_info", "system_prompt", "memory_state"].include?(record["type"]) }
509
+
510
+ basename = File.basename(path)
511
+ match = basename.match(/\A(?<timestamp>\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z)_(?<id>[0-9a-fA-F-]{36})\.jsonl\z/)
512
+ return nil unless match
513
+
514
+ timestamp = match[:timestamp].tr("-", ":").sub(/\A(\d{4}):(\d{2}):(\d{2})T/, "\\1-\\2-\\3T")
515
+ {
516
+ "type" => "session",
517
+ "version" => VERSION,
518
+ "id" => match[:id],
519
+ "timestamp" => timestamp,
520
+ "cwd" => @cwd
521
+ }
502
522
  end
503
523
 
504
524
  def session_named?(session)
@@ -756,8 +776,7 @@ module Kward
756
776
 
757
777
  def session_info(path)
758
778
  records = records_from_file(path)
759
- header = records.find { |record| record["type"] == "session" }
760
- return nil unless header && header["id"].to_s != ""
779
+ header = session_header(records, path)
761
780
 
762
781
  messages = restored_messages(records)
763
782
  name = session_name(records)
@@ -130,7 +130,8 @@ module Kward
130
130
  return "" if display_indent.to_i <= 0
131
131
 
132
132
  connector_position = show_connector ? display_indent - 1 : -1
133
- (0...(display_indent * 3)).map do |index|
133
+ indentation = " "
134
+ indentation + (0...(display_indent * 3)).map do |index|
134
135
  level = index / 3
135
136
  position = index % 3
136
137
  gutter = gutters.find { |candidate| candidate[:position] == level }
@@ -8,13 +8,14 @@ require_relative "../rpc/redactor"
8
8
  module Kward
9
9
  # Append-only JSONL telemetry logger with secret-conscious error payloads.
10
10
  class TelemetryLogger
11
- CATEGORIES = %w[tokens performance tools errors].freeze
11
+ CATEGORIES = %w[tokens performance tools errors compaction].freeze
12
12
  ENV_KEYS = {
13
13
  "enabled" => "KWARD_LOGGING",
14
14
  "tokens" => "KWARD_LOGGING_TOKENS",
15
15
  "performance" => "KWARD_LOGGING_PERFORMANCE",
16
16
  "tools" => "KWARD_LOGGING_TOOLS",
17
- "errors" => "KWARD_LOGGING_ERRORS"
17
+ "errors" => "KWARD_LOGGING_ERRORS",
18
+ "compaction" => "KWARD_LOGGING_COMPACTION"
18
19
  }.freeze
19
20
  DEFAULT_MAX_BYTES = 10 * 1024 * 1024
20
21
 
@@ -101,7 +102,8 @@ module Kward
101
102
  "tokens" => truthy?(logging["tokens"]),
102
103
  "performance" => truthy?(logging["performance"]),
103
104
  "tools" => truthy?(logging["tools"]),
104
- "errors" => truthy?(logging["errors"])
105
+ "errors" => truthy?(logging["errors"]),
106
+ "compaction" => truthy?(logging["compaction"])
105
107
  }
106
108
  rescue StandardError
107
109
  CATEGORIES.each_with_object({ "enabled" => false }) { |category, result| result[category] = false }