kward 0.70.0 → 0.72.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/cli.rb CHANGED
@@ -13,6 +13,7 @@ require_relative "cli_transcript_formatter"
13
13
  require_relative "model/context_usage"
14
14
  require_relative "events"
15
15
  require_relative "export_path"
16
+ require_relative "ekwsh"
16
17
  require_relative "auth/anthropic_oauth"
17
18
  require_relative "auth/github_oauth"
18
19
  require_relative "auth/openrouter_api_key"
@@ -29,9 +30,12 @@ require_relative "model/retry_message"
29
30
  require_relative "rpc/server"
30
31
  require_relative "session_diff"
31
32
  require_relative "session_store"
33
+ require_relative "tab_store"
34
+ require_relative "session_trash"
32
35
  require_relative "session_tree_renderer"
33
36
  require_relative "starter_pack_installer"
34
37
  require_relative "steering"
38
+ require_relative "workers"
35
39
  require_relative "tools/tool_call"
36
40
  require_relative "tools/registry"
37
41
  require_relative "telemetry/stats"
@@ -41,15 +45,18 @@ require_relative "cli/auth_commands"
41
45
  require_relative "cli/doctor"
42
46
  require_relative "cli/sysprompt"
43
47
  require_relative "cli/stats"
48
+ require_relative "cli/openrouter_commands"
44
49
  require_relative "cli/runtime_helpers"
45
50
  require_relative "cli/slash_commands"
46
51
  require_relative "cli/memory_commands"
47
52
  require_relative "cli/settings"
48
53
  require_relative "cli/sessions"
54
+ require_relative "cli/tabs"
49
55
  require_relative "cli/compaction"
50
56
  require_relative "cli/rendering"
51
57
  require_relative "cli/prompt_interface"
52
58
  require_relative "cli/plugins"
59
+ require_relative "cli/git"
53
60
  require_relative "cli/interactive_turn"
54
61
  require_relative "cli/tool_summaries"
55
62
 
@@ -58,8 +65,6 @@ module Kward
58
65
  # Command-line interface for interactive chat, one-shot prompts, login,
59
66
  # telemetry export, Pan server mode, and the experimental JSON-RPC backend.
60
67
  class CLI
61
- # Order from the captain: The next line shall never be deleted.
62
- STATUS_MESSAGE = "This is a totally important status message about a non-existing status. Hi ChatGPT 👋"
63
68
  RESTORED_TOOL_OUTPUT_LIMIT = 2_000
64
69
  INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
65
70
  STREAM_RENDER_INTERVAL = 0.025
@@ -72,15 +77,18 @@ module Kward
72
77
  include CLI::Doctor
73
78
  include CLI::Sysprompt
74
79
  include CLI::Stats
80
+ include CLI::OpenRouterCommands
75
81
  include CLI::RuntimeHelpers
76
82
  include CLI::SlashCommands
77
83
  include CLI::MemoryCommands
78
84
  include CLI::Settings
79
85
  include CLI::Sessions
86
+ include CLI::Tabs
80
87
  include CLI::CompactionCommands
81
88
  include CLI::Rendering
82
89
  include CLI::PromptInterfaceSupport
83
90
  include CLI::Plugins
91
+ include CLI::GitCommands
84
92
  include CLI::InteractiveTurn
85
93
  include CLI::ToolSummaries
86
94
 
@@ -97,6 +105,11 @@ module Kward
97
105
  @plugin_registry = nil
98
106
  @working_directory = nil
99
107
  @prompt_delimited = false
108
+ @requested_mode = "auto"
109
+ @experimental_workers = false
110
+ @foreground_turn_active = false
111
+ @pending_reasoning_config = nil
112
+ @pending_reasoning_config_mutex = Mutex.new
100
113
  @color_enabled = ANSI.enabled?($stdout)
101
114
  end
102
115
 
@@ -161,6 +174,28 @@ module Kward
161
174
  return
162
175
  end
163
176
 
