kward 0.71.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
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,10 +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"
32
34
  require_relative "session_trash"
33
35
  require_relative "session_tree_renderer"
34
36
  require_relative "starter_pack_installer"
35
37
  require_relative "steering"
38
+ require_relative "workers"
36
39
  require_relative "tools/tool_call"
37
40
  require_relative "tools/registry"
38
41
  require_relative "telemetry/stats"
@@ -48,10 +51,12 @@ require_relative "cli/slash_commands"
48
51
  require_relative "cli/memory_commands"
49
52
  require_relative "cli/settings"
50
53
  require_relative "cli/sessions"
54
+ require_relative "cli/tabs"
51
55
  require_relative "cli/compaction"
52
56
  require_relative "cli/rendering"
53
57
  require_relative "cli/prompt_interface"
54
58
  require_relative "cli/plugins"
59
+ require_relative "cli/git"
55
60
  require_relative "cli/interactive_turn"
56
61
  require_relative "cli/tool_summaries"
57
62
 
@@ -60,8 +65,6 @@ module Kward
60
65
  # Command-line interface for interactive chat, one-shot prompts, login,
61
66
  # telemetry export, Pan server mode, and the experimental JSON-RPC backend.
62
67
  class CLI
63
- # Order from the captain: The next line shall never be deleted.
64
- STATUS_MESSAGE = "This is a totally important status message about a non-existing status. Hi ChatGPT 👋"
65
68
  RESTORED_TOOL_OUTPUT_LIMIT = 2_000
66
69
  INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
67
70
  STREAM_RENDER_INTERVAL = 0.025
@@ -80,10 +83,12 @@ module Kward
80
83
  include CLI::MemoryCommands
81
84
  include CLI::Settings
82
85
  include CLI::Sessions
86
+ include CLI::Tabs
83
87
  include CLI::CompactionCommands
84
88
  include CLI::Rendering
85
89
  include CLI::PromptInterfaceSupport
86
90
  include CLI::Plugins
91
+ include CLI::GitCommands
87
92
  include CLI::InteractiveTurn
88
93
  include CLI::ToolSummaries
89
94
 
@@ -100,6 +105,11 @@ module Kward
100
105
  @plugin_registry = nil
101
106
  @working_directory = nil
102
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
103
113
  @color_enabled = ANSI.enabled?($stdout)
104
114
  end
105
115
 
@@ -164,6 +174,28 @@ module Kward
164
174
  return
165
175
  end
166
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
+
167
199
  if @argv.first == "sysprompt"
168
200
  if help_option_arguments?(@argv[1..] || [])
169
201
  print_command_help("sysprompt")
@@ -181,7 +213,7 @@ module Kward
181
213
  end
182
214
  raise ArgumentError, command_usage("rpc") unless @argv.length == 1
183
215
 
184
- 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
185
217
  return
186
218
  end
187
219
 
@@ -231,53 +263,107 @@ module Kward
231
263
  run_prompt_or_interactive
232
264
  end
233
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
+
234
277
  def run_prompt_or_interactive
278
+ stdin_input = read_stdin_input
235
279
  first_prompt = one_shot_prompt_argument
236
- if first_prompt
237
- answer = one_shot(first_prompt)
238
- puts answer unless answer.empty?
239
- return
240
- end
241
280
 
242
- stdin_prompt = piped_prompt
243
- unless stdin_prompt.empty?
244
- 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)
245
292
  puts answer unless answer.empty?
246
- return
247
293
  end
248
-
249
- interactive_loop
250
294
  end
251
295
 
252
- def one_shot(input)
296
+ def one_shot(input, filter: false)
253
297
  streamed = false
254
298
  assistant_streamed = false
255
299
  markdown_chunks = []
256
300
  conversation = new_conversation
301
+ apply_filter_system_prompt(conversation) if filter
257
302
  agent = Agent.new(
258
303
  client: @client,
259
304
  tool_registry: ToolRegistry.new(workspace: configured_workspace, prompt: @prompt),
260
305
  conversation: conversation
261
306
  )
262
- answer = agent.ask(input) do |event|
263
- result = render_blocking_turn_event(event, markdown_chunks)
264
- streamed = true if result
265
- 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
266
315
  end
