kward 0.70.0 → 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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +48 -2
  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 +1 -15
  12. data/doc/context-tools.md +70 -0
  13. data/doc/plugins.md +2 -2
  14. data/doc/releasing.md +14 -5
  15. data/doc/rpc.md +3 -11
  16. data/doc/session-management.md +220 -0
  17. data/doc/usage.md +7 -8
  18. data/doc/workspace-tools.md +105 -0
  19. data/lib/kward/cli/commands.rb +8 -0
  20. data/lib/kward/cli/openrouter_commands.rb +55 -0
  21. data/lib/kward/cli/prompt_interface.rb +80 -6
  22. data/lib/kward/cli/rendering.rb +11 -6
  23. data/lib/kward/cli/sessions.rb +260 -11
  24. data/lib/kward/cli/settings.rb +0 -30
  25. data/lib/kward/cli/slash_commands.rb +24 -6
  26. data/lib/kward/cli.rb +13 -0
  27. data/lib/kward/compactor.rb +4 -1
  28. data/lib/kward/config_files.rb +4 -6
  29. data/lib/kward/conversation.rb +49 -20
  30. data/lib/kward/model/client.rb +37 -50
  31. data/lib/kward/model/context_usage.rb +13 -6
  32. data/lib/kward/model/model_info.rb +92 -16
  33. data/lib/kward/model/payloads.rb +2 -0
  34. data/lib/kward/openrouter_model_cache.rb +120 -0
  35. data/lib/kward/plugin_registry.rb +47 -1
  36. data/lib/kward/prompt_interface/banner.rb +16 -51
  37. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  38. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  39. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  40. data/lib/kward/prompt_interface/layout.rb +2 -2
  41. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  42. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  43. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  44. data/lib/kward/prompt_interface/screen.rb +1 -0
  45. data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
  46. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  47. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  48. data/lib/kward/prompt_interface.rb +22 -28
  49. data/lib/kward/prompts/commands.rb +2 -1
  50. data/lib/kward/prompts.rb +2 -2
  51. data/lib/kward/rpc/server.rb +3 -8
  52. data/lib/kward/rpc/session_manager.rb +17 -6
  53. data/lib/kward/session_store.rb +23 -4
  54. data/lib/kward/telemetry/logger.rb +5 -3
  55. data/lib/kward/tool_output_compactor.rb +127 -0
  56. data/lib/kward/tools/base.rb +8 -2
  57. data/lib/kward/tools/registry.rb +37 -6
  58. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  59. data/lib/kward/tools/search/web.rb +2 -2
  60. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  61. data/lib/kward/tools/tool_call.rb +2 -0
  62. data/lib/kward/version.rb +1 -1
  63. data/lib/kward/workspace.rb +58 -2
  64. data/templates/default/fulldoc/html/css/kward.css +256 -7
  65. data/templates/default/fulldoc/html/full_list.erb +107 -0
  66. data/templates/default/fulldoc/html/js/kward.js +161 -2
  67. data/templates/default/fulldoc/html/setup.rb +8 -0
  68. data/templates/default/kward_navigation.rb +91 -0
  69. data/templates/default/layout/html/layout.erb +39 -8
  70. data/templates/default/layout/html/setup.rb +33 -38
  71. metadata +13 -3
  72. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  73. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -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,7 +128,7 @@ 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
134
  def start(render: true)
@@ -203,7 +203,6 @@ module Kward
203
203
  @output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
204
204
  clear_prompt_for_output_locked unless @rendered_rows.zero? && @last_composer_rows.empty?
205
205
  @transcript_buffer.clear
206
- @visual_banner_count = 0
207
206
  @transcript_viewport_rows = 0
208
207
  @stream_state.finish_block
209
208
  @stream_state.reset
@@ -275,23 +274,15 @@ module Kward
275
274
  [overlay_card_width(screen_width) - 6, 1].max
276
275
  end
277
276
 
278
- def select(message, choices, title: "Sessions", custom: false, initial_index: 0)
277
+ def select(message, choices, title: "Sessions", custom: false, initial_index: 0, action_keys: {}, action_handlers: {})
279
278
  return nil if choices.empty? && !custom
280
279
 
281
280
  start
282
281
  @mutex.synchronize do
283
- @prompt_label = message.to_s
284
- self.composer_input = ""
285
- self.composer_cursor = 0
286
- @composer.clear_attachments
287
- @pending_keys.clear
288
- @asking = true
289
- @busy = false
290
- @queued_count = 0
282
+ prepare_modal_input_locked(message, clear_attachments: true)
291
283
  choice_labels = choices.map(&:to_s)
292
284
  selection_index = choice_labels.empty? ? 0 : [[initial_index.to_i, 0].max, choice_labels.length - 1].min