177
+ if @argv.first == "edit"
178
+ if help_option_arguments?(@argv[1..] || [])
179
+ print_command_help("edit")
180
+ return
181
+ end
182
+ raise ArgumentError, command_usage("edit") unless @argv.length == 2
183
+
184
+ edit_file_command(@argv[1])
185
+ return
186
+ end
187
+
188
+ if @argv.first == "count-tests"
189
+ if help_option_arguments?(@argv[1..] || [])
190
+ print_command_help("count-tests")
191
+ return
192
+ end
193
+ raise ArgumentError, command_usage("count-tests") unless @argv.length == 1
194
+
195
+ print_test_count
196
+ return
197
+ end
198
+
164
199
  if @argv.first == "sysprompt"
165
200
  if help_option_arguments?(@argv[1..] || [])
166
201
  print_command_help("sysprompt")
@@ -178,7 +213,7 @@ module Kward
178
213
  end
179
214
  raise ArgumentError, command_usage("rpc") unless @argv.length == 1
180
215
 
181
- Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
216
+ Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client, experimental_workers: @experimental_workers).run
182
217
  return
183
218
  end
184
219
 
@@ -193,6 +228,16 @@ module Kward
193
228
  return
194
229
  end
195
230
 
231
+ if @argv.first == "openrouter"
232
+ if help_option_arguments?(@argv[1..] || [])
233
+ print_command_help("openrouter")
234
+ return
235
+ end
236
+
237
+ handle_openrouter_command(@argv[1..] || [])
238
+ return
239
+ end
240
+
196
241
  if pan_mode?
197
242
  if help_option_arguments?(@argv[1..] || [])
198
243
  print_command_help("pan")
@@ -218,53 +263,107 @@ module Kward
218
263
  run_prompt_or_interactive
219
264
  end
220
265
 
266
+ def edit_file_command(path)
267
+ setup_interactive_prompt
268
+ unless @prompt.respond_to?(:edit_file)
269
+ raise ArgumentError, "The integrated editor requires an interactive terminal."
270
+ end
271
+
272
+ @prompt.edit_file(path, base_dir: Dir.pwd, allow_new: true)
273
+ ensure
274
+ @prompt.close if @prompt.respond_to?(:close) && prompt_interface?
275
+ end
276
+
221
277
  def run_prompt_or_interactive
278
+ stdin_input = read_stdin_input
222
279
  first_prompt = one_shot_prompt_argument
223
- if first_prompt
224
- answer = one_shot(first_prompt)
225
- puts answer unless answer.empty?
226
- return
227
- end
228
280
 
229
- stdin_prompt = piped_prompt
230
- unless stdin_prompt.empty?
231
- answer = one_shot(stdin_prompt)
281
+ case resolved_execution_mode(first_prompt: first_prompt, stdin_input: stdin_input)
282
+ when "chat"
283
+ interactive_loop
284
+ when "filter"
285
+ raise ArgumentError, "Filter mode requires stdin input." if stdin_input.nil?
286
+
287
+ answer = one_shot(filter_prompt(instruction: first_prompt, input: stdin_input), filter: true)
288
+ puts answer unless answer.empty?
289
+ when "oneshot"
290
+ input = first_prompt || stdin_input.to_s.strip
291
+ answer = one_shot(input)
232
292
  puts answer unless answer.empty?
233
- return
234
293
  end
235
-
236
- interactive_loop
237
294
  end
238
295
 
239
- def one_shot(input)
296
+ def one_shot(input, filter: false)
240
297
  streamed = false
241
298
  assistant_streamed = false
242
299
  markdown_chunks = []
243
300
  conversation = new_conversation
301
+ apply_filter_system_prompt(conversation) if filter
244
302
  agent = Agent.new(
245
303
  client: @client,
246
304
  tool_registry: ToolRegistry.new(workspace: configured_workspace, prompt: @prompt),
247
305
  conversation: conversation
248
306
  )
249
- answer = agent.ask(input) do |event|
250
- result = render_blocking_turn_event(event, markdown_chunks)
251
- streamed = true if result
252
- assistant_streamed = true if result == :assistant_streamed
307
+ answer = if filter
308
+ agent.ask(input)
309
+ else
310
+ agent.ask(input) do |event|
311
+ result = render_blocking_turn_event(event, markdown_chunks)
312
+ streamed = true if result
313
+ assistant_streamed = true if result == :assistant_streamed
314
+ end
253
315
  end
