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
|
@@ -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
|
|
@@ -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,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
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require_relative "../workspace"
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
# Namespace for the Kward CLI agent runtime.
|
|
6
|
+
module Kward
|
|
7
|
+
# Model-callable tool wrappers and their argument schemas.
|
|
8
|
+
module Tools
|
|
9
|
+
# Builds a compact, task-shaped bundle from likely workspace files.
|
|
10
|
+
class ContextForTask < Base
|
|
11
|
+
DEFAULT_BUDGET = 4_000
|
|
12
|
+
MAX_BUDGET = 20_000
|
|
13
|
+
MAX_FILES = 8
|
|
14
|
+
MAX_MATCHES_PER_FILE = 8
|
|
15
|
+
DEFAULT_EXTENSIONS = %w[.rb .js .jsx .ts .tsx .py .go .rs .java .cs .cpp .c .h .hpp .md .yml .yaml .json .toml].freeze
|
|
16
|
+
SKIP_DIRECTORIES = %w[.git .yardoc _yardoc node_modules vendor tmp log coverage dist build .bundle].freeze
|
|
17
|
+
|
|
18
|
+
# Builds the tool schema and stores the execution dependency.
|
|
19
|
+
def initialize(workspace:)
|
|
20
|
+
@workspace = workspace
|
|
21
|
+
super(
|
|
22
|
+
"context_for_task",
|
|
23
|
+
"Build focused workspace context for a task from outlines and matching excerpts within a byte budget.",
|
|
24
|
+
properties: {
|
|
25
|
+
budget: { type: "integer", description: "Approximate byte budget for the returned context. Default 4000, maximum 20000." },
|
|
26
|
+
paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative files or directories to focus." },
|
|
27
|
+
task: { type: "string", description: "Task or question to gather context for." }
|
|
28
|
+
},
|
|
29
|
+
required: ["task"]
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Executes focused context retrieval.
|
|
34
|
+
def call(args, _conversation, cancellation: nil)
|
|
35
|
+
cancellation&.raise_if_cancelled!
|
|
36
|
+
task = argument(args, :task, "").to_s.strip
|
|
37
|
+
return "Error: task is required" if task.empty?
|
|
38
|
+
|
|
39
|
+
budget = normalized_budget(argument(args, :budget))
|
|
40
|
+
return budget if budget.is_a?(String)
|
|
41
|
+
|
|
42
|
+
focus_paths = Array(argument(args, :paths, [])).map(&:to_s).reject(&:empty?)
|
|
43
|
+
files = candidate_files(focus_paths, cancellation: cancellation)
|
|
44
|
+
return "No readable candidate files found for focused context." if files.empty?
|
|
45
|
+
|
|
46
|
+
terms = search_terms(task)
|
|
47
|
+
ranked = rank_files(files, terms)
|
|
48
|
+
render_context(task: task, budget: budget, terms: terms, ranked: ranked, cancellation: cancellation)
|
|
49
|
+
rescue SecurityError, Errno::ENOENT => e
|
|
50
|
+
"Error: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def normalized_budget(value)
|
|
56
|
+
return DEFAULT_BUDGET if value.nil?
|
|
57
|
+
|
|
58
|
+
budget = value.to_i
|
|
59
|
+
return "Error: budget must be positive" unless budget.positive?
|
|
60
|
+
|
|
61
|
+
[budget, MAX_BUDGET].min
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def candidate_files(paths, cancellation:)
|
|
65
|
+
roots = paths.empty? ? [@workspace.root.to_s] : paths.map { |path| @workspace.resolved_path(path) }
|
|
66
|
+
files = roots.flat_map do |path|
|
|
67
|
+
cancellation&.raise_if_cancelled!
|
|
68
|
+
File.directory?(path) ? files_under(path, cancellation: cancellation) : [path]
|
|
69
|
+
end
|
|
70
|
+
files.uniq.select { |path| readable_context_file?(path) }.first(MAX_FILES * 8)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def files_under(root, cancellation:)
|
|
74
|
+
files = []
|
|
75
|
+
stack = [root]
|
|
76
|
+
until stack.empty? || files.length >= MAX_FILES * 8
|
|
77
|
+
cancellation&.raise_if_cancelled!
|
|
78
|
+
current = stack.pop
|
|
79
|
+
next if skipped_directory?(current)
|
|
80
|
+
|
|
81
|
+
entries = Dir.children(current).sort.map { |entry| File.join(current, entry) }
|
|
82
|
+
entries.each do |entry|
|
|
83
|
+
if File.directory?(entry)
|
|
84
|
+
stack << entry
|
|
85
|
+
else
|
|
86
|
+
files << entry if readable_context_file?(entry)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
files
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def readable_context_file?(path)
|
|
94
|
+
return false unless File.file?(path)
|
|
95
|
+
return false if File.size(path) > Workspace::MAX_FILE_BYTES
|
|
96
|
+
return false unless DEFAULT_EXTENSIONS.include?(File.extname(path)) || File.basename(path) == "Gemfile"
|
|
97
|
+
|
|
98
|
+
sample = File.open(path, "rb") { |file| file.read(4096).to_s }
|
|
99
|
+
!sample.include?("\x00")
|
|
100
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def skipped_directory?(path)
|
|
105
|
+
SKIP_DIRECTORIES.include?(File.basename(path))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def search_terms(task)
|
|
109
|
+
task.scan(/[A-Za-z_][A-Za-z0-9_]{2,}/).map(&:downcase).reject { |term| stopword?(term) }.uniq.first(20)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def stopword?(term)
|
|
113
|
+
%w[the and for with from this that into when where what why how fix add update change implement review debug explain failing failure error issue].include?(term)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def rank_files(files, terms)
|
|
117
|
+
scored = files.map do |path|
|
|
118
|
+
content = File.read(path)
|
|
119
|
+
relative = relative_path(path)
|
|
120
|
+
score = score_file(relative, content, terms)
|
|
121
|
+
{ path: path, relative: relative, content: content, score: score }
|
|
122
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
123
|
+
nil
|
|
124
|
+
end.compact
|
|
125
|
+
filtered = terms.empty? ? scored : scored.select { |file| file[:score].positive? }
|
|
126
|
+
filtered.sort_by { |file| [-file[:score], file[:relative]] }.first(MAX_FILES)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def score_file(relative, content, terms)
|
|
130
|
+
haystack = "#{relative}\n#{content}".downcase
|
|
131
|
+
terms.sum { |term| haystack.scan(term).length } + (terms.any? { |term| relative.downcase.include?(term) } ? 5 : 0)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def render_context(task:, budget:, terms:, ranked:, cancellation:)
|
|
135
|
+
lines = ["# Focused context", "- Task: #{task}", "- Budget: #{budget} bytes", "- Search terms: #{terms.empty? ? '(none)' : terms.join(', ')}", ""]
|
|
136
|
+
used = lines.join("\n").bytesize
|
|
137
|
+
|
|
138
|
+
ranked.each do |file|
|
|
139
|
+
cancellation&.raise_if_cancelled!
|
|
140
|
+
section = file_section(file, terms)
|
|
141
|
+
break if used + section.bytesize > budget && lines.length > 5
|
|
142
|
+
|
|
143
|
+
if used + section.bytesize > budget
|
|
144
|
+
remaining = budget - used
|
|
145
|
+
break if remaining < 200
|
|
146
|
+
|
|
147
|
+
section = section.byteslice(0, remaining).to_s.scrub << "\n[Context budget reached.]"
|
|
148
|
+
end
|
|
149
|
+
lines << section
|
|
150
|
+
used += section.bytesize
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
lines.join("\n")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def file_section(file, terms)
|
|
157
|
+
outline = @workspace.summarize_file_structure(file[:relative])
|
|
158
|
+
matches = matching_excerpt(file[:content], terms)
|
|
159
|
+
parts = ["## #{file[:relative]}", "- Score: #{file[:score]}"]
|
|
160
|
+
parts << outline unless outline.start_with?("No recognizable source structure")
|
|
161
|
+
parts << matches unless matches.empty?
|
|
162
|
+
parts.join("\n") << "\n"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def matching_excerpt(content, terms)
|
|
166
|
+
return "" if terms.empty?
|
|
167
|
+
|
|
168
|
+
lines = content.split("\n", -1)
|
|
169
|
+
indexes = matching_indexes(lines, terms)
|
|
170
|
+
return "" if indexes.empty?
|
|
171
|
+
|
|
172
|
+
selected = indexes.flat_map { |index| ([index - 2, 0].max..[index + 2, lines.length - 1].min).to_a }.uniq.sort
|
|
173
|
+
render_excerpt(lines, selected)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def matching_indexes(lines, terms)
|
|
177
|
+
indexes = []
|
|
178
|
+
lines.each_with_index do |line, index|
|
|
179
|
+
lower = line.downcase
|
|
180
|
+
indexes << index if terms.any? { |term| lower.include?(term) }
|
|
181
|
+
break if indexes.length >= MAX_MATCHES_PER_FILE
|
|
182
|
+
end
|
|
183
|
+
indexes
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def render_excerpt(lines, selected)
|
|
187
|
+
output = ["### Matching excerpts"]
|
|
188
|
+
previous = nil
|
|
189
|
+
selected.each do |index|
|
|
190
|
+
output << "..." if previous && index > previous + 1
|
|
191
|
+
output << "%4d: %s" % [index + 1, lines[index]]
|
|
192
|
+
previous = index
|
|
193
|
+
end
|
|
194
|
+
output.join("\n")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def relative_path(path)
|
|
198
|
+
Pathname.new(path).relative_path_from(@workspace.root).to_s
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -11,11 +11,13 @@ module Kward
|
|
|
11
11
|
@workspace = workspace
|
|
12
12
|
super(
|
|
13
13
|
"read_file",
|
|
14
|
-
"Read a workspace text file. Output is capped; use offset/limit to
|
|
14
|
+
"Read a workspace text file. Output is capped; use offset/limit or mode to control context budget.",
|
|
15
15
|
properties: {
|
|
16
16
|
path: { type: "string", description: "Workspace-relative path." },
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
limit: { type: "integer", description: "Maximum lines to return." },
|
|
18
|
+
max_bytes: { type: "integer", description: "Optional byte budget for this read, capped by Kward's workspace read limit." },
|
|
19
|
+
mode: { type: "string", enum: %w[preview outline range full], description: "Context mode. preview returns a short slice, outline returns source declarations, range reads offset/limit, full reads until Kward's cap." },
|
|
20
|
+
offset: { type: "integer", description: "1-indexed start line." }
|
|
19
21
|
},
|
|
20
22
|
required: ["path"]
|
|
21
23
|
)
|
|
@@ -26,7 +28,9 @@ module Kward
|
|
|
26
28
|
path = argument(args, :path, "")
|
|
27
29
|
offset = argument(args, :offset)
|
|
28
30
|
limit = argument(args, :limit)
|
|
29
|
-
|
|
31
|
+
mode = argument(args, :mode)
|
|
32
|
+
max_bytes = argument(args, :max_bytes)
|
|
33
|
+
content = @workspace.read_file(path, offset: offset, limit: limit, mode: mode, max_bytes: max_bytes)
|
|
30
34
|
conversation.mark_read(@workspace.resolved_path(path)) unless content.start_with?("Error:")
|
|
31
35
|
content
|
|
32
36
|
end
|