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
@@ -295,12 +295,21 @@ module Kward
295
295
  rpc_sessions = @mutex.synchronize { @sessions.values.dup }
296
296
  rpc_sessions.reverse_each do |rpc_session|
297
297
  next unless session_idle?(rpc_session)
298
+ next unless rpc_session.session.respond_to?(:delete_if_unused)
299
+ next unless rpc_session.session.delete_if_unused
298
300
 
299
- close_rpc_session(rpc_session)
301
+ remove_live_session(rpc_session)
300
302
  end
301
303
  { closed: true }
302
304
  end
303
305
 
306
+ # Stops all live RPC session workers during server shutdown.
307
+ def shutdown_sessions
308
+ rpc_sessions = @mutex.synchronize { @sessions.values.dup }
309
+ rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session) if session_idle?(rpc_session) }
310
+ { closed: true }
311
+ end
312
+
304
313
  # Returns the normalized transcript for the active RPC session.
305
314
  def transcript(session_id:)
306
315
  rpc_session = fetch_session(session_id)
@@ -321,10 +330,7 @@ module Kward
321
330
  content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
322
331
  streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
323
332
  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"
333
+ return steer_running_turn(rpc_session, content)
328
334
  end
329
335
  turn = Turn.new(
330
336
  id: SecureRandom.uuid,
@@ -461,13 +467,7 @@ module Kward
461
467
  rpc_session = fetch_session(session_id)
462
468
  command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
463
469
  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
- )
470
+ context = plugin_context(rpc_session, args: arguments.to_s, say_callback: lambda { |message| output << message.to_s })
471
471
  result = command.handler.call(arguments.to_s, context)
472
472
  output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
473
473
  { command: command.name, output: output, result: result.nil? ? nil : result.to_s }
@@ -709,10 +709,6 @@ module Kward
709
709
  pending_turn_count(rpc_session.id).zero?
710
710
  end
711
711
 
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
712
  def tool_calls(message)
717
713
  MessageAccess.tool_calls(message)
718
714
  end
@@ -721,10 +717,6 @@ module Kward
721
717
  MessageAccess.role(message)
722
718
  end
723
719
 
724
- def message_content(message)
725
- MessageAccess.content(message)
726
- end
727
-
728
720
  def session_tree_helper(rpc_session)
729
721
  SessionTree.new(rpc_session)
730
722
  end
@@ -913,9 +905,7 @@ module Kward
913
905
  end
914
906
 
915
907
  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)
908
+ remove_live_session(rpc_session)
919
909
  rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
920
910
  end
921
911
 
@@ -928,12 +918,16 @@ module Kward
928
918
  next unless rpc_session.session.respond_to?(:delete_if_unused)
929
919
  next unless rpc_session.session.delete_if_unused
930
920
 
931
- @mutex.synchronize { @sessions.delete(rpc_session.id) }
932
- stop_worker(rpc_session)
933
- stop_footer_worker(rpc_session)
921
+ remove_live_session(rpc_session)
934
922
  end
935
923
  end
936
924
 
925
+ def remove_live_session(rpc_session)
926
+ @mutex.synchronize { @sessions.delete(rpc_session.id) }
927
+ stop_worker(rpc_session)
928
+ stop_footer_worker(rpc_session)
929
+ end
930
+
937
931
  def stop_worker(rpc_session)
938
932
  worker = rpc_session.worker
939
933
  return unless worker&.alive?
@@ -1076,21 +1070,23 @@ module Kward
1076
1070
 
1077
1071
  turn.steering.submit(input)
1078
1072
  turn_payload(turn)
1079
- rescue StandardError
1080
- nil
1081
1073
  end
1082
1074
 
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(
1075
+ def plugin_context(rpc_session, args: nil, say_callback:)
1076
+ PluginRegistry::Context.new(
1088
1077
  conversation: rpc_session.conversation,
1089
- args: turn.plugin_arguments.to_s,
1078
+ args: args,
1090
1079
  session: rpc_session.session,
1091
1080
  workspace_root: rpc_session.workspace_root,
1092
- say_callback: lambda { |message| output << message.to_s }
1081
+ say_callback: say_callback
1093
1082
  )
1083
+ end
1084
+
1085
+ def run_plugin_turn(rpc_session, turn)
1086
+ turn.cancellation&.raise_if_cancelled!
1087
+ command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
1088
+ output = []
1089
+ context = plugin_context(rpc_session, args: turn.plugin_arguments.to_s, say_callback: lambda { |message| output << message.to_s })
1094
1090
  result = command.handler.call(turn.plugin_arguments.to_s, context)
1095
1091
  answer = (output + [result]).compact.map(&:to_s).reject(&:empty?).join("\n")
1096
1092
  unless answer.empty?
@@ -1103,12 +1099,7 @@ module Kward
1103
1099
  def notify_plugin_transcript_event(rpc_session, event)
1104
1100
  return if plugin_registry.transcript_event_handlers.empty?
1105
1101
 
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
- )
1102
+ context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
1112
1103
  plugin_registry.notify_transcript_event(event, context)
