kward 0.70.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +89 -3
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +34 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +52 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +58 -23
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +102 -13
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +83 -0
- 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 +74 -3
- data/doc/releasing.md +45 -8
- data/doc/rpc.md +77 -15
- data/doc/session-management.md +254 -0
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +60 -15
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +144 -0
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +41 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +111 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +262 -13
- data/lib/kward/cli/settings.rb +216 -37
- data/lib/kward/cli/slash_commands.rb +439 -8
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +171 -26
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +125 -5
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +59 -22
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +108 -1
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +124 -83
- data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
- 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 +416 -43
- data/lib/kward/prompt_interface/layout.rb +2 -2
- 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/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +122 -82
- data/lib/kward/prompt_interface/runtime_state.rb +49 -1
- data/lib/kward/prompt_interface/screen.rb +17 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +307 -35
- data/lib/kward/prompts/commands.rb +7 -1
- data/lib/kward/prompts.rb +4 -2
- data/lib/kward/rpc/server.rb +45 -11
- data/lib/kward/rpc/session_manager.rb +52 -53
- 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 +67 -4
- 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/telemetry/logger.rb +5 -3
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- 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 +92 -15
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +12 -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 +154 -12
- data/templates/default/fulldoc/html/css/kward.css +362 -42
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +102 -0
- data/templates/default/layout/html/layout.erb +43 -10
- data/templates/default/layout/html/setup.rb +39 -38
- metadata +65 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
|
@@ -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
|
|
@@ -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
|
|
@@ -13,13 +13,14 @@ module Kward
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def items
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf_id)
|
|
17
|
+
active_path = tree_nodes.active_path
|
|
18
|
+
tool_calls_by_id = tree_nodes.tool_calls
|
|
19
|
+
visible_roots = tree_nodes.visible_roots
|
|
19
20
|
multiple_roots = visible_roots.length > 1
|
|
20
21
|
result = []
|
|
21
22
|
|
|
22
|
-
stack = visible_roots.sort_by { |root|
|
|
23
|
+
stack = visible_roots.sort_by { |root| tree_nodes.contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
|
|
23
24
|
[root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
|
|
24
25
|
end.reverse
|
|
25
26
|
|
|
@@ -33,7 +34,7 @@ module Kward
|
|
|
33
34
|
label: session_tree_label(entry, node[:source], prefix, active_path.include?(entry["id"].to_s), tool_calls_by_id)
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
children = node[:children].sort_by { |child|
|
|
37
|
+
children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
|
|
37
38
|
multiple_children = children.length > 1
|
|
38
39
|
child_indent = if multiple_children
|
|
39
40
|
indent + 1
|
|
@@ -55,52 +56,6 @@ module Kward
|
|
|
55
56
|
|
|
56
57
|
private
|
|
57
58
|
|
|
58
|
-
def visible_session_tree_nodes(node)
|
|
59
|
-
results = {}
|
|
60
|
-
stack = [[node, false, {}]]
|
|
61
|
-
|
|
62
|
-
until stack.empty?
|
|
63
|
-
current, visited, seen = stack.pop
|
|
64
|
-
node_key = current.object_id
|
|
65
|
-
next if seen[node_key]
|
|
66
|
-
|
|
67
|
-
if visited
|
|
68
|
-
children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
|
|
69
|
-
results[node_key] = if hidden_session_tree_entry?(current["entry"] || {})
|
|
70
|
-
children
|
|
71
|
-
else
|
|
72
|
-
[{ source: current, children: children }]
|
|
73
|
-
end
|
|
74
|
-
else
|
|
75
|
-
branch_seen = seen.merge(node_key => true)
|
|
76
|
-
stack << [current, true, seen]
|
|
77
|
-
Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
results[node.object_id] || []
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
def hidden_session_tree_entry?(entry)
|
|
85
|
-
return false if @current_leaf_id && entry["id"].to_s == @current_leaf_id.to_s
|
|
86
|
-
return false unless entry["type"] == "message"
|
|
87
|
-
|
|
88
|
-
message = entry["message"]
|
|
89
|
-
return false unless message.is_a?(Hash) && message_role(message) == "assistant"
|
|
90
|
-
|
|
91
|
-
content = message_content(message)
|
|
92
|
-
content_tool_calls = content.is_a?(Array) && content.any? { |part| session_tree_content_part_value(part, :type) == "toolCall" }
|
|
93
|
-
(content_tool_calls && !session_tree_text_content?(content)) || (!message_tool_calls(message).empty? && full_message_text(message).empty?)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def session_tree_text_content?(content)
|
|
97
|
-
Array(content).any? { |part| session_tree_content_part_value(part, :type) == "text" && session_tree_content_part_value(part, :text).to_s.strip != "" }
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def session_tree_content_part_value(part, key)
|
|
101
|
-
MessageAccess.value(part, key)
|
|
102
|
-
end
|
|
103
|
-
|
|
104
59
|
def session_tree_label(entry, node, prefix, active_path, tool_calls_by_id)
|
|
105
60
|
label = node["label"] || entry["resolvedLabel"]
|
|
106
61
|
label = label.to_s.strip
|
|
@@ -152,70 +107,6 @@ module Kward
|
|
|
152
107
|
end.join
|
|
153
108
|
end
|
|
154
109
|
|
|
155
|
-
def session_tree_contains_active_path?(node, active_path)
|
|
156
|
-
stack = [node]
|
|
157
|
-
seen = {}
|
|
158
|
-
until stack.empty?
|
|
159
|
-
current = stack.pop
|
|
160
|
-
next if seen[current.object_id]
|
|
161
|
-
|
|
162
|
-
seen[current.object_id] = true
|
|
163
|
-
entry_id = (current[:source]["entry"] || {})["id"].to_s
|
|
164
|
-
return true if active_path.include?(entry_id)
|
|
165
|
-
|
|
166
|
-
stack.concat(current[:children])
|
|
167
|
-
end
|
|
168
|
-
false
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
def session_tree_active_path(roots, leaf_id)
|
|
172
|
-
by_id = session_tree_entries_by_id(roots)
|
|
173
|
-
ids = []
|
|
174
|
-
entry = by_id[leaf_id.to_s]
|
|
175
|
-
seen = {}
|
|
176
|
-
while entry && !seen[entry["id"].to_s]
|
|
177
|
-
seen[entry["id"].to_s] = true
|
|
178
|
-
ids << entry["id"].to_s
|
|
179
|
-
entry = by_id[entry["parentId"].to_s]
|
|
180
|
-
end
|
|
181
|
-
ids
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def session_tree_entries_by_id(roots)
|
|
185
|
-
roots.each_with_object({}) do |root, map|
|
|
186
|
-
stack = [root]
|
|
187
|
-
seen = {}
|
|
188
|
-
until stack.empty?
|
|
189
|
-
node = stack.pop
|
|
190
|
-
next if seen[node.object_id]
|
|
191
|
-
|
|
192
|
-
seen[node.object_id] = true
|
|
193
|
-
entry = node["entry"] || {}
|
|
194
|
-
map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
|
|
195
|
-
stack.concat(Array(node["children"]))
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def session_tree_tool_calls(roots)
|
|
201
|
-
roots.each_with_object({}) do |root, tool_calls|
|
|
202
|
-
stack = [root]
|
|
203
|
-
seen = {}
|
|
204
|
-
until stack.empty?
|
|
205
|
-
node = stack.pop
|
|
206
|
-
next if seen[node.object_id]
|
|
207
|
-
|
|
208
|
-
seen[node.object_id] = true
|
|
209
|
-
entry = node["entry"] || {}
|
|
210
|
-
message = entry["message"]
|
|
211
|
-
if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
|
|
212
|
-
message_tool_calls(message).each { |tool_call| tool_calls[tool_call_id(tool_call).to_s] = tool_call }
|
|
213
|
-
end
|
|
214
|
-
stack.concat(Array(node["children"]))
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
110
|
def session_tree_tool_display(message, tool_calls_by_id)
|
|
220
111
|
tool_call = tool_calls_by_id[session_tree_message_tool_call_id(message).to_s]
|
|
221
112
|
return SessionTreeToolDisplay.label(tool_call) if tool_call
|
|
@@ -233,12 +124,11 @@ module Kward
|
|
|
233
124
|
end
|
|
234
125
|
|
|
235
126
|
def display_message_text(message)
|
|
236
|
-
|
|
127
|
+
SessionTreeNodes.truncate_text(full_message_text(message))
|
|
237
128
|
end
|
|
238
129
|
|
|
239
130
|
def truncate_session_tree_text(text)
|
|
240
|
-
|
|
241
|
-
normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
|
|
131
|
+
SessionTreeNodes.truncate_text(text)
|
|
242
132
|
end
|
|
243
133
|
|
|
244
134
|
def full_message_text(message)
|
|
@@ -249,10 +139,6 @@ module Kward
|
|
|
249
139
|
MessageAccess.role(message)
|
|
250
140
|
end
|
|
251
141
|
|
|
252
|
-
def message_content(message)
|
|
253
|
-
MessageAccess.content(message)
|
|
254
|
-
end
|
|
255
|
-
|
|
256
142
|
def message_name(message)
|
|
257
143
|
MessageAccess.name(message)
|
|
258
144
|
end
|
|
@@ -261,13 +147,5 @@ module Kward
|
|
|
261
147
|
MessageAccess.tool_call_id(message)
|
|
262
148
|
end
|
|
263
149
|
|
|
264
|
-
def message_tool_calls(message)
|
|
265
|
-
MessageAccess.tool_calls(message)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def tool_call_id(tool_call)
|
|
269
|
-
ToolCall.id(tool_call)
|
|
270
|
-
end
|
|
271
|
-
|
|
272
150
|
end
|
|
273
151
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "config_files"
|
|
4
|
+
require_relative "private_file"
|
|
5
|
+
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
7
|
+
module Kward
|
|
8
|
+
# Persists the terminal UI's open session tabs per workspace.
|
|
9
|
+
class TabStore
|
|
10
|
+
def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
|
|
11
|
+
@config_dir = config_dir
|
|
12
|
+
@cwd = File.expand_path(cwd)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load
|
|
16
|
+
return { "session_paths" => [], "active_index" => 0 } unless File.file?(path)
|
|
17
|
+
|
|
18
|
+
data = JSON.parse(File.read(path))
|
|
19
|
+
paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
|
|
20
|
+
labels = Array(data["labels"]).map(&:to_s)
|
|
21
|
+
active_index = data["active_index"].to_i
|
|
22
|
+
{ "session_paths" => paths, "labels" => labels, "active_index" => active_index }
|
|
23
|
+
rescue JSON::ParserError
|
|
24
|
+
{ "session_paths" => [], "active_index" => 0 }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def save(session_paths:, active_index:, labels: [])
|
|
28
|
+
paths = Array(session_paths).map(&:to_s).reject(&:empty?)
|
|
29
|
+
PrivateFile.write_json(path, {
|
|
30
|
+
"cwd" => @cwd,
|
|
31
|
+
"session_paths" => paths,
|
|
32
|
+
"labels" => Array(labels).map(&:to_s),
|
|
33
|
+
"active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
|
|
34
|
+
})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def path
|
|
38
|
+
File.join(@config_dir, "tabs", "#{workspace_key}.json")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def workspace_key
|
|
44
|
+
Digest::SHA256.hexdigest(@cwd)[0, 24]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -8,13 +8,14 @@ require_relative "../rpc/redactor"
|
|
|
8
8
|
module Kward
|
|
9
9
|
# Append-only JSONL telemetry logger with secret-conscious error payloads.
|
|
10
10
|
class TelemetryLogger
|
|
11
|
-
CATEGORIES = %w[tokens performance tools errors].freeze
|
|
11
|
+
CATEGORIES = %w[tokens performance tools errors compaction].freeze
|
|
12
12
|
ENV_KEYS = {
|
|
13
13
|
"enabled" => "KWARD_LOGGING",
|
|
14
14
|
"tokens" => "KWARD_LOGGING_TOKENS",
|
|
15
15
|
"performance" => "KWARD_LOGGING_PERFORMANCE",
|
|
16
16
|
"tools" => "KWARD_LOGGING_TOOLS",
|
|
17
|
-
"errors" => "KWARD_LOGGING_ERRORS"
|
|
17
|
+
"errors" => "KWARD_LOGGING_ERRORS",
|
|
18
|
+
"compaction" => "KWARD_LOGGING_COMPACTION"
|
|
18
19
|
}.freeze
|
|
19
20
|
DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
|
20
21
|
|
|
@@ -101,7 +102,8 @@ module Kward
|
|
|
101
102
|
"tokens" => truthy?(logging["tokens"]),
|
|
102
103
|
"performance" => truthy?(logging["performance"]),
|
|
103
104
|
"tools" => truthy?(logging["tools"]),
|
|
104
|
-
"errors" => truthy?(logging["errors"])
|
|
105
|
+
"errors" => truthy?(logging["errors"]),
|
|
106
|
+
"compaction" => truthy?(logging["compaction"])
|
|
105
107
|
}
|
|
106
108
|
rescue StandardError
|
|
107
109
|
CATEGORIES.each_with_object({ "enabled" => false }) { |category, result| result[category] = false }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Small text navigation helpers shared by composer and editor buffers.
|
|
4
|
+
module TextBoundary
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def previous_word_boundary(text, index)
|
|
8
|
+
cursor = index.to_i
|
|
9
|
+
cursor -= 1 while cursor.positive? && word_separator?(text[cursor - 1])
|
|
10
|
+
cursor -= 1 while cursor.positive? && !word_separator?(text[cursor - 1])
|
|
11
|
+
cursor
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def next_word_boundary(text, index)
|
|
15
|
+
cursor = index.to_i
|
|
16
|
+
cursor += 1 while cursor < text.length && word_separator?(text[cursor])
|
|
17
|
+
cursor += 1 while cursor < text.length && !word_separator?(text[cursor])
|
|
18
|
+
cursor
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def word_separator?(char)
|
|
22
|
+
char.to_s.match?(/\s/)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Deterministically trims large tool outputs before they are appended to the
|
|
4
|
+
# model-facing transcript.
|
|
5
|
+
#
|
|
6
|
+
# The original output is still handed to session/tool-execution persistence by
|
|
7
|
+
# ToolRegistry; this object only decides what the next model call sees. Keep it
|
|
8
|
+
# conservative: small outputs and short errors are more valuable verbatim than
|
|
9
|
+
# compacted.
|
|
10
|
+
class ToolOutputCompactor
|
|
11
|
+
MIN_BYTES = 12 * 1024
|
|
12
|
+
ERROR_OUTPUT_MAX_BYTES = 8 * 1024
|
|
13
|
+
HEAD_LINES = 40
|
|
14
|
+
TAIL_LINES = 40
|
|
15
|
+
ERROR_CONTEXT_LINES = 2
|
|
16
|
+
|
|
17
|
+
ERROR_PATTERN = /\b(error|fatal|failed|failure|exception|traceback|panic|segmentation fault|assertion)\b/i.freeze
|
|
18
|
+
TEST_PATTERN = /(^\s*\d+\)\s|\b(\d+\s+(tests?|examples?|runs?|assertions?|failures?|errors?|skips?)|finished in|failures?:|seed\s+\d+)\b)/i.freeze
|
|
19
|
+
SEARCH_PATTERN = /(^\#{1,6}\s+\S+|^[-*]\s+\S+|\S+:\d+:|https?:\/\/\S+)/.freeze
|
|
20
|
+
|
|
21
|
+
def compact(tool_name, content, artifact_id: nil)
|
|
22
|
+
text = normalize(content)
|
|
23
|
+
return text unless text.bytesize > MIN_BYTES
|
|
24
|
+
return text if error_output?(text) && text.bytesize <= ERROR_OUTPUT_MAX_BYTES
|
|
25
|
+
|
|
26
|
+
compacted = tool_name.to_s == "run_shell_command" ? compact_shell_output(text) : compact_lines(text)
|
|
27
|
+
return text if compacted == text
|
|
28
|
+
return text if compacted.bytesize >= text.bytesize
|
|
29
|
+
|
|
30
|
+
artifact_id = yield if artifact_id.nil? && block_given?
|
|
31
|
+
header = compacted_header(tool_name, text, compacted, artifact_id: artifact_id)
|
|
32
|
+
candidate = "#{header}\n\n#{compacted}"
|
|
33
|
+
candidate.bytesize < text.bytesize ? candidate : text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def normalize(content)
|
|
39
|
+
return content unless content.is_a?(String)
|
|
40
|
+
|
|
41
|
+
Conversation.normalize_tool_content(content)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def error_output?(text)
|
|
45
|
+
text.match?(ERROR_PATTERN)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def compact_shell_output(text)
|
|
49
|
+
sections = shell_sections(text)
|
|
50
|
+
return compact_lines(text) if sections.empty?
|
|
51
|
+
|
|
52
|
+
sections.map do |heading, body|
|
|
53
|
+
next heading if body.empty?
|
|
54
|
+
|
|
55
|
+
"#{heading}\n#{compact_lines(body)}"
|
|
56
|
+
end.join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def shell_sections(text)
|
|
60
|
+
parts = text.split(/\n(?=STDOUT:\n|STDERR:\n)/)
|
|
61
|
+
return [] if parts.length < 2
|
|
62
|
+
|
|
63
|
+
parts.map do |part|
|
|
64
|
+
heading, body = part.split("\n", 2)
|
|
65
|
+
[heading, body.to_s]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compact_lines(text)
|
|
70
|
+
lines = text.split("\n", -1)
|
|
71
|
+
selected = selected_line_indexes(lines)
|
|
72
|
+
return text if selected.length >= lines.length
|
|
73
|
+
|
|
74
|
+
render_selected_lines(lines, selected)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def selected_line_indexes(lines)
|
|
78
|
+
indexes = []
|
|
79
|
+
indexes.concat((0...[HEAD_LINES, lines.length].min).to_a)
|
|
80
|
+
indexes.concat(priority_context_indexes(lines))
|
|
81
|
+
|
|
82
|
+
tail_start = [lines.length - TAIL_LINES, 0].max
|
|
83
|
+
indexes.concat((tail_start...lines.length).to_a)
|
|
84
|
+
indexes.uniq.sort
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def priority_context_indexes(lines)
|
|
88
|
+
indexes = []
|
|
89
|
+
lines.each_with_index do |line, index|
|
|
90
|
+
next unless line.match?(ERROR_PATTERN) || line.match?(TEST_PATTERN) || line.match?(SEARCH_PATTERN)
|
|
91
|
+
|
|
92
|
+
first = [index - ERROR_CONTEXT_LINES, 0].max
|
|
93
|
+
last = [index + ERROR_CONTEXT_LINES, lines.length - 1].min
|
|
94
|
+
indexes.concat((first..last).to_a)
|
|
95
|
+
end
|
|
96
|
+
indexes
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def render_selected_lines(lines, selected)
|
|
100
|
+
output = []
|
|
101
|
+
previous = nil
|
|
102
|
+
selected.each do |index|
|
|
103
|
+
if previous && index > previous + 1
|
|
104
|
+
output << "[... omitted lines #{previous + 2}-#{index} ...]"
|
|
105
|
+
end
|
|
106
|
+
output << lines[index]
|
|
107
|
+
previous = index
|
|
108
|
+
end
|
|
109
|
+
output.join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def compacted_header(tool_name, original, compacted, artifact_id: nil)
|
|
113
|
+
[
|
|
114
|
+
"[Tool output compacted by Kward: #{original.bytesize} bytes -> #{compacted.bytesize} bytes]",
|
|
115
|
+
"Tool: #{tool_name}",
|
|
116
|
+
"Preserved first #{HEAD_LINES} lines, last #{TAIL_LINES} lines, and error/failure/search context.",
|
|
117
|
+
retrieval_instruction(artifact_id)
|
|
118
|
+
].join("\n")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def retrieval_instruction(artifact_id)
|
|
122
|
+
return "Full output is retained outside model context." if artifact_id.to_s.empty?
|
|
123
|
+
|
|
124
|
+
"Full output id: #{artifact_id}. Use retrieve_tool_output to inspect it."
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/kward/tools/base.rb
CHANGED
|
@@ -24,8 +24,8 @@ module Kward
|
|
|
24
24
|
description: @description,
|
|
25
25
|
parameters: {
|
|
26
26
|
type: "object",
|
|
27
|
-
properties:
|
|
28
|
-
required: @required,
|
|
27
|
+
properties: sorted_properties,
|
|
28
|
+
required: @required.sort,
|
|
29
29
|
additionalProperties: false
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -34,6 +34,12 @@ module Kward
|
|
|
34
34
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
|
+
def sorted_properties
|
|
38
|
+
@properties.keys.sort_by(&:to_s).each_with_object({}) do |key, result|
|
|
39
|
+
result[key] = @properties[key]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
37
43
|
# Reads a tool argument while accepting symbol or string keys from restored calls.
|
|
38
44
|
def argument(args, key, default = nil)
|
|
39
45
|
return args[key] if args.key?(key)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
4
|
+
module Kward
|
|
5
|
+
# Model-callable tool wrappers and their argument schemas.
|
|
6
|
+
module Tools
|
|
7
|
+
# Reports approximate context budget savings for the current process.
|
|
8
|
+
class ContextBudgetStats < Base
|
|
9
|
+
def initialize(context_budget_meter: nil)
|
|
10
|
+
@context_budget_meter = context_budget_meter
|
|
11
|
+
super(
|
|
12
|
+
"context_budget_stats",
|
|
13
|
+
"Return approximate per-session context budget savings from tool output compaction and deduplication.",
|
|
14
|
+
properties: {},
|
|
15
|
+
required: []
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(_args, conversation, cancellation: nil)
|
|
20
|
+
cancellation&.raise_if_cancelled!
|
|
21
|
+
meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
|
|
22
|
+
return "Error: context budget stats are unavailable" unless meter
|
|
23
|
+
|
|
24
|
+
snapshot = meter.snapshot
|
|
25
|
+
lines = [
|
|
26
|
+
"# Context budget stats",
|
|
27
|
+
"- Calls: #{snapshot.calls}",
|
|
28
|
+
"- Original bytes: #{snapshot.original_bytes}",
|
|
29
|
+
"- Returned bytes: #{snapshot.returned_bytes}",
|
|
30
|
+
"- Saved bytes: #{snapshot.saved_bytes}",
|
|
31
|
+
"- Estimated tokens saved: #{estimated_tokens(snapshot.saved_bytes)}"
|
|
32
|
+
]
|
|
33
|
+
lines.concat(tool_breakdown_lines(snapshot.tool_breakdown))
|
|
34
|
+
lines.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def tool_breakdown_lines(tool_breakdown)
|
|
40
|
+
return [] if tool_breakdown.empty?
|
|
41
|
+
|
|
42
|
+
lines = ["", "## By tool"]
|
|
43
|
+
tool_breakdown.sort_by { |tool, data| [-data[:savedBytes], tool] }.each do |tool, data|
|
|
44
|
+
lines << "- #{tool}: #{data[:calls]} call(s), #{data[:savedBytes]} bytes saved, #{data[:returnedBytes]}/#{data[:originalBytes]} bytes returned"
|
|
45
|
+
end
|
|
46
|
+
lines
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def estimated_tokens(bytes)
|
|
50
|
+
(bytes.to_i / 4.0).ceil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|