267
316
  flush_markdown_deltas(markdown_chunks) if streamed
317
+ return answer if filter
318
+
268
319
  assistant_streamed ? "" : render_markdown_transcript(answer)
269
320
  end
270
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
271
326
 
327
+ "oneshot"
328
+ end
329
+
330
+ def filter_prompt(instruction:, input:)
331
+ <<~PROMPT
332
+ Instruction:
333
+ #{instruction}
334
+
335
+ Input:
336
+ #{input}
337
+ PROMPT
338
+ end
339
+
340
+ def apply_filter_system_prompt(conversation)
341
+ return unless conversation.system_message
272
342
 
343
+ conversation.system_message[:content] = [conversation.system_message[:content], filter_system_prompt].compact.join("\n\n")
344
+ end
273
345
 
346
+ def filter_system_prompt
347
+ <<~PROMPT.strip
348
+ You are being used as a command-line text filter.
274
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
275
359
 
276
360
  def interactive_loop(agent: nil)
277
361
  setup_interactive_prompt
278
362
  session_store = interactive_session_store(agent)
279
363
  @resumed_last_session = false
280
- 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?
281
367
  agent = resume_last_session(session_store) || build_new_session_agent(session_store)
282
368
  elsif session_store
283
369
  @active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
@@ -290,13 +376,29 @@ module Kward
290
376
  update_assistant_prompt(agent.conversation)
291
377
  @footer_conversation = agent.conversation
292
378
 
293
- print_visual_banner unless @resumed_last_session
379
+ print_visual_banner unless @resumed_last_session || @restored_tabs
294
380
  render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
295
381
 
296
382
  @pending_inputs = []
297
383
 
298
384
  loop do
299
- 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
300
402
  break if input.nil?
301
403
 
302
404
  display_input = submitted_display_input(input)
@@ -314,31 +416,57 @@ module Kward
314
416
  end
315
417
  break if ["/exit", "/quit"].include?(command)
316
418
  handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
317
- 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
318
422
  end
319
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
320
431
  next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
321
432
 
433
+ flush_pending_reasoning_config(conversation: agent.conversation)
322
434
  expanded_input = expand_prompt_template(input)
323
435
  display_input = display_input || input if expanded_input
324
436
  input = expanded_input || input
437
+ agent = refresh_implementation_writer(agent)
325
438
  @footer_conversation = agent.conversation
326
439
  begin
327
440
  @rewind_return_leaf_id = nil
328
441
  auto_name_active_session(display_input || input)
329
- 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
330
451
  pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
331
452
  rescue StandardError => e
332
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"
333
457
  end
334
458
  end
335
459
 
460
+ flush_pending_reasoning_config(conversation: agent.conversation)
336
461
  agent.conversation
337
462
  rescue Interrupt
463
+ flush_pending_reasoning_config(conversation: agent&.conversation)
338
464
  runtime_output("Goodbye.")
339
465
  agent&.conversation
340
466
  ensure
341
467
  begin
468
+ stop_tabs if respond_to?(:stop_tabs, true)
469
+ stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
342
470
  @prompt.close if prompt_interface?
343
471
  ensure
344
472
  cleanup_unused_sessions
@@ -347,9 +475,13 @@ module Kward
347
475
  end
348
476
 
349
477
  def piped_prompt
350
- 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?
351
483
 
352
- @stdin.read.strip
484
+ @stdin.read
353
485
  end
354
486
 
355
487
  end
@@ -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
@@ -101,6 +116,15 @@ module Kward
101
116
  File.join(cache_dir, "openrouter_models.json")
102
117
  end
103
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
+
104
128
  # @return [String] directory containing structured memory files
105
129
  def memory_dir
106
130
  File.join(config_dir, "memory")
@@ -144,6 +168,50 @@ module Kward
144
168
  PrivateFile.write_json(path, config)
145
169
  end
146
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
+
147
215
  # Merges top-level config values and writes the updated config privately.
148
216
  def update_config(values, path = config_path)
149
217
  raise "Config values must be an object" unless values.is_a?(Hash)
@@ -154,6 +222,17 @@ module Kward
154
222
  config
