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,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
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
require_relative "../config_files"
|
|
2
|
+
require_relative "../conversation"
|
|
2
3
|
require_relative "ask_user_question"
|
|
3
4
|
require_relative "code_search"
|
|
5
|
+
require_relative "context_budget_stats"
|
|
6
|
+
require_relative "context_for_task"
|
|
4
7
|
require_relative "edit_file"
|
|
5
8
|
require_relative "fetch_content"
|
|
6
9
|
require_relative "fetch_raw"
|
|
@@ -8,12 +11,16 @@ require_relative "list_directory"
|
|
|
8
11
|
require_relative "read_file"
|
|
9
12
|
require_relative "read_skill"
|
|
10
13
|
require_relative "run_shell_command"
|
|
14
|
+
require_relative "summarize_file_structure"
|
|
15
|
+
require_relative "retrieve_tool_output"
|
|
11
16
|
require_relative "web_search"
|
|
12
17
|
require_relative "write_file"
|
|
13
18
|
require_relative "search/code"
|
|
14
19
|
require_relative "search/web"
|
|
15
20
|
require_relative "search/web_fetch"
|
|
16
21
|
require_relative "tool_call"
|
|
22
|
+
require_relative "../telemetry/logger"
|
|
23
|
+
require_relative "../tool_output_compactor"
|
|
17
24
|
require_relative "../workspace"
|
|
18
25
|
|
|
19
26
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -40,7 +47,7 @@ module Kward
|
|
|
40
47
|
# Tool schemas advertised to the model for the current frontend and config.
|
|
41
48
|
#
|
|
42
49
|
# @return [Array<Hash>] tool schemas currently advertised to the model
|
|
43
|
-
attr_reader :schemas
|
|
50
|
+
attr_reader :schemas, :writer_id
|
|
44
51
|
|
|
45
52
|
# Builds tool objects and the schema list for the current frontend/config.
|
|
46
53
|
#
|
|
@@ -53,7 +60,7 @@ module Kward
|
|
|
53
60
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
54
61
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
55
62
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
56
|
-
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil)
|
|
63
|
+
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, write_lock: nil, writer_id: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil)
|
|
57
64
|
@workspace = workspace
|
|
58
65
|
@prompt = prompt
|
|
59
66
|
@web_search = web_search
|
|
@@ -62,6 +69,12 @@ module Kward
|
|
|
62
69
|
@skills = skills
|
|
63
70
|
@web_search_enabled = web_search_enabled
|
|
64
71
|
@ask_user_question_enabled = ask_user_question_enabled
|
|
72
|
+
@allowed_tool_names = allowed_tool_names&.map(&:to_s)
|
|
73
|
+
@write_lock = write_lock
|
|
74
|
+
@writer_id = writer_id
|
|
75
|
+
@tool_output_compactor = tool_output_compactor
|
|
76
|
+
@telemetry_logger = telemetry_logger
|
|
77
|
+
@context_budget_meter = context_budget_meter
|
|
65
78
|
@tools = build_tools.freeze
|
|
66
79
|
@schemas = build_schema_tools.map(&:schema).freeze
|
|
67
80
|
end
|
|
@@ -82,36 +95,96 @@ module Kward
|
|
|
82
95
|
args = ToolCall.arguments(tool_call)
|
|
83
96
|
tool = @tools[name]
|
|
84
97
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
original_content = if tool
|
|
99
|
+
if mutation_tool?(name) && !write_lock_owned?
|
|
100
|
+
"Workspace write denied: another worker owns the write lock."
|
|
101
|
+
else
|
|
102
|
+
tool.call(args, conversation, cancellation: cancellation)
|
|
103
|
+
end
|
|
104
|
+
else
|
|
105
|
+
"Unknown tool: #{name}"
|
|
106
|
+
end
|
|
107
|
+
original_content = Conversation.normalize_tool_content(original_content)
|
|
108
|
+
duplicate_id = conversation.tool_output_artifact_id_for(tool_name: name, content: original_content)
|
|
109
|
+
content = original_content
|
|
110
|
+
if reusable_duplicate_output?(name) && conversation.tool_output_artifacts.key?(duplicate_id)
|
|
111
|
+
content = "[Same as previous tool output #{duplicate_id}; not repeated. Use retrieve_tool_output to inspect it.]"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
artifact_id = nil
|
|
115
|
+
model_content = @tool_output_compactor.compact(name, content) do
|
|
116
|
+
artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: original_content)
|
|
117
|
+
end
|
|
118
|
+
record_context_budget(conversation, name, before: original_content, after: model_content)
|
|
119
|
+
log_tool_output_compaction(name, artifact_id: artifact_id, before: original_content, after: model_content) if model_content != original_content
|
|
91
120
|
conversation.append_tool(
|
|
92
121
|
tool_call_id: tool_call["id"] || tool_call[:id],
|
|
93
122
|
name: name,
|
|
94
|
-
content:
|
|
123
|
+
content: model_content
|
|
95
124
|
)
|
|
96
|
-
conversation.append_tool_execution(tool_call: tool_call, content:
|
|
125
|
+
conversation.append_tool_execution(tool_call: tool_call, content: original_content)
|
|
97
126
|
|
|
98
|
-
|
|
127
|
+
model_content
|
|
99
128
|
end
|
|
100
129
|
|
|
101
130
|
private
|
|
102
131
|
|
|
132
|
+
def record_context_budget(conversation, name, before:, after:)
|
|
133
|
+
meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
|
|
134
|
+
return unless meter
|
|
135
|
+
return if name.to_s == "context_budget_stats"
|
|
136
|
+
|
|
137
|
+
saved = meter.record(tool_name: name, original_bytes: before.bytesize, returned_bytes: after.bytesize)
|
|
138
|
+
@telemetry_logger.log(
|
|
139
|
+
"compaction",
|
|
140
|
+
"context_budget",
|
|
141
|
+
"tool_name" => name,
|
|
142
|
+
"bytes_before" => before.bytesize,
|
|
143
|
+
"bytes_after" => after.bytesize,
|
|
144
|
+
"bytes_saved" => saved
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def reusable_duplicate_output?(name)
|
|
149
|
+
name.to_s != "read_skill"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def log_tool_output_compaction(name, artifact_id:, before:, after:)
|
|
153
|
+
@telemetry_logger.log(
|
|
154
|
+
"compaction",
|
|
155
|
+
"tool_output",
|
|
156
|
+
"tool_name" => name,
|
|
157
|
+
"artifact_id" => artifact_id,
|
|
158
|
+
"bytes_before" => before.bytesize,
|
|
159
|
+
"bytes_after" => after.bytesize,
|
|
160
|
+
"bytes_saved" => before.bytesize - after.bytesize
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def mutation_tool?(name)
|
|
165
|
+
ToolCall.write_lock_required?(name)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def write_lock_owned?
|
|
169
|
+
return true unless @write_lock
|
|
170
|
+
|
|
171
|
+
@write_lock.owned_by?(@writer_id)
|
|
172
|
+
end
|
|
173
|
+
|
|
103
174
|
def build_tools
|
|
104
|
-
|
|
175
|
+
tools = all_tools
|
|
176
|
+
tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
|
|
177
|
+
tools.to_h { |tool| [tool.name, tool] }
|
|
105
178
|
end
|
|
106
179
|
|
|
107
180
|
def build_schema_tools
|
|
108
181
|
tools = @tools.values_at(
|
|
109
|
-
"list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search"
|
|
182
|
+
"list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "context_for_task", "context_budget_stats", "retrieve_tool_output"
|
|
110
183
|
)
|
|
111
184
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
112
185
|
tools << @tools["read_skill"] if skills_available?
|
|
113
186
|
tools << @tools["ask_user_question"] if ask_user_question_available?
|
|
114
|
-
tools
|
|
187
|
+
tools.compact
|
|
115
188
|
end
|
|
116
189
|
|
|
117
190
|
def all_tools
|
|
@@ -131,7 +204,11 @@ module Kward
|
|
|
131
204
|
Tools::WriteFile.new(workspace: @workspace),
|
|
132
205
|
Tools::EditFile.new(workspace: @workspace),
|
|
133
206
|
Tools::RunShellCommand.new(workspace: @workspace),
|
|
134
|
-
Tools::CodeSearch.new(code_search: @code_search)
|
|
207
|
+
Tools::CodeSearch.new(code_search: @code_search),
|
|
208
|
+
Tools::SummarizeFileStructure.new(workspace: @workspace),
|
|
209
|
+
Tools::ContextForTask.new(workspace: @workspace),
|
|
210
|
+
Tools::ContextBudgetStats.new(context_budget_meter: @context_budget_meter),
|
|
211
|
+
Tools::RetrieveToolOutput.new
|
|
135
212
|
]
|
|
136
213
|
end
|
|
137
214
|
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
# Retrieves original tool outputs that were compacted out of model context.
|
|
8
|
+
class RetrieveToolOutput < Base
|
|
9
|
+
DEFAULT_LIMIT = 120
|
|
10
|
+
|
|
11
|
+
# Builds the retrieval tool schema.
|
|
12
|
+
def initialize
|
|
13
|
+
super(
|
|
14
|
+
"retrieve_tool_output",
|
|
15
|
+
"Retrieve original output from a compacted previous tool result.",
|
|
16
|
+
properties: {
|
|
17
|
+
id: { type: "string", description: "Tool output id, for example toolout_abc123." },
|
|
18
|
+
query: { type: "string", description: "Optional case-insensitive text search within the original output." },
|
|
19
|
+
offset: { type: "integer", description: "1-indexed line offset for returned output." },
|
|
20
|
+
limit: { type: "integer", description: "Maximum lines to return; default 120." }
|
|
21
|
+
},
|
|
22
|
+
required: ["id"]
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Executes retrieval from the active conversation artifact store.
|
|
27
|
+
def call(args, conversation, cancellation: nil)
|
|
28
|
+
cancellation&.raise_if_cancelled!
|
|
29
|
+
id = argument(args, :id, "").to_s
|
|
30
|
+
return "Error: id is required" if id.empty?
|
|
31
|
+
|
|
32
|
+
artifact = conversation.tool_output_artifacts[id]
|
|
33
|
+
return "Error: unknown tool output id: #{id}" unless artifact
|
|
34
|
+
|
|
35
|
+
content = artifact[:content].to_s
|
|
36
|
+
query = argument(args, :query, "").to_s
|
|
37
|
+
lines = query.empty? ? content.split("\n", -1) : matching_lines(content, query)
|
|
38
|
+
return "No matching lines for #{query.inspect} in #{id}." if lines.empty?
|
|
39
|
+
|
|
40
|
+
slice_lines(id, lines, offset: argument(args, :offset), limit: argument(args, :limit), query: query)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def matching_lines(content, query)
|
|
46
|
+
needle = query.downcase
|
|
47
|
+
content.split("\n", -1).each_with_index.filter_map do |line, index|
|
|
48
|
+
next unless line.downcase.include?(needle)
|
|
49
|
+
|
|
50
|
+
"#{index + 1}: #{line}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def slice_lines(id, lines, offset:, limit:, query:)
|
|
55
|
+
start_index = [offset.to_i - 1, 0].max
|
|
56
|
+
return "Error: offset #{offset} is beyond output (#{lines.length} lines total)" if start_index >= lines.length
|
|
57
|
+
|
|
58
|
+
line_limit = limit.to_i.positive? ? limit.to_i : DEFAULT_LIMIT
|
|
59
|
+
selected = lines[start_index, line_limit] || []
|
|
60
|
+
header = "[Retrieved tool output #{id}"
|
|
61
|
+
header << " matching #{query.inspect}" unless query.empty?
|
|
62
|
+
header << ": lines #{start_index + 1}-#{start_index + selected.length} of #{lines.length}]"
|
|
63
|
+
output = "#{header}\n#{selected.join("\n")}".rstrip
|
|
64
|
+
if start_index + selected.length < lines.length
|
|
65
|
+
output << "\n\n[#{lines.length - start_index - selected.length} more lines. Use offset=#{start_index + selected.length + 1} to continue.]"
|
|
66
|
+
end
|
|
67
|
+
output
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -619,8 +619,8 @@ module Kward
|
|
|
619
619
|
return web_config[snake] if web_config.key?(snake)
|
|
620
620
|
return web_config[camel] if web_config.key?(camel)
|
|
621
621
|
return config[prefixed] if config.key?(prefixed)
|
|
622
|
-
return config[snake] if config.key?(snake)
|
|
623
|
-
return config[camel] if config.key?(camel)
|
|
622
|
+
return config[snake] if key.to_s != "provider" && config.key?(snake)
|
|
623
|
+
return config[camel] if key.to_s != "provider" && config.key?(camel)
|
|
624
624
|
|
|
625
625
|
nil
|
|
626
626
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
# Returns a compact symbol outline for a workspace source file.
|
|
8
|
+
class SummarizeFileStructure < Base
|
|
9
|
+
# Builds the tool schema and stores the execution dependency.
|
|
10
|
+
def initialize(workspace:)
|
|
11
|
+
@workspace = workspace
|
|
12
|
+
super(
|
|
13
|
+
"summarize_file_structure",
|
|
14
|
+
"Return a compact outline of classes, modules, methods, and functions in a workspace source file.",
|
|
15
|
+
properties: {
|
|
16
|
+
path: { type: "string", description: "Workspace-relative source file path." }
|
|
17
|
+
},
|
|
18
|
+
required: ["path"]
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Executes the structure summary tool.
|
|
23
|
+
def call(args, _conversation, cancellation: nil)
|
|
24
|
+
cancellation&.raise_if_cancelled!
|
|
25
|
+
@workspace.summarize_file_structure(argument(args, :path, ""))
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -17,6 +17,10 @@ module Kward
|
|
|
17
17
|
"run_shell_command" => "bash",
|
|
18
18
|
"list_directory" => "list_directory",
|
|
19
19
|
"code_search" => "code_search",
|
|
20
|
+
"summarize_file_structure" => "summarize_file_structure",
|
|
21
|
+
"context_for_task" => "context_for_task",
|
|
22
|
+
"context_budget_stats" => "context_budget_stats",
|
|
23
|
+
"retrieve_tool_output" => "retrieve_tool_output",
|
|
20
24
|
"web_search" => "web_search",
|
|
21
25
|
"fetch_content" => "fetch_content",
|
|
22
26
|
"fetch_raw" => "fetch_raw",
|
|
@@ -63,6 +67,14 @@ module Kward
|
|
|
63
67
|
TOOL_NAME_MAP[name.to_s]
|
|
64
68
|
end
|
|
65
69
|
|
|
70
|
+
def write_lock_required?(name)
|
|
71
|
+
%w[edit_file write_file run_shell_command edit write bash].include?(name.to_s)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def file_change_tool?(name)
|
|
75
|
+
%w[edit_file write_file edit write].include?(name.to_s)
|
|
76
|
+
end
|
|
77
|
+
|
|
66
78
|
# Converts provider argument payloads into hashes.
|
|
67
79
|
#
|
|
68
80
|
# Providers normally send JSON strings, while tests and compatibility callers
|
data/lib/kward/version.rb
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
module Workers
|
|
5
|
+
# Small git boundary used by write-lane workers to keep implementation work isolated.
|
|
6
|
+
class GitGuard
|
|
7
|
+
def initialize(root: Dir.pwd)
|
|
8
|
+
@root = root.to_s
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def repository?
|
|
12
|
+
success?("rev-parse", "--is-inside-work-tree")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def clean?
|
|
16
|
+
return true unless repository?
|
|
17
|
+
|
|
18
|
+
status.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def dirty?
|
|
22
|
+
!clean?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def status
|
|
26
|
+
run("status", "--porcelain").stdout
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def head
|
|
30
|
+
result = run("rev-parse", "--verify", "HEAD")
|
|
31
|
+
result.success? ? result.stdout.strip : nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def commit_all(message)
|
|
35
|
+
add = run("add", "-A")
|
|
36
|
+
return Result.new(success: false, stdout: add.stdout, stderr: add.stderr) unless add.success?
|
|
37
|
+
|
|
38
|
+
commit = run("commit", "-m", message)
|
|
39
|
+
return Result.new(success: false, stdout: commit.stdout, stderr: commit.stderr) unless commit.success?
|
|
40
|
+
|
|
41
|
+
Result.new(success: true, stdout: commit.stdout, stderr: commit.stderr, commit: head)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
|
|
47
|
+
def success?
|
|
48
|
+
success
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def output
|
|
52
|
+
[stdout, stderr].compact.reject(&:empty?).join("\n")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def success?(*args)
|
|
57
|
+
run(*args).success?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run(*args)
|
|
61
|
+
stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
|
|
62
|
+
Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
|
|
63
|
+
rescue Errno::ENOENT
|
|
64
|
+
Result.new(success: false, stdout: "", stderr: "git executable not found")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Kward
|
|
2
|
+
module Workers
|
|
3
|
+
# Drains a running worker's accumulated event history into a frontend renderer.
|
|
4
|
+
class LiveView
|
|
5
|
+
FINISHED_STATUSES = %w[ready failed cancelled archived].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(worker:, agent:, renderer:, poll_interval: 0.05)
|
|
8
|
+
@worker = worker
|
|
9
|
+
@agent = agent
|
|
10
|
+
@renderer = renderer
|
|
11
|
+
@poll_interval = poll_interval
|
|
12
|
+
@seen_events = worker.event_history.length
|
|
13
|
+
@stop = false
|
|
14
|
+
@thread = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :worker, :agent
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
@thread = Thread.new { run }
|
|
21
|
+
@thread.report_on_exception = false
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def stop
|
|
26
|
+
@stop = true
|
|
27
|
+
@thread&.join(0.2)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run
|
|
33
|
+
until @stop
|
|
34
|
+
events = @worker.event_history[@seen_events..] || []
|
|
35
|
+
events.each { |event| @renderer.call(event, @agent) }
|
|
36
|
+
@seen_events += events.length
|
|
37
|
+
@renderer.call(:flush, @agent) if finished?
|
|
38
|
+
break if finished?
|
|
39
|
+
|
|
40
|
+
sleep @poll_interval
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def finished?
|
|
45
|
+
FINISHED_STATUSES.include?(@worker.status)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|