293
- @select_state = { choices: choice_labels, selection_index: selection_index, title: title.to_s, custom: custom }
294
- 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 }
295
286
  render_prompt_locked
296
287
  end
297
288
 
@@ -305,12 +296,20 @@ module Kward
305
296
  render_prompt_locked if resized || footer_refreshed
306
297
  else
307
298
  result = handle_select_key(key)
308
- 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
309
301
  end
310
302
  end
311
303
 
312
- if result.is_a?(String) || result == SELECT_CANCEL
313
- 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))
314
313
  return result == SELECT_CANCEL ? nil : result
315
314
  end
316
315
 
@@ -430,20 +429,16 @@ module Kward
430
429
  end
431
430
  end
432
431
 
433
- def print_visual_banner
432
+ def print_visual_banner(message = nil)
434
433
  @mutex.synchronize do
435
434
  width, height = screen_size
436
- rows = banner_rows(width)
435
+ rows = banner_rows(width, message: message)
437
436
  return if rows.empty?
438
437
 
439
438
  with_synchronized_output_locked do
440
439
  prepare_transcript_output_locked
441
- rows.each do |row|
442
- write_visual_transcript_text_locked(row)
443
- write_visual_transcript_text_locked("\n")
444
- end
445
- @visual_banner_count += 1
446
- invalidate_transcript_display_rows_cache
440
+ write_transcript_text_locked(rows.join("\n"))
441
+ write_transcript_text_locked("\n")
447
442
  remember_transcript_viewport_locked(height)
448
443
  @stream_state.finish_block
449
444
  restore_composer_cursor_locked
@@ -487,7 +482,6 @@ module Kward
487
482
  def clear_transcript
488
483
  @mutex.synchronize do
489
484
  @transcript_buffer.clear
490
- @visual_banner_count = 0
491
485
  @transcript_viewport_rows = 0
492
486
  @stream_state.finish_block
493
487
  @stream_state.reset
@@ -11,7 +11,9 @@ module Kward
11
11
  { name: "sessions", description: "Open the saved sessions picker.", argument_hint: "[path]" },
12
12
  { name: "resume", description: "Alias for /sessions.", argument_hint: "[path]" },
13
13
  { name: "name", description: "Name or clear the current session.", argument_hint: "[name]" },
14
+ { name: "rename", description: "Rename the current session.", argument_hint: "<name>" },
14
15
  { name: "clone", description: "Clone the current session.", argument_hint: "" },
16
+ { name: "fork", description: "Fork from an earlier prompt into a new session.", argument_hint: "" },
15
17
  { name: "rewind", description: "Revisit an earlier prompt and try a different direction.", argument_hint: "" },
16
18
  { name: "tree", description: "Inspect and navigate the full technical session tree.", argument_hint: "" },
17
19
  { name: "copy", description: "Copy clean session text to the clipboard.", argument_hint: "[last|transcript]" },
@@ -21,7 +23,6 @@ module Kward
21
23
  { name: "settings", description: "Configure prompt overlays.", argument_hint: "" },
22
24
  { name: "login", description: "Log in with an OAuth provider.", argument_hint: "" },
23
25
  { name: "model", description: "Select the default model.", argument_hint: "" },
24
- { name: "openrouter/catalog", description: "List the full OpenRouter model catalog.", argument_hint: "" },
25
26
  { name: "reasoning", description: "Select reasoning effort.", argument_hint: "" },
26
27
  { name: "reload", description: "Reload installed plugins.", argument_hint: "" },
