kward 0.71.0 → 0.73.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/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -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 +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -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 +288 -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 +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -18,6 +18,7 @@ require_relative "../model/model_info"
18
18
  require_relative "../plugin_registry"
19
19
  require_relative "../prompts/commands"
20
20
  require_relative "../session_store"
21
+ require_relative "../session_naming"
21
22
  require_relative "../session_trash"
22
23
  require_relative "../steering"
23
24
  require_relative "../tools/tool_call"
@@ -26,6 +27,7 @@ require_relative "../transcript_export"
26
27
  require_relative "../workspace"
27
28
  require_relative "attachment_normalizer"
28
29
  require_relative "config_manager"
30
+ require_relative "memory_methods"
29
31
  require_relative "prompt_bridge"
30
32
  require_relative "runtime_payloads"
31
33
  require_relative "session_metrics"
@@ -51,6 +53,8 @@ module Kward
51
53
  # `ToolRegistry`. This class should coordinate those pieces rather than own
52
54
  # their low-level mechanics.
53
55
  class SessionManager
56
+ include MemoryMethods
57
+
54
58
  RECENT_EVENT_LIMIT = 1_000
55
59
  RPC_ATTACHMENT_MAX_BYTES = AttachmentNormalizer::MAX_BYTES
56
60
  RPC_IMAGE_MIME_TYPES = AttachmentNormalizer::IMAGE_MIME_TYPES
@@ -295,12 +299,21 @@ module Kward
295
299
  rpc_sessions = @mutex.synchronize { @sessions.values.dup }
296
300
  rpc_sessions.reverse_each do |rpc_session|
297
301
  next unless session_idle?(rpc_session)
302
+ next unless rpc_session.session.respond_to?(:delete_if_unused)
303
+ next unless rpc_session.session.delete_if_unused
298
304
 
299
- close_rpc_session(rpc_session)
305
+ remove_live_session(rpc_session)
300
306
  end
301
307
  { closed: true }
302
308
  end
303
309
 
310
+ # Stops all live RPC session workers during server shutdown.
311
+ def shutdown_sessions
312
+ rpc_sessions = @mutex.synchronize { @sessions.values.dup }
313
+ rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session) if session_idle?(rpc_session) }
314
+ { closed: true }
315
+ end
316
+
304
317
  # Returns the normalized transcript for the active RPC session.
305
318
  def transcript(session_id:)
306
319
  rpc_session = fetch_session(session_id)
@@ -321,10 +334,7 @@ module Kward
321
334
  content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
322
335
  streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
323
336
  if streaming_behavior == "steer"
324
- steered_turn = steer_running_turn(rpc_session, content)
325
- return steered_turn if steered_turn
326
-
327
- streaming_behavior = "followUp"
337
+ return steer_running_turn(rpc_session, content)
328
338
  end