1113
1104
  end
1114
1105
 
@@ -1124,6 +1115,8 @@ module Kward
1124
1115
  emit_turn_event(turn, "modelRetry", retry_event_payload(event))
1125
1116
  when Events::Steering
1126
1117
  emit_turn_event(turn, "turnSteered", { input: event.input, createdAt: event.created_at })
1118
+ when Events::SteeringApplied
1119
+ emit_turn_event(turn, "steeringApplied", { count: event.count })
1127
1120
  when Events::ToolCall
1128
1121
  emit_turn_event(turn, "toolCall", normalized_tool_event_payload(event.tool_call))
1129
1122
  when Events::ToolResult
@@ -1160,12 +1153,7 @@ module Kward
1160
1153
  return clear_footer_update(rpc_session) unless renderer
1161
1154
 
1162
1155
  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
- )
1156
+ context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
1169
1157
  renderer.call(context).to_s.gsub(/\s+/, " ").strip
1170
1158
  rescue StandardError => e
1171
1159
  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
@@ -178,6 +178,9 @@ module Kward
178
178
  # @return [String] workspace directory this store lists and creates sessions for
179
179
  attr_reader :cwd
180
180
 
181
+ # @return [String] configuration directory containing session and tab files
182
+ attr_reader :config_dir
183
+
181
184
  # Creates a new empty session file for the store's workspace directory.
182
185
  #
183
186
  # Parent fields record clone/fork ancestry; they do not imply live coupling
@@ -285,6 +288,7 @@ module Kward
285
288
  session_memories: memory_state["sessionMemories"],
286
289
  last_memory_retrieval: memory_state["lastRetrieval"]
287
290
  )
291
+ restore_tool_output_artifacts(records, conversation)
288
292
  conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