254
316
  flush_markdown_deltas(markdown_chunks) if streamed
317
+ return answer if filter
318
+
255
319
  assistant_streamed ? "" : render_markdown_transcript(answer)
256
320
  end
257
321
 
322
+ def resolved_execution_mode(first_prompt:, stdin_input:)
323
+ return @requested_mode unless @requested_mode == "auto"
324
+ return "chat" if stdin_input.nil? && first_prompt.nil?
325
+ return "filter" if !stdin_input.nil? && first_prompt
258
326
 
327
+ "oneshot"
328
+ end
259
329
 
330
+ def filter_prompt(instruction:, input:)
331
+ <<~PROMPT
332
+ Instruction:
333
+ #{instruction}
260
334
 
335
+ Input:
336
+ #{input}
337
+ PROMPT
338
+ end
339
+
340
+ def apply_filter_system_prompt(conversation)
341
+ return unless conversation.system_message
261
342
 
343
+ conversation.system_message[:content] = [conversation.system_message[:content], filter_system_prompt].compact.join("\n\n")
344
+ end
345
+
346
+ def filter_system_prompt
347
+ <<~PROMPT.strip
348
+ You are being used as a command-line text filter.
349
+
350
+ Transform the provided input according to the user's instruction.
351
+ Return only the transformed output.
352
+
353
+ Do not include explanations, introductions, summaries, Markdown fences, or commentary.
354
+ Do not say what you changed.
355
+ Preserve the input format unless the instruction requires changing it.
356
+ If the input is code, data, markup, or configuration, output only the resulting code/data/markup/configuration.
357
+ PROMPT
358
+ end
262
359
 
263
360
  def interactive_loop(agent: nil)
264
361
  setup_interactive_prompt
265
362
  session_store = interactive_session_store(agent)
266
363
  @resumed_last_session = false
267
- if session_store && agent.nil?
364
+ if session_store && @prompt.respond_to?(:update_tabs)
365
+ agent = setup_interactive_tabs(session_store, agent)
366
+ elsif session_store && agent.nil?
268
367
  agent = resume_last_session(session_store) || build_new_session_agent(session_store)
269
368
  elsif session_store
270
369
  @active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
@@ -277,13 +376,29 @@ module Kward
277
376
  update_assistant_prompt(agent.conversation)
278
377
  @footer_conversation = agent.conversation
279
378
 
280
- print_visual_banner unless @resumed_last_session
379
+ print_visual_banner unless @resumed_last_session || @restored_tabs
281
380
  render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
282
381
 
283
382
  @pending_inputs = []
284
383
 
285
384
  loop do
286
- input = @pending_inputs.shift || @prompt.ask("You>")
385
+ if @pending_inputs.empty? && active_tab&.shell
386
+ run_ekwsh_loop(active_tab.shell, tab: active_tab)
387
+ end
388
+ input = @pending_inputs.shift || (active_tab ? poll_active_tab_input : @prompt.ask("You>"))
389
+ if input.is_a?(Hash) && input[:tab_action]
390
+ tab_result = handle_tab_action(input, session_store)
391
+ break if tab_result == PromptInterface::EXIT_INPUT
392
+ agent = active_tab.agent if active_tab
393
+ next
394
+ end
395
+ if input.is_a?(Hash) && input[:reasoning_action]
396
+ conversation = active_tab ? active_tab.agent.conversation : agent.conversation
397
+ cycle_reasoning(conversation, direction: input[:reasoning_action], persist: :debounced)
398
+ agent = active_tab.agent if active_tab
399
+ next
400
+ end
401
+ next if input == :tab_idle
287
402
  break if input.nil?
288
403
 
289
404
  display_input = submitted_display_input(input)
@@ -301,31 +416,57 @@ module Kward
301
416
  end
302
417
  break if ["/exit", "/quit"].include?(command)