329
339
  turn = Turn.new(
330
340
  id: SecureRandom.uuid,
@@ -385,89 +395,11 @@ module Kward
385
395
  run_plugin_command(session_id: session_id, command: name, arguments: arguments)
386
396
  end
387
397
 
388
- def memory_manager
389
- Memory::Manager.for_config_dir(@config_dir)
390
- end
391
-
392
- def memory_status
393
- manager = memory_manager
394
- { enabled: manager.enabled?, autoSummary: manager.auto_summary_enabled?, paths: manager.paths }
395
- end
396
-
397
- def memory_enable
398
- memory_manager.enable
399
- { enabled: true }
400
- end
401
-
402
- def memory_disable
403
- memory_manager.disable
404
- { enabled: false }
405
- end
406
-
407
- def memory_auto_summary_enable
408
- memory_manager.auto_summary_enable
409
- { autoSummary: true }
410
- end
411
-
412
- def memory_auto_summary_disable
413
- memory_manager.auto_summary_disable
414
- { autoSummary: false }
415
- end
416
-
417
- def memory_list(include_inactive: false, workspace_root: Dir.pwd)
418
- memory_manager.hierarchy(include_inactive: include_inactive, workspace_root: workspace_root)
419
- end
420
-
421
- def memory_add(text:, scope: nil, tags: [])
422
- { memory: memory_manager.add_soft(text, scope: scope || "global", tags: tags) }
423
- end
424
-
425
- def memory_add_core(text:, scope: nil, tags: [])
426
- { memory: memory_manager.add_core(text, scope: scope || "global", tags: tags) }
427
- end
428
-
429
- def memory_forget(id:)
430
- { forgotten: memory_manager.forget_memory(id) }
431
- end
432
-
433
- def memory_promote(id:)
434
- { memory: memory_manager.promote_memory(id) }
435
- end
436
-
437
- def memory_relax(id:, workspace_root: Dir.pwd)
438
- { memory: memory_manager.relax_core(id, workspace_root: workspace_root) }
439
- end
440
-
441
- def memory_inspect
442
- memory_manager.inspect_memory
443
- end
444
-
445
- def memory_why(session_id: nil)
446
- if session_id
447
- rpc_session = fetch_session(session_id)
448
- return rpc_session.conversation.last_memory_retrieval || memory_manager.explain_retrieval
449
- end
450
- memory_manager.explain_retrieval
451
- end
452
-
453
- def memory_summarize(session_id:)
454
- rpc_session = fetch_session(session_id)
455
- records = memory_manager.summarize_conversation(rpc_session.conversation, client: @client)
456
- persist_memory_state(rpc_session)
457
- { memories: records }
458
- end
459
-
460
398
  def run_plugin_command(session_id:, command:, arguments: "")
461
399
  rpc_session = fetch_session(session_id)
462
400
  command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
463
401
  output = []
464
- context = PluginRegistry::Context.new(
465
- conversation: rpc_session.conversation,
466
- args: arguments.to_s,
467
- session: rpc_session.session,
468
- workspace_root: rpc_session.workspace_root,
469
- say_callback: lambda { |message| output << message.to_s }
470
- )
402
+ context = plugin_context(rpc_session, args: arguments.to_s, say_callback: lambda { |message| output << message.to_s })
471
403
  result = command.handler.call(arguments.to_s, context)
472
404
  output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
473
405
  { command: command.name, output: output, result: result.nil? ? nil : result.to_s }
@@ -613,11 +545,15 @@ module Kward
613
545
  end
614
546
 
615
547
  def refresh_session_runtime_contexts
548
+ provider = current_model[:provider]
616
549
  model = current_model_id
617
550
  reasoning_effort = current_reasoning_effort
618
551
  sessions = @mutex.synchronize { @sessions.values }
619
552
  sessions.each do |rpc_session|
620
- rpc_session.conversation.update_runtime_context!(provider: current_model[:provider], model: model, reasoning_effort: reasoning_effort)
553
+ conversation = rpc_session.conversation
554
+ runtime_changed = [conversation.provider, conversation.model, conversation.reasoning_effort] != [provider, model, reasoning_effort]
555
+ conversation.update_runtime_context!(provider: provider, model: model, reasoning_effort: reasoning_effort)
556
+ conversation.persist_runtime_context! if runtime_changed
621
557
  end
622
558
  end
623
559
 
@@ -709,10 +645,6 @@ module Kward
709
645
  pending_turn_count(rpc_session.id).zero?
710
646
  end
711
647
 
712
- def active_session_count(workspace_root)
713
- @mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
714
- end
715
-
716
648
  def tool_calls(message)
717
649
  MessageAccess.tool_calls(message)
718
650
  end
@@ -721,10 +653,6 @@ module Kward
721
653
  MessageAccess.role(message)
722
654
  end
723
655
 
724
- def message_content(message)
725
- MessageAccess.content(message)
726
- end
727
-
728
656
  def session_tree_helper(rpc_session)
729
657
  SessionTree.new(rpc_session)
730
658
  end
@@ -913,9 +841,7 @@ module Kward
913
841
  end
914
842
 
915
843
  def close_rpc_session(rpc_session, delete_unused: true)
916
- @mutex.synchronize { @sessions.delete(rpc_session.id) }
917
- stop_worker(rpc_session)
918
- stop_footer_worker(rpc_session)
844
+ remove_live_session(rpc_session)
919
845
  rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
920
846
  end
921
847
 
@@ -928,12 +854,16 @@ module Kward
928
854
  next unless rpc_session.session.respond_to?(:delete_if_unused)
929
855
  next unless rpc_session.session.delete_if_unused
930
856
 
931
- @mutex.synchronize { @sessions.delete(rpc_session.id) }
932
- stop_worker(rpc_session)
933
- stop_footer_worker(rpc_session)
857
+ remove_live_session(rpc_session)
934
858
  end
935
859
  end
936
860
 
861
+ def remove_live_session(rpc_session)
862
+ @mutex.synchronize { @sessions.delete(rpc_session.id) }
863
+ stop_worker(rpc_session)
864
+ stop_footer_worker(rpc_session)
865
+ end
866
+
937
867
  def stop_worker(rpc_session)
938
868
  worker = rpc_session.worker
939
869
  return unless worker&.alive?
@@ -1042,7 +972,7 @@ module Kward
1042
972
  end
1043
973
 
1044
974
  def default_session_name(input)
1045
- input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
975
+ SessionNaming.default_name(input)
1046
976
  end
1047
977
 
1048
978
  def turn_display_input(turn)
@@ -1076,21 +1006,23 @@ module Kward
1076
1006
 
1077
1007
  turn.steering.submit(input)
1078
1008
  turn_payload(turn)
1079
- rescue StandardError
1080
- nil
1081
1009
  end
1082
1010
 
1083
- def run_plugin_turn(rpc_session, turn)
1084
- turn.cancellation&.raise_if_cancelled!
1085
- command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
1086
- output = []
1087
- context = PluginRegistry::Context.new(
1011
+ def plugin_context(rpc_session, args: nil, say_callback:)
1012
+ PluginRegistry::Context.new(
1088
1013
  conversation: rpc_session.conversation,
1089
- args: turn.plugin_arguments.to_s,
1014
+ args: args,
1090
1015
  session: rpc_session.session,
1091
1016
  workspace_root: rpc_session.workspace_root,
1092
- say_callback: lambda { |message| output << message.to_s }
1017
+ say_callback: say_callback
1093
1018
  )
1019
+ end
1020
+
1021
+ def run_plugin_turn(rpc_session, turn)
1022
+ turn.cancellation&.raise_if_cancelled!
1023
+ command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
1024
+ output = []
1025
+ context = plugin_context(rpc_session, args: turn.plugin_arguments.to_s, say_callback: lambda { |message| output << message.to_s })
1094
1026
  result = command.handler.call(turn.plugin_arguments.to_s, context)
1095
1027
  answer = (output + [result]).compact.map(&:to_s).reject(&:empty?).join("\n")
1096
1028
  unless answer.empty?
@@ -1103,12 +1035,7 @@ module Kward
1103
1035
  def notify_plugin_transcript_event(rpc_session, event)
1104
1036
  return if plugin_registry.transcript_event_handlers.empty?
1105
1037
 
1106
- context = PluginRegistry::Context.new(
1107
- conversation: rpc_session.conversation,
1108
- session: rpc_session.session,
1109
- workspace_root: rpc_session.workspace_root,
1110
- say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
1111
- )
1038
+ context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
1112
1039
  plugin_registry.notify_transcript_event(event, context)
1113
1040
  end
1114
1041
 
@@ -1124,6 +1051,8 @@ module Kward
1124
1051
  emit_turn_event(turn, "modelRetry", retry_event_payload(event))
1125
1052
  when Events::Steering
1126
1053
  emit_turn_event(turn, "turnSteered", { input: event.input, createdAt: event.created_at })
1054
+ when Events::SteeringApplied
1055
+ emit_turn_event(turn, "steeringApplied", { count: event.count })
1127
1056
  when Events::ToolCall
1128
1057
  emit_turn_event(turn, "toolCall", normalized_tool_event_payload(event.tool_call))
1129
1058
  when Events::ToolResult
@@ -1160,12 +1089,7 @@ module Kward
1160
1089
  return clear_footer_update(rpc_session) unless renderer
1161
1090
 
1162
1091
  text = begin
1163
- context = PluginRegistry::Context.new(
1164
- conversation: rpc_session.conversation,
1165
- session: rpc_session.session,
1166
- workspace_root: rpc_session.workspace_root,
1167
- say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
1168
- )
1092
+ context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
1169
1093
  renderer.call(context).to_s.gsub(/\s+/, " ").strip
1170
1094
  rescue StandardError => e
1171
1095
  warn "Warning: Kward plugin footer error: #{e.message}"
@@ -1,7 +1,7 @@
1
1
  require_relative "../message_access"
2
2
  require_relative "../message_text"
3
+ require_relative "../session_tree_nodes"
3
4
  require_relative "../session_tree_tool_display"
4
- require_relative "../tools/tool_call"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -28,13 +28,14 @@ module Kward
28
28
  #
29
29
  # @return [Array<Hash>] rows for the `session/tree` RPC method
30
30
  def rows
31
- active_path = tree_active_path(@roots, @current_leaf)
32
- tool_calls_by_id = tree_tool_calls(@roots)
33
- visible_roots = @roots.flat_map { |root| visible_tree_nodes(root) }
31
+ tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf)
32
+ active_path = tree_nodes.active_path
33
+ tool_calls_by_id = tree_nodes.tool_calls
34
+ visible_roots = tree_nodes.visible_roots
34
35
  multiple_roots = visible_roots.length > 1
35
36
  result = []
36
37
 
37
- stack = visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
38
+ stack = visible_roots.sort_by { |root| tree_nodes.contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
38
39
  [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
39
40
  end.reverse
40
41
 
@@ -60,7 +61,7 @@ module Kward
60
61
  prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
61
62
  }.compact
62
63
 
63
- children = node[:children].sort_by { |child| tree_contains_active_path?(child, active_path) ? 0 : 1 }
64
+ children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
64
65
  multiple_children = children.length > 1
65
66
  child_indent = if multiple_children
66
67
  indent + 1
@@ -80,112 +81,6 @@ module Kward
80
81
 
81
82
  private
82
83
 
83
- def tree_active_path(roots, leaf_id)
84
- by_id = tree_entries_by_id(roots)
85
- ids = []
86
- current = by_id[leaf_id.to_s]
87
- seen = {}
88
- while current && !seen[current["id"].to_s]
89
- seen[current["id"].to_s] = true
90
- ids << current["id"].to_s
91
- current = by_id[current["parentId"].to_s]
92
- end
93
- ids
94
- end
95
-
96
- def tree_entries_by_id(roots)
97
- roots.each_with_object({}) do |root, map|
98
- stack = [root]
99
- seen = {}
100
- until stack.empty?
101
- node = stack.pop
102
- next if seen[node.object_id]
103
-
104
- seen[node.object_id] = true
105
- entry = node["entry"] || {}
106
- map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
107
- stack.concat(Array(node["children"]))
108
- end
109
- end
110
- end
111
-
112
- def visible_tree_nodes(node)
113
- results = {}
114
- stack = [[node, false, {}]]
115
-
116
- until stack.empty?
117
- current, visited, seen = stack.pop
118
- node_key = current.object_id
119
- next if seen[node_key]
120
-
121
- if visited
122
- children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
123
- results[node_key] = if hidden_tree_entry?(current["entry"] || {})
124
- children
125
- else
126
- [{ source: current, children: children }]
127
- end
128
- else
129
- branch_seen = seen.merge(node_key => true)
130
- stack << [current, true, seen]
131
- Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
132
- end
133
- end
134
-
135
- results[node.object_id] || []
136
- end
137
-
138
- def hidden_tree_entry?(entry)
139
- return false if @current_leaf && entry["id"].to_s == @current_leaf.to_s
140
- return false unless entry["type"] == "message"
141
-
142
- message = entry["message"]
143
- return false unless message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
144
-
145
- content = MessageAccess.content(message)
146
- content_tool_calls = content.is_a?(Array) && content.any? { |part| ToolCall.value(part, :type) == "toolCall" }
147
- (content_tool_calls && !tree_text_content?(content)) || (!MessageAccess.tool_calls(message).empty? && MessageText.full_text(message).empty?)
148
- end
149
-
150
- def tree_text_content?(content)
151
- Array(content).any? { |part| ToolCall.value(part, :type) == "text" && ToolCall.value(part, :text).to_s.strip != "" }
152
- end
153
-
154
- def tree_contains_active_path?(node, active_path)
155
- stack = [node]
156
- seen = {}
157
- until stack.empty?
158
- current = stack.pop
159
- next if seen[current.object_id]
160
-
161
- seen[current.object_id] = true
162
- entry_id = (current[:source]["entry"] || {})["id"].to_s
163
- return true if active_path.include?(entry_id)
164
-
165
- stack.concat(current[:children])
166
- end
167
- false
168
- end
169
-
170
- def tree_tool_calls(roots)
171
- roots.each_with_object({}) do |root, tool_calls_by_id|
172
- stack = [root]
173
- seen = {}
174
- until stack.empty?
175
- node = stack.pop
176
- next if seen[node.object_id]
177
-
178
- seen[node.object_id] = true
179
- entry = node["entry"] || {}
180
- message = entry["message"]
181
- if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
182
- MessageAccess.tool_calls(message).each { |tool_call| tool_calls_by_id[ToolCall.id(tool_call).to_s] = tool_call }
183
- end
184
- stack.concat(Array(node["children"]))
185
- end
186
- end
187
- end
188
-
189
84
  def tree_entry_display(entry, tool_calls_by_id = {})
190
85
  case entry["type"]
191
86
  when "message"
@@ -237,12 +132,11 @@ module Kward
237
132
  end
238
133
 
239
134
  def display_message_text(message)
240
- truncate_tree_text(MessageText.full_text(message))
135
+ SessionTreeNodes.truncate_text(MessageText.full_text(message))
241
136
  end
242
137
 
243
138
  def truncate_tree_text(text)
244
- normalized = text.to_s.gsub(/\s+/, " ").strip
245
- normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
139
+ SessionTreeNodes.truncate_text(text)
246
140
  end
247
141
  end
248
142
  end
@@ -67,7 +67,7 @@ module Kward
67
67
  end
68
68
 
69
69
  def mutation_tool?
70
- ["edit", "write"].include?(@fields[:toolName])
70
+ ToolCall.file_change_tool?(@fields[:toolName])
71
71
  end
72
72
  end
73
73
  end
@@ -75,6 +75,17 @@ module Kward
75
75
  index ? text.to_s[index..] : nil
76
76
  end
77
77
 
78
+ def changed_files_from_result(text, matching_call = nil)
79
+ path = matching_call&.dig(:arguments, :path) || matching_call&.dig(:arguments, "path")
80
+ return [path] if path
81
+
82
+ if (match = text.to_s.match(/\A(?:Wrote \d+ bytes to|Edited)\s+([^:\n]+)/))
83
+ [match[1].strip]
84
+ else
85
+ []
86
+ end
87
+ end
88
+
78
89
  def error_result?(text)
79
90
  text.to_s.start_with?("Error:", "Declined:", "Cancelled.")
80
91
  end
@@ -106,7 +106,7 @@ module Kward
106
106
  type: "toolCall",
107
107
  id: ToolCall.id(tool_call),
108
108
  name: normalize_tool_name(raw_name) || raw_name,
109
- arguments: normalize_tool_arguments(raw_name, ToolCall.raw_arguments(tool_call))
109
+ arguments: ToolMetadata.normalize_tool_args(raw_name, ToolCall.parse_arguments(ToolCall.raw_arguments(tool_call)))
110
110
  }.compact
111
111
  end
112
112
 
@@ -235,7 +235,7 @@ module Kward
235
235
  type: "toolCall",
236
236
  id: ToolCall.value(part, :id),
237
237
  name: normalize_tool_name(raw_name) || raw_name,
238
- arguments: normalize_tool_arguments(raw_name, arguments)
238
+ arguments: ToolMetadata.normalize_tool_args(raw_name, ToolCall.parse_arguments(arguments))
239
239
  }.compact