289
293
  session = Session.new(
290
294
  store: self,
@@ -561,6 +565,46 @@ module Kward
561
565
  records.reverse.find { |record| record["type"] == "memory_state" } || { "sessionMemories" => [], "lastRetrieval" => nil }
562
566
  end
563
567
 
568
+ def restore_tool_output_artifacts(records, conversation)
569
+ tool_names = tool_message_names_by_id(records)
570
+ records.each do |record|
571
+ next unless record["type"] == "tool_execution_end"
572
+
573
+ content = record.dig("result", "content")
574
+ next if content.nil?
575
+
576
+ tool_name = tool_names[record["toolCallId"].to_s] || raw_tool_name(record["toolName"])
577
+ next if tool_name.to_s.empty?
578
+
579
+ conversation.restore_tool_output_artifact(
580
+ tool_name: tool_name,
581
+ content: content,
582
+ created_at: parse_time(record["timestamp"])
583
+ )
584
+ end
585
+ end
586
+
587
+ def tool_message_names_by_id(records)
588
+ records.each_with_object({}) do |record, names|
589
+ next unless record["type"] == "message"
590
+
591
+ message = record["message"]
592
+ next unless message.is_a?(Hash) && message_role(message) == "tool"
593
+
594
+ tool_call_id = message_tool_call_id(message).to_s
595
+ names[tool_call_id] = message_name(message) unless tool_call_id.empty? || message_name(message).to_s.empty?
596
+ end
597
+ end
598
+
599
+ def raw_tool_name(name)
600
+ {
601
+ "bash" => "run_shell_command",
602
+ "edit" => "edit_file",
603
+ "read" => "read_file",
604
+ "write" => "write_file"
605
+ }.fetch(name.to_s, name.to_s)
606
+ end
607
+
564
608
  def session_runtime(records, header)
565
609
  result = {
566
610
  "provider" => header["provider"],
@@ -0,0 +1,136 @@
1
+ require_relative "message_access"
2
+ require_relative "message_text"
3
+ require_relative "tools/tool_call"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Shared traversal helpers for persisted session trees.
8
+ #
9
+ # Frontends own their final labels or payloads. This class owns only the
10
+ # frontend-neutral mechanics needed by both terminal and RPC tree views:
11
+ # active-path lookup, hidden tool-call-only assistant nodes, visible-node
12
+ # flattening, and assistant tool-call lookup by id.
13
+ class SessionTreeNodes
14
+ def initialize(roots:, current_leaf: nil)
15
+ @roots = roots
16
+ @current_leaf = current_leaf
17
+ end
18
+
19
+ def active_path
20
+ by_id = entries_by_id
21
+ ids = []
22
+ entry = by_id[@current_leaf.to_s]
23
+ seen = {}
24
+ while entry && !seen[entry["id"].to_s]
25
+ seen[entry["id"].to_s] = true
26
+ ids << entry["id"].to_s
27
+ entry = by_id[entry["parentId"].to_s]
28
+ end
29
+ ids
30
+ end
31
+
32
+ def visible_roots
33
+ @roots.flat_map { |root| visible_nodes(root) }
34
+ end
35
+
36
+ def contains_active_path?(node, active_path)
37
+ stack = [node]
38
+ seen = {}
39
+ until stack.empty?
40
+ current = stack.pop
41
+ next if seen[current.object_id]
42
+
43
+ seen[current.object_id] = true
44
+ entry_id = (current[:source]["entry"] || {})["id"].to_s
45
+ return true if active_path.include?(entry_id)
46
+
47
+ stack.concat(current[:children])
48
+ end
49
+ false
50
+ end
51
+
52
+ def tool_calls
53
+ @roots.each_with_object({}) do |root, calls|
54
+ stack = [root]
55
+ seen = {}
56
+ until stack.empty?
57
+ node = stack.pop
58
+ next if seen[node.object_id]
59
+
60
+ seen[node.object_id] = true
61
+ entry = node["entry"] || {}
62
+ message = entry["message"]
63
+ if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
64
+ MessageAccess.tool_calls(message).each { |tool_call| calls[ToolCall.id(tool_call).to_s] = tool_call }
65
+ end
66
+ stack.concat(Array(node["children"]))
67
+ end
68
+ end
69
+ end
70
+
71
+ def self.truncate_text(text)
72
+ normalized = text.to_s.gsub(/\s+/, " ").strip
73
+ normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
74
+ end
75
+
76
+ private
77
+
78
+ def visible_nodes(node)
79
+ results = {}
80
+ stack = [[node, false, {}]]
81
+
82
+ until stack.empty?
83
+ current, visited, seen = stack.pop
84
+ node_key = current.object_id
85
+ next if seen[node_key]
86
+
87
+ if visited
88
+ children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
89
+ results[node_key] = if hidden_entry?(current["entry"] || {})
90
+ children
91
+ else
92
+ [{ source: current, children: children }]
93
+ end
94
+ else
95
+ branch_seen = seen.merge(node_key => true)
96
+ stack << [current, true, seen]
97
+ Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
98
+ end
99
+ end
100
+
101
+ results[node.object_id] || []
102
+ end
103
+
104
+ def hidden_entry?(entry)
105
+ return false if @current_leaf && entry["id"].to_s == @current_leaf.to_s
106
+ return false unless entry["type"] == "message"
107
+
108
+ message = entry["message"]
109
+ return false unless message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
110
+
111
+ content = MessageAccess.content(message)
112
+ content_tool_calls = content.is_a?(Array) && content.any? { |part| MessageAccess.value(part, :type) == "toolCall" }
113
+ (content_tool_calls && !text_content?(content)) || (!MessageAccess.tool_calls(message).empty? && MessageText.full_text(message).empty?)
114
+ end
115
+
116
+ def text_content?(content)
117
+ Array(content).any? { |part| MessageAccess.value(part, :type) == "text" && MessageAccess.value(part, :text).to_s.strip != "" }
118
+ end
119
+
120
+ def entries_by_id
121
+ @roots.each_with_object({}) do |root, map|
122
+ stack = [root]
123
+ seen = {}
124
+ until stack.empty?
125
+ node = stack.pop
126
+ next if seen[node.object_id]
127
+
128
+ seen[node.object_id] = true
129
+ entry = node["entry"] || {}
130
+ map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
131
+ stack.concat(Array(node["children"]))
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end