303
418
  handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
304
- agent = replacement_agent if replacement_agent
419
+ if replacement_agent?(replacement_agent)
420
+ agent = active_tab ? replace_active_tab_agent(replacement_agent) : replacement_agent
421
+ end
305
422
  end
306
423
  next if handled
424
+ request_handled, request_replacement = handle_request_worker_input(command_input, agent, session_store)
425
+ if request_handled
426
+ if replacement_agent?(request_replacement)
427
+ agent = active_tab ? replace_active_tab_agent(request_replacement) : request_replacement
428
+ end
429
+ next
430
+ end
307
431
  next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
308
432
 
433
+ flush_pending_reasoning_config(conversation: agent.conversation)
309
434
  expanded_input = expand_prompt_template(input)
310
435
  display_input = display_input || input if expanded_input
311
436
  input = expanded_input || input
437
+ agent = refresh_implementation_writer(agent)
312
438
  @footer_conversation = agent.conversation
313
439
  begin
314
440
  @rewind_return_leaf_id = nil
315
441
  auto_name_active_session(display_input || input)
316
- pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
442
+ @foreground_turn_active = true if @active_worker_role == "implementation"
443
+ if active_tab
444
+ submit_tab_input(active_tab, input, display_input: display_input)
445
+ pending_inputs = []
446
+ else
447
+ pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
448
+ agent = @busy_replacement_agent if replacement_agent?(@busy_replacement_agent)
449
+ @busy_replacement_agent = nil
450
+ end
317
451
  pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
318
452
  rescue StandardError => e
319
453
  runtime_output("Error: #{e.message}")
454
+ ensure
455
+ @foreground_turn_active = false if @active_worker_role == "implementation"
456
+ release_implementation_writer if @active_worker_role == "implementation"
320
457
  end
321
458
  end
322
459
 
460
+ flush_pending_reasoning_config(conversation: agent.conversation)
323
461
  agent.conversation
324
462
  rescue Interrupt
463
+ flush_pending_reasoning_config(conversation: agent&.conversation)
325
464
  runtime_output("Goodbye.")
326
465
  agent&.conversation
327
466
  ensure
328
467
  begin
468
+ stop_tabs if respond_to?(:stop_tabs, true)
469
+ stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
329
470
  @prompt.close if prompt_interface?
330
471
  ensure
331
472
  cleanup_unused_sessions
@@ -334,9 +475,13 @@ module Kward
334
475
  end
335
476
 
336
477
  def piped_prompt
337
- return "" if @stdin.tty?
478
+ read_stdin_input.to_s.strip
479
+ end
480
+
481
+ def read_stdin_input
482
+ return nil if @stdin.tty?
338
483
 
339
- @stdin.read.strip
484
+ @stdin.read
340
485
  end
341
486
 
342
487
  end
@@ -665,7 +665,10 @@ module Kward
665
665
  ## Critical Context
666
666
  - [Preserve important context, add new context needed to continue]
667
667
 
668
- Keep each section concise. Preserve exact file paths, class names, module names, method names, constants, commands, spec names, migration names, error messages, user requirements, and unresolved problems. Do not invent work that did not happen.
668
+ ## Available Tool Artifacts
669
+ - [Preserve any toolout_* ids from compacted tool outputs, with what each id contains and why it may matter]
670
+
671
+ Keep each section concise. Preserve exact file paths, class names, module names, method names, constants, commands, spec names, migration names, error messages, toolout_* artifact ids, user requirements, and unresolved problems. Do not invent work that did not happen.
669
672
  PROMPT
670
673
 
671
674
  SPLIT_TURN_PROMPT = <<~PROMPT.strip.freeze
@@ -1,7 +1,9 @@
1
+ require "digest"
1
2
  require "fileutils"
2
3
  require "json"
3
4
  require "yaml"
4
5
  require_relative "private_file"
6
+ require_relative "editor_mode"
5
7
  require_relative "prompts/templates"
6
8
  require_relative "skills/registry"
7
9
 
@@ -64,6 +66,10 @@ module Kward
64
66
  File.join(config_dir, "cache")