27
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
@@ -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,
@@ -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)
@@ -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 }
@@ -0,0 +1,127 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Deterministically trims large tool outputs before they are appended to the
4
+ # model-facing transcript.
5
+ #
6
+ # The original output is still handed to session/tool-execution persistence by
7
+ # ToolRegistry; this object only decides what the next model call sees. Keep it
8
+ # conservative: small outputs and short errors are more valuable verbatim than
9
+ # compacted.
10
+ class ToolOutputCompactor
11
+ MIN_BYTES = 12 * 1024
12
+ ERROR_OUTPUT_MAX_BYTES = 8 * 1024
13
+ HEAD_LINES = 40
14
+ TAIL_LINES = 40
15
+ ERROR_CONTEXT_LINES = 2
16
+
17
+ ERROR_PATTERN = /\b(error|fatal|failed|failure|exception|traceback|panic|segmentation fault|assertion)\b/i.freeze
18
+ TEST_PATTERN = /(^\s*\d+\)\s|\b(\d+\s+(tests?|examples?|runs?|assertions?|failures?|errors?|skips?)|finished in|failures?:|seed\s+\d+)\b)/i.freeze
19
+ SEARCH_PATTERN = /(^\#{1,6}\s+\S+|^[-*]\s+\S+|\S+:\d+:|https?:\/\/\S+)/.freeze
20
+
21
+ def compact(tool_name, content, artifact_id: nil)
22
+ text = normalize(content)
23
+ return text unless text.bytesize > MIN_BYTES
24
+ return text if error_output?(text) && text.bytesize <= ERROR_OUTPUT_MAX_BYTES
25
+
26
+ compacted = tool_name.to_s == "run_shell_command" ? compact_shell_output(text) : compact_lines(text)
27
+ return text if compacted == text
28
+ return text if compacted.bytesize >= text.bytesize
29
+
30
+ artifact_id = yield if artifact_id.nil? && block_given?
31
+ header = compacted_header(tool_name, text, compacted, artifact_id: artifact_id)
32
+ candidate = "#{header}\n\n#{compacted}"
33
+ candidate.bytesize < text.bytesize ? candidate : text
34
+ end
35
+
36
+ private
37
+
38
+ def normalize(content)
39
+ return content unless content.is_a?(String)
40
+
41
+ Conversation.normalize_tool_content(content)
42
+ end
43
+
44
+ def error_output?(text)
45
+ text.match?(ERROR_PATTERN)
46
+ end
47
+
48
+ def compact_shell_output(text)
49
+ sections = shell_sections(text)
50
+ return compact_lines(text) if sections.empty?
51
+
52
+ sections.map do |heading, body|
53
+ next heading if body.empty?
54
+
55
+ "#{heading}\n#{compact_lines(body)}"
56
+ end.join("\n")
57
+ end
58
+
59
+ def shell_sections(text)
60
+ parts = text.split(/\n(?=STDOUT:\n|STDERR:\n)/)
61
+ return [] if parts.length < 2
62
+
63
+ parts.map do |part|
64
+ heading, body = part.split("\n", 2)
65
+ [heading, body.to_s]
66
+ end
67
+ end
68
+
69
+ def compact_lines(text)
70
+ lines = text.split("\n", -1)
71
+ selected = selected_line_indexes(lines)
72
+ return text if selected.length >= lines.length
73
+
74
+ render_selected_lines(lines, selected)
75
+ end
76
+
77
+ def selected_line_indexes(lines)
78
+ indexes = []
79
+ indexes.concat((0...[HEAD_LINES, lines.length].min).to_a)
80
+ indexes.concat(priority_context_indexes(lines))
81
+
82
+ tail_start = [lines.length - TAIL_LINES, 0].max
83
+ indexes.concat((tail_start...lines.length).to_a)
84
+ indexes.uniq.sort
85
+ end
86
+
87
+ def priority_context_indexes(lines)
88
+ indexes = []
89
+ lines.each_with_index do |line, index|
90
+ next unless line.match?(ERROR_PATTERN) || line.match?(TEST_PATTERN) || line.match?(SEARCH_PATTERN)
91
+
92
+ first = [index - ERROR_CONTEXT_LINES, 0].max
93
+ last = [index + ERROR_CONTEXT_LINES, lines.length - 1].min
94
+ indexes.concat((first..last).to_a)
95
+ end
96
+ indexes
97
+ end
98
+
99
+ def render_selected_lines(lines, selected)
100
+ output = []
101
+ previous = nil
102
+ selected.each do |index|
103
+ if previous && index > previous + 1
104
+ output << "[... omitted lines #{previous + 2}-#{index} ...]"
105
+ end
106
+ output << lines[index]
107
+ previous = index
108
+ end
109
+ output.join("\n")
110
+ end
111
+
112
+ def compacted_header(tool_name, original, compacted, artifact_id: nil)
113
+ [
114
+ "[Tool output compacted by Kward: #{original.bytesize} bytes -> #{compacted.bytesize} bytes]",
115
+ "Tool: #{tool_name}",
116
+ "Preserved first #{HEAD_LINES} lines, last #{TAIL_LINES} lines, and error/failure/search context.",
117
+ retrieval_instruction(artifact_id)
118
+ ].join("\n")
119
+ end
120
+
121
+ def retrieval_instruction(artifact_id)
122
+ return "Full output is retained outside model context." if artifact_id.to_s.empty?
123
+
124
+ "Full output id: #{artifact_id}. Use retrieve_tool_output to inspect it."
125
+ end
126
+ end
127
+ end
@@ -24,8 +24,8 @@ module Kward
24
24
  description: @description,
25
25
  parameters: {
26
26
  type: "object",
27
- properties: @properties,
28
- required: @required,
27
+ properties: sorted_properties,
28
+ required: @required.sort,
29
29
  additionalProperties: false
30
30
  }
31
31
  }
@@ -34,6 +34,12 @@ module Kward
34
34
 
35
35
  private