155
223
  end
156
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
+
157
236
  # Removes a top-level config key when it exists.
158
237
  def delete_config_key(key, path = config_path)
159
238
  config = read_config(path)
@@ -193,6 +272,49 @@ module Kward
193
272
  composer["busy_help"] != false
194
273
  end
195
274
 
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"])
316
+ end
317
+
196
318
  # Returns whether file tools must stay inside the active workspace root.
197
319
  def workspace_guardrails_enabled?(config = read_config)
198
320
  tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
@@ -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
@@ -1,5 +1,6 @@
1
1
  require "digest"
2
2
  require "set"
3
+ require_relative "context_budget_meter"
3
4
  require_relative "image_attachments"
4
5
  require_relative "message_access"
5
6
  require_relative "plugin_registry"
@@ -60,6 +61,8 @@ module Kward
60
61
  attr_reader :last_plugin_prompt_context
61
62
  # @return [Hash] original large tool outputs retained outside model context
62
63
  attr_reader :tool_output_artifacts
64
+ # @return [ContextBudgetMeter] runtime context savings for this conversation
65
+ attr_reader :context_budget_meter
63
66
 
64
67
  def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
65
68
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
@@ -89,6 +92,7 @@ module Kward
89
92
  @session_memories = Array(session_memories)
90
93
  @last_memory_retrieval = last_memory_retrieval
91
94
  @tool_output_artifacts = {}
95
+ @context_budget_meter = ContextBudgetMeter.new
92
96
  @messages.concat(transcript_messages)
93
97
  @read_paths = Set.new(read_paths)
94
98
  @on_append = on_append
@@ -146,6 +150,10 @@ module Kward
146
150
  end
147
151
 
148
152
  def store_tool_output_artifact(tool_name:, content:)
153
+ restore_tool_output_artifact(tool_name: tool_name, content: content, created_at: Time.now.utc)
154
+ end
155
+
156
+ def restore_tool_output_artifact(tool_name:, content:, created_at: nil)
149
157
  text = self.class.normalize_tool_content(content)
150
158
  id = tool_output_artifact_id_for(tool_name: tool_name, content: text)
151
159
  @tool_output_artifacts[id] = {
@@ -153,7 +161,7 @@ module Kward
153
161
  tool_name: tool_name,
154
162
  content: text,
155
163
  bytes: text.bytesize,
156
- created_at: Time.now.utc
164
+ created_at: created_at || Time.now.utc
157
165
  }
158
166
  id
159
167
  end
@@ -186,11 +194,11 @@ module Kward
186
194
  replacement
187
195
  end
188
196
 
189
- def update_runtime_context!(provider: nil, model:, reasoning_effort:)
197
+ def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true)
190
198
  @provider = provider unless provider.to_s.empty?
191
199
  @model = model
192
200
  @reasoning_effort = reasoning_effort
193
- refresh_system_message!
201
+ refresh_system_message! if refresh
194
202
  end
195
203
 
196
204
  def persist_runtime_context!
@@ -270,7 +278,7 @@ module Kward
270
278
  end
271
279
 
272
280
  def prompt_time
273
- Time.at(0)
281
+ Time.now
274
282
  end
275
283
 
276
284
  def workspace_agents_mtime
@@ -0,0 +1,25 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Normalizes built-in TUI file editor mode names.
4
+ module EditorMode
5
+ MODES = %w[modern emacs vibe].freeze
6
+ DEFAULT = "modern".freeze
7
+ LINE_NUMBER_MODES = %w[absolute relative].freeze
8
+ DEFAULT_LINE_NUMBERS = "absolute".freeze
9
+
10
+ module_function
11
+
12
+ def normalize(value)
13
+ text = value.to_s.downcase
14
+ return DEFAULT if text == "default"
15
+ return "vibe" if text == "vi"
16
+
17
+ MODES.include?(text) ? text : DEFAULT
18
+ end
19
+
20
+ def normalize_line_numbers(value)
21
+ text = value.to_s.downcase
22
+ LINE_NUMBER_MODES.include?(text) ? text : DEFAULT_LINE_NUMBERS
23
+ end
24
+ end
25
+ end