240
240
  end
241
241
 
@@ -244,10 +244,10 @@ module Kward
244
244
  details = explicit_details.is_a?(Hash) ? safe_details(explicit_details) : {}
245
245
  text = content_text(content)
246
246
 
247
- diff = details[:diff] || details["diff"] || extract_unified_diff(text)
247
+ diff = details[:diff] || details["diff"] || ToolMetadata.extract_unified_diff(text)
248
248
  details[:diff] = diff if diff
249
249
 
250
- changed_files = details[:changedFiles] || details["changedFiles"] || changed_files_from_result(text, matching_call)
250
+ changed_files = details[:changedFiles] || details["changedFiles"] || ToolMetadata.changed_files_from_result(text, matching_call)
251
251
  details[:changedFiles] = changed_files if changed_files && !changed_files.empty?
252
252
 
253
253
  details
@@ -262,21 +262,6 @@ module Kward
262
262
  allowed
263
263
  end
264
264
 
265
- def extract_unified_diff(text)
266
- ToolMetadata.extract_unified_diff(text)
267
- end
268
-
269
- def changed_files_from_result(text, matching_call)
270
- path = matching_call&.dig(:arguments, :path) || matching_call&.dig(:arguments, "path")
271
- return [path] if path
272
-
273
- if (match = text.match(/\A(?:Wrote \d+ bytes to|Edited)\s+([^:\n]+)/))
274
- [match[1].strip]
275
- else
276
- []
277
- end
278
- end
279
-
280
265
  def error_tool_result?(message, content)