36
36
 
37
+ def sorted_properties
38
+ @properties.keys.sort_by(&:to_s).each_with_object({}) do |key, result|
39
+ result[key] = @properties[key]
40
+ end
41
+ end
42
+
37
43
  # Reads a tool argument while accepting symbol or string keys from restored calls.
38
44
  def argument(args, key, default = nil)
39
45
  return args[key] if args.key?(key)
@@ -1,4 +1,5 @@
1
1
  require_relative "../config_files"
2
+ require_relative "../conversation"
2
3
  require_relative "ask_user_question"
3
4
  require_relative "code_search"
4
5
  require_relative "edit_file"
@@ -8,12 +9,16 @@ require_relative "list_directory"
8
9
  require_relative "read_file"
9
10
  require_relative "read_skill"
10
11
  require_relative "run_shell_command"
12
+ require_relative "summarize_file_structure"
13
+ require_relative "retrieve_tool_output"
11
14
  require_relative "web_search"
12
15
  require_relative "write_file"
13
16
  require_relative "search/code"
14
17
  require_relative "search/web"
15
18
  require_relative "search/web_fetch"
16
19
  require_relative "tool_call"
20
+ require_relative "../telemetry/logger"
21
+ require_relative "../tool_output_compactor"
17
22
  require_relative "../workspace"
18
23
 
19
24
  # Namespace for the Kward CLI agent runtime.
@@ -53,7 +58,7 @@ module Kward
53
58
  # @param web_search_enabled [Boolean, nil] override for web search exposure
54
59
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
55
60
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
56
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil)
61
+ def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new)
57
62
  @workspace = workspace
58
63
  @prompt = prompt
59
64
  @web_search = web_search
@@ -62,6 +67,8 @@ module Kward
62
67
  @skills = skills
63
68
  @web_search_enabled = web_search_enabled
64
69
  @ask_user_question_enabled = ask_user_question_enabled
70
+ @tool_output_compactor = tool_output_compactor
71
+ @telemetry_logger = telemetry_logger
65
72
  @tools = build_tools.freeze
66
73
  @schemas = build_schema_tools.map(&:schema).freeze
67
74
  end
@@ -87,26 +94,48 @@ module Kward
87
94
  else
88
95
  "Unknown tool: #{name}"
89
96
  end
90
-
97
+ content = Conversation.normalize_tool_content(content)
98
+ duplicate_id = conversation.tool_output_artifact_id_for(tool_name: name, content: content)
99
+ if conversation.tool_output_artifacts.key?(duplicate_id)
100
+ content = "[Same as previous tool output #{duplicate_id}; not repeated. Use retrieve_tool_output to inspect it.]"
101
+ end
102
+
103
+ artifact_id = nil
104
+ model_content = @tool_output_compactor.compact(name, content) do
105
+ artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: content)
106
+ end
107
+ log_tool_output_compaction(name, artifact_id: artifact_id, before: content, after: model_content) if model_content != content
91
108
  conversation.append_tool(
92
109
  tool_call_id: tool_call["id"] || tool_call[:id],
93
110
  name: name,
94
- content: content
111
+ content: model_content
95
112
  )
96
113
  conversation.append_tool_execution(tool_call: tool_call, content: content)
97
114
 
98
- content
115
+ model_content
99
116
  end
100
117
 
101
118
  private
102
119
 
120
+ def log_tool_output_compaction(name, artifact_id:, before:, after:)
121
+ @telemetry_logger.log(
122
+ "compaction",
123
+ "tool_output",
124
+ "tool_name" => name,
125
+ "artifact_id" => artifact_id,
126
+ "bytes_before" => before.bytesize,
127
+ "bytes_after" => after.bytesize,
128
+ "bytes_saved" => before.bytesize - after.bytesize
129
+ )
130
+ end
131
+
103
132
  def build_tools
104
133
  all_tools.to_h { |tool| [tool.name, tool] }
105
134
  end
106
135
 
107
136
  def build_schema_tools
108
137
  tools = @tools.values_at(
109
- "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search"
138
+ "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "retrieve_tool_output"
110
139
  )
111
140
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
112
141
  tools << @tools["read_skill"] if skills_available?
@@ -131,7 +160,9 @@ module Kward
131
160
  Tools::WriteFile.new(workspace: @workspace),
132
161
  Tools::EditFile.new(workspace: @workspace),
133
162
  Tools::RunShellCommand.new(workspace: @workspace),
134
- Tools::CodeSearch.new(code_search: @code_search)
163
+ Tools::CodeSearch.new(code_search: @code_search),
164
+ Tools::SummarizeFileStructure.new(workspace: @workspace),
165
+ Tools::RetrieveToolOutput.new
135
166
  ]
136
167
  end
137
168