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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +74 -4
- data/doc/session-management.md +35 -1
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +53 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +218 -9
- data/lib/kward/cli/slash_commands.rb +415 -2
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +158 -26
- data/lib/kward/config_files.rb +123 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +387 -35
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +98 -50
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +16 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +286 -8
- data/lib/kward/prompts/commands.rb +5 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/server.rb +42 -3
- data/lib/kward/rpc/session_manager.rb +35 -47
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +110 -24
- data/templates/default/fulldoc/html/css/kward.css +107 -36
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +4 -2
- data/templates/default/layout/html/setup.rb +6 -0
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1084
|
-
|
|
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:
|
|
1078
|
+
args: args,
|
|
1090
1079
|
session: rpc_session.session,
|
|
1091
1080
|
workspace_root: rpc_session.workspace_root,
|
|
1092
|
-
say_callback:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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|
|
|
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|
|
|
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
|
-
|
|
135
|
+
SessionTreeNodes.truncate_text(MessageText.full_text(message))
|
|
241
136
|
end
|
|
242
137
|
|
|
243
138
|
def truncate_tree_text(text)
|
|
244
|
-
|
|
245
|
-
normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
|
|
139
|
+
SessionTreeNodes.truncate_text(text)
|
|
246
140
|
end
|
|
247
141
|
end
|
|
248
142
|
end
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -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
|