281
266
  return ToolCall.value(message, :isError) if has_key?(message, :isError)
282
267
  return ToolCall.value(message, :is_error) if has_key?(message, :is_error)
@@ -299,26 +284,6 @@ module Kward
299
284
  ToolMetadata.normalize_tool_name(name)
300
285
  end
301
286
 
302
- def normalize_tool_arguments(name, arguments)
303
- args = ToolCall.parse_arguments(arguments)
304
- case name.to_s
305
- when "edit_file", "edit"
306
- ToolMetadata.normalize_tool_args(name, args)
307
- when "run_shell_command", "bash"
308
- normalize_bash_args(args)
309
- else
310
- ToolCall.camelize_args(args)
311
- end
312
- end
313
-
314
- def normalize_bash_args(args)
315
- normalized = ToolCall.camelize_args(args)
316
- timeout = ToolCall.value(args, :timeoutSeconds) || ToolCall.value(args, :timeout_seconds)
317
- normalized[:timeoutSeconds] = timeout if timeout
318
- normalized.delete(:timeout_seconds)
319
- normalized
320
- end
321
-
322
287
  def normalize_mime_type(mime_type)
323
288
  mime_type.to_s.downcase.sub("image/jpg", "image/jpeg")
324
289
  end
@@ -0,0 +1,56 @@
1
+ require "open3"
2
+ require "rbconfig"
3
+ require "tempfile"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Executes scratchpad buffers and returns transformed buffer content.
8
+ module ScratchpadRunner
9
+ RUBY_END_MARKER_PATTERN = /^__END__\n?/.freeze
10
+
11
+ Result = Struct.new(:buffer, :exit_status, keyword_init: true)
12
+
13
+ module_function
14
+
15
+ def run(language, content)
16
+ case language&.to_sym
17
+ when :ruby
18
+ run_ruby(content)
19
+ else
20
+ raise ArgumentError, "Scratchpad language #{language.inspect} is not runnable"
21
+ end
22
+ end
23
+
24
+ def run_ruby(content)
25
+ content = content.to_s
26
+ output, status = capture_ruby_output(content)
27
+ Result.new(buffer: ruby_buffer_with_output(content, output, status.exitstatus), exit_status: status.exitstatus)
28
+ end
29
+
30
+ def capture_ruby_output(content)
31
+ Tempfile.create(["kward-scratchpad", ".rb"]) do |file|
32
+ file.write(content)
33
+ file.flush
34
+ Open3.capture2e(RbConfig.ruby, file.path)
35
+ end
36
+ end
37
+
38
+ def ruby_buffer_with_output(content, output, exit_status)
39
+ output = output.to_s
40
+ output += "\n" unless output.empty? || output.end_with?("\n")
41
+ output += "[exit status: #{exit_status}]\n" unless exit_status.to_i.zero?
42
+
43
+ if (match = content.match(RUBY_END_MARKER_PATTERN))
44
+ "#{content[0...match.begin(0)]}__END__\n#{output}"
45
+ else
46
+ "#{content}#{ruby_end_separator(content)}__END__\n#{output}"
47
+ end
48
+ end
49
+
50
+ def ruby_end_separator(content)
51
+ return "" if content.empty?
52
+
53
+ content.end_with?("\n") ? "\n" : "\n\n"
54
+ end
55
+ end
56
+ end
@@ -25,20 +25,37 @@ module Kward
25
25
  new