65
67
  end
66
68
 
69
+ def ekwsh_config_path
70
+ File.join(config_dir, "ekwsh.yml")
71
+ end
72
+
67
73
  def default_config
68
74
  {
69
75
  "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
@@ -72,7 +78,16 @@ module Kward
72
78
  "auto_summary" => false
73
79
  },
74
80
  "composer" => {
75
- "busy_help" => true
81
+ "busy_help" => true,
82
+ "tab_keybindings" => "auto"
83
+ },
84
+ "editor" => {
85
+ "mode" => "modern",
86
+ "auto_indent" => true,
87
+ "auto_close_pairs" => true,
88
+ "soft_wrap" => true,
89
+ "bar_cursor" => true,
90
+ "line_numbers" => "absolute"
76
91
  },
77
92
  "sessions" => {
78
93
  "auto_resume" => false
@@ -97,6 +112,19 @@ module Kward
97
112
  File.join(cache_dir, "code_search")
98
113
  end
99
114
 
115
+ def openrouter_models_cache_path
116
+ File.join(cache_dir, "openrouter_models.json")
117
+ end
118
+
119
+ def project_browser_state_path
120
+ File.join(cache_dir, "project_browser_state.json")
121
+ end
122
+
123
+ def prompt_history_path(cwd, config_dir: self.config_dir)
124
+ key = Digest::SHA256.hexdigest(canonical_workspace_root(cwd))[0, 24]
125
+ File.join(config_dir, "history", "#{key}.jsonl")
126
+ end
127
+
100
128
  # @return [String] directory containing structured memory files
101
129
  def memory_dir
102
130
  File.join(config_dir, "memory")
@@ -140,6 +168,50 @@ module Kward
140
168
  PrivateFile.write_json(path, config)
141
169
  end
142
170
 
171
+ def read_ekwsh_config(path = ekwsh_config_path)
172
+ path = File.expand_path(path)
173
+ return { env: {}, aliases: {} } unless File.exist?(path)
174
+
175
+ data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
176
+ normalize_ekwsh_config(data)
177
+ rescue Psych::SyntaxError => e
178
+ raise "Invalid ekwsh YAML config: #{path}: #{e.message}"
179
+ end
180
+
181
+ def normalize_ekwsh_config(data)
182
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
183
+ settings = data.is_a?(Hash) ? data : {}
184
+ {
185
+ env: normalize_ekwsh_env(settings["env"]),
186
+ aliases: normalize_ekwsh_aliases(settings["aliases"])
187
+ }
188
+ end
189
+
190
+ def normalize_ekwsh_env(values)
191
+ return {} unless values.is_a?(Hash)
192
+
193
+ values.each_with_object({}) do |(key, value), result|
194
+ key = key.to_s
195
+ next unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
196
+ next if value.nil?
197
+
198
+ result[key] = value.to_s
199
+ end
200
+ end
201
+
202
+ def normalize_ekwsh_aliases(values)
203
+ return {} unless values.is_a?(Hash)
204
+
205
+ values.each_with_object({}) do |(name, command), result|
206
+ name = name.to_s
207
+ command = command.to_s.strip
208
+ next unless name.match?(/\A[A-Za-z0-9_.:-]+\z/)
209
+ next if command.empty?
210
+
211
+ result[name] = command
212
+ end
213
+ end
214
+
143
215
  # Merges top-level config values and writes the updated config privately.
144
216
  def update_config(values, path = config_path)
145
217
  raise "Config values must be an object" unless values.is_a?(Hash)
@@ -150,6 +222,17 @@ module Kward
150
222
  config
151
223
  end
152
224
 
225
+ # Merges values into a one-level nested config section and writes privately.
226
+ def update_nested_config(section, values, path = config_path)
227
+ raise "Config values must be an object" unless values.is_a?(Hash)
228
+
229
+ config = read_config(path)
230
+ current = config[section.to_s].is_a?(Hash) ? config[section.to_s].dup : {}
231
+ config[section.to_s] = current.merge(values.transform_keys(&:to_s))
232
+ write_config(config, path)
233
+ config
234
+ end
235
+
153
236
  # Removes a top-level config key when it exists.
154
237
  def delete_config_key(key, path = config_path)
155
238
  config = read_config(path)
@@ -189,10 +272,47 @@ module Kward
189
272
  composer["busy_help"] != false
190
273
  end
191
274
 
192
- # Returns whether the terminal startup banner should be displayed.
193
- def banner_enabled?(config = read_config)
194
- banner = config["banner"].is_a?(Hash) ? config["banner"] : {}
195
- banner["enabled"] != false
275
+ # Returns the configured tab keybinding family, or auto when unset/invalid.
276
+ def composer_tab_keybindings(config = read_config)
277
+ composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
278
+ value = composer["tab_keybindings"].to_s.downcase
279
+ %w[auto ctrl alt].include?(value) ? value : "auto"
280
+ end
281
+
282
+ # Returns the built-in TUI editor keymap mode.
283
+ def editor_mode(config = read_config)
284
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
285
+ EditorMode.normalize(editor["mode"])
286
+ end
287
+
288
+ # Returns whether the built-in TUI editor should auto-indent new lines.
289
+ def editor_auto_indent?(config = read_config)
290
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
291
+ editor["auto_indent"] != false
292
+ end
293
+
294
+ # Returns whether the built-in TUI editor should auto-close typed pairs.
295
+ def editor_auto_close_pairs?(config = read_config)
296
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
297
+ editor["auto_close_pairs"] != false
298
+ end
299
+
300
+ # Returns whether the built-in TUI editor should soft-wrap long lines.
301
+ def editor_soft_wrap?(config = read_config)
302
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
303
+ editor["soft_wrap"] != false
304
+ end
305
+
306
+ # Returns whether editable built-in TUI editor buffers should use a bar cursor.
307
+ def editor_bar_cursor?(config = read_config)
308
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
309
+ editor["bar_cursor"] != false
310
+ end
311
+
312
+ # Returns the built-in TUI editor line-number display mode.
313
+ def editor_line_numbers(config = read_config)
314
+ editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
315
+ EditorMode.normalize_line_numbers(editor["line_numbers"])
196
316
  end
197
317
 
198
318
  # Returns whether file tools must stay inside the active workspace root.
@@ -0,0 +1,44 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Tracks approximate context bytes saved by tool budgeting during one process.
4
+ class ContextBudgetMeter
5
+ Snapshot = Struct.new(:calls, :original_bytes, :returned_bytes, :saved_bytes, :tool_breakdown, keyword_init: true)
6
+
7
+ def initialize
8
+ @mutex = Mutex.new
9
+ @calls = 0
10
+ @original_bytes = 0
11
+ @returned_bytes = 0
12
+ @tool_breakdown = Hash.new { |hash, key| hash[key] = { calls: 0, originalBytes: 0, returnedBytes: 0, savedBytes: 0 } }
13
+ end
14
+
15
+ def record(tool_name:, original_bytes:, returned_bytes:)
16
+ original_bytes = original_bytes.to_i
17
+ returned_bytes = returned_bytes.to_i
18
+ saved_bytes = [original_bytes - returned_bytes, 0].max
19
+ @mutex.synchronize do
20
+ @calls += 1
21
+ @original_bytes += original_bytes
22
+ @returned_bytes += returned_bytes
23
+ entry = @tool_breakdown[tool_name.to_s]
24
+ entry[:calls] += 1
25
+ entry[:originalBytes] += original_bytes
26
+ entry[:returnedBytes] += returned_bytes
27
+ entry[:savedBytes] += saved_bytes
28
+ end
29
+ saved_bytes
30
+ end
31
+
32
+ def snapshot
33
+ @mutex.synchronize do
34
+ Snapshot.new(
35
+ calls: @calls,
36
+ original_bytes: @original_bytes,
37
+ returned_bytes: @returned_bytes,
38
+ saved_bytes: [@original_bytes - @returned_bytes, 0].max,
39
+ tool_breakdown: @tool_breakdown.transform_values(&:dup)
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end