26
26
  end
27
27
 
28
+ def self.content_from_session_file(path)
29
+ records = File.readlines(path, chomp: true).filter_map { |line| parse_record(line) }
30
+ content_from_records(records)
31
+ rescue Errno::ENOENT, Errno::EACCES
32
+ ""
33
+ end
34
+
28
35
  def self.from_records(records)
29
36
  execution_records = records.select { |record| record["type"] == "tool_execution_end" }
30
37
  source_records = execution_records.empty? ? records : execution_records
31
38
  source_records.each_with_object(new) do |record, diff|
32
39
  if record["type"] == "tool_execution_end"
33
- next if record["isError"] || record.dig("result", "isError")
34
-
35
- diff.add_diff(record.dig("result", "diff"))
40
+ diff.add_diff(record_diff(record))
36
41
  elsif record["type"] == "message" && (record.dig("message", "role") == "tool" || record.dig("message", :role) == "tool")
37
42
  diff.add_tool_result(record.dig("message", "content") || record.dig("message", :content))
38
43
  end
39
44
  end
40
45
  end
41
46
 
47
+ def self.content_from_records(records)
48
+ records.filter_map { |record| record_diff(record) }.join("\n")
49
+ end
50
+
51
+ def self.record_diff(record)
52
+ return nil unless record["type"] == "tool_execution_end"
53
+ return nil if record["isError"] || record.dig("result", "isError")
54
+
55
+ diff = record.dig("result", "diff").to_s
56
+ diff.empty? ? nil : diff
57
+ end
58
+
42
59
  def self.count(diff)
43
60
  if (stats = truncated_diff_stats(diff))
44
61
  return stats
@@ -0,0 +1,11 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Shared session-name formatting for CLI and RPC session auto-naming.
4
+ module SessionNaming
5
+ module_function
6
+
7
+ def default_name(input)
8
+ input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
9
+ end
10
+ end
11
+ end