kward 0.67.1 → 0.69.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 +48 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +54 -0
- data/Gemfile.lock +8 -2
- data/README.md +37 -30
- data/Rakefile +14 -1
- data/doc/authentication.md +84 -43
- data/doc/code-search.md +55 -28
- data/doc/configuration.md +27 -2
- data/doc/extensibility.md +90 -129
- data/doc/getting-started.md +53 -57
- data/doc/memory.md +51 -118
- data/doc/personas.md +417 -0
- data/doc/plugins.md +55 -99
- data/doc/releasing.md +10 -9
- data/doc/rpc.md +7 -7
- data/doc/usage.md +125 -141
- data/doc/web-search.md +80 -14
- data/exe/kward +2 -0
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +30 -3
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +229 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +227 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +134 -0
- data/lib/kward/cli/rendering.rb +378 -0
- data/lib/kward/cli/runtime_helpers.rb +170 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +669 -0
- data/lib/kward/cli/slash_commands.rb +114 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/sysprompt.rb +57 -0
- data/lib/kward/cli/tool_summaries.rb +157 -0
- data/lib/kward/cli.rb +52 -2792
- data/lib/kward/cli_transcript_formatter.rb +40 -12
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +31 -9
- data/lib/kward/config_files.rb +78 -34
- data/lib/kward/conversation.rb +110 -13
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +144 -14
- data/lib/kward/message_access.rb +29 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +14 -10
- data/lib/kward/model/model_info.rb +160 -4
- data/lib/kward/model/payloads.rb +254 -22
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +387 -25
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +63 -7
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +43 -11
- data/lib/kward/rpc/session_manager.rb +139 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +50 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +154 -25
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +3 -2
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/fetch_content.rb +41 -0
- data/lib/kward/tools/fetch_raw.rb +40 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +42 -4
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +20 -17
- data/lib/kward/tools/search/web_fetch.rb +202 -0
- data/lib/kward/tools/tool_call.rb +27 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- data/templates/default/fulldoc/html/css/kward.css +1501 -0
- data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
- data/templates/default/fulldoc/html/js/kward.js +296 -0
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/layout/html/breadcrumb.erb +11 -0
- data/templates/default/layout/html/layout.erb +141 -0
- data/templates/default/layout/html/setup.rb +139 -0
- metadata +56 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
require "nokogiri"
|
|
2
|
+
require "uri"
|
|
3
|
+
require_relative "web"
|
|
4
|
+
|
|
5
|
+
# Namespace for the Kward CLI agent runtime.
|
|
6
|
+
module Kward
|
|
7
|
+
# Fetches specific web resources for agent research workflows.
|
|
8
|
+
class WebFetch
|
|
9
|
+
DEFAULT_MAX_BYTES = 16 * 1024
|
|
10
|
+
MAX_MAX_BYTES = 128 * 1024
|
|
11
|
+
MAX_REDIRECTS = 5
|
|
12
|
+
HTTP_TIMEOUT_SECONDS = 10
|
|
13
|
+
|
|
14
|
+
# Creates a fetcher for web content and raw resources.
|
|
15
|
+
def initialize(http_client: WebSearch::NetHttpClient.new)
|
|
16
|
+
@http_client = http_client
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Fetches a URL and extracts readable text for human-facing pages.
|
|
20
|
+
def fetch_content(args)
|
|
21
|
+
url = args_value(args, "url").to_s.strip
|
|
22
|
+
return "Error: url is required" if url.empty?
|
|
23
|
+
|
|
24
|
+
max_bytes = bounded_max_bytes(args_value(args, "max_bytes") || args_value(args, "maxBytes"))
|
|
25
|
+
extract = normalize_extract(args_value(args, "extract") || "auto")
|
|
26
|
+
return "Error: extract must be one of: auto, text, markdown" unless extract
|
|
27
|
+
|
|
28
|
+
response = fetch_url(url, max_bytes: max_bytes)
|
|
29
|
+
return response if response.is_a?(String)
|
|
30
|
+
|
|
31
|
+
body = response[:body].to_s
|
|
32
|
+
content_type = header_value(response[:headers], "content-type")
|
|
33
|
+
text = extract_readable_text(body, content_type: content_type, mode: extract)
|
|
34
|
+
text = truncate_bytes(text, max_bytes)
|
|
35
|
+
|
|
36
|
+
[
|
|
37
|
+
"# Fetched content",
|
|
38
|
+
"- URL: #{response[:url]}",
|
|
39
|
+
"- Content type: #{content_type.empty? ? "unknown" : content_type}",
|
|
40
|
+
"- Bytes returned: #{text.bytesize}",
|
|
41
|
+
"",
|
|
42
|
+
text.empty? ? "(No readable text extracted.)" : text
|
|
43
|
+
].join("\n")
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
"Error: fetch_content failed: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fetches a URL and returns bounded raw response content.
|
|
49
|
+
def fetch_raw(args)
|
|
50
|
+
url = args_value(args, "url").to_s.strip
|
|
51
|
+
return "Error: url is required" if url.empty?
|
|
52
|
+
|
|
53
|
+
max_bytes = bounded_max_bytes(args_value(args, "max_bytes") || args_value(args, "maxBytes"))
|
|
54
|
+
accept = args_value(args, "accept").to_s.strip
|
|
55
|
+
response = fetch_url(url, max_bytes: max_bytes, accept: accept.empty? ? "*/*" : accept)
|
|
56
|
+
return response if response.is_a?(String)
|
|
57
|
+
|
|
58
|
+
body = truncate_bytes(response[:body].to_s, max_bytes)
|
|
59
|
+
content_type = header_value(response[:headers], "content-type")
|
|
60
|
+
[
|
|
61
|
+
"# Fetched raw content",
|
|
62
|
+
"- URL: #{response[:url]}",
|
|
63
|
+
"- Content type: #{content_type.empty? ? "unknown" : content_type}",
|
|
64
|
+
"- Bytes returned: #{body.bytesize}",
|
|
65
|
+
"",
|
|
66
|
+
body
|
|
67
|
+
].join("\n")
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
"Error: fetch_raw failed: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def fetch_url(url, max_bytes:, accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8")
|
|
75
|
+
current_url = normalize_url(url)
|
|
76
|
+
redirects = 0
|
|
77
|
+
|
|
78
|
+
loop do
|
|
79
|
+
response = @http_client.get(current_url, headers: browser_headers(accept))
|
|
80
|
+
code = response.code.to_i
|
|
81
|
+
headers = response_headers(response)
|
|
82
|
+
|
|
83
|
+
if redirect?(code)
|
|
84
|
+
return "Error: too many redirects" if redirects >= MAX_REDIRECTS
|
|
85
|
+
|
|
86
|
+
location = header_value(headers, "location")
|
|
87
|
+
return "Error: redirect missing Location header" if location.empty?
|
|
88
|
+
|
|
89
|
+
current_url = normalize_url(URI.join(current_url, location).to_s)
|
|
90
|
+
redirects += 1
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return "Error: fetch failed with HTTP #{response.code}" unless code.between?(200, 299)
|
|
95
|
+
|
|
96
|
+
body = response.body.to_s
|
|
97
|
+
body = truncate_bytes(body, max_bytes)
|
|
98
|
+
return { url: current_url, headers: headers, body: body }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalize_url(value)
|
|
103
|
+
uri = URI.parse(value.to_s.strip)
|
|
104
|
+
raise "url must use http or https" unless %w[http https].include?(uri.scheme)
|
|
105
|
+
raise "url host is required" if uri.host.to_s.empty?
|
|
106
|
+
|
|
107
|
+
uri.to_s
|
|
108
|
+
rescue URI::InvalidURIError
|
|
109
|
+
raise "invalid url"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def response_headers(response)
|
|
113
|
+
return {} unless response.respond_to?(:headers) && response.headers.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
response.headers.transform_keys { |key| key.to_s.downcase }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def header_value(headers, key)
|
|
119
|
+
headers[key.to_s.downcase].to_s
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def redirect?(code)
|
|
123
|
+
[301, 302, 303, 307, 308].include?(code)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def bounded_max_bytes(value)
|
|
127
|
+
number = value.to_i
|
|
128
|
+
number = DEFAULT_MAX_BYTES if number <= 0
|
|
129
|
+
[number, MAX_MAX_BYTES].min
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def normalize_extract(value)
|
|
133
|
+
normalized = value.to_s.strip.downcase
|
|
134
|
+
%w[auto text markdown].include?(normalized) ? normalized : nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def extract_readable_text(body, content_type:, mode:)
|
|
138
|
+
return clean_text(body) if mode == "text" || !html_content?(content_type, body)
|
|
139
|
+
|
|
140
|
+
document = Nokogiri::HTML(body)
|
|
141
|
+
document.css("script, style, noscript, svg, nav, footer, form").remove
|
|
142
|
+
title = document.at_css("title")&.text.to_s.strip
|
|
143
|
+
root = document.at_css("article") || document.at_css("main") || document.at_css("body") || document
|
|
144
|
+
parts = []
|
|
145
|
+
parts << "# #{clean_text(title)}" unless title.empty?
|
|
146
|
+
root.css("h1, h2, h3, h4, h5, h6, p, li, pre, code, blockquote").each do |node|
|
|
147
|
+
text = clean_text(node.text)
|
|
148
|
+
next if text.empty?
|
|
149
|
+
|
|
150
|
+
parts << format_html_node(node, text, mode: mode)
|
|
151
|
+
end
|
|
152
|
+
parts.uniq.join("\n\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def html_content?(content_type, body)
|
|
156
|
+
content_type.to_s.include?("html") || body.to_s.lstrip.start_with?("<!DOCTYPE html", "<html", "<HTML")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def format_html_node(node, text, mode:)
|
|
160
|
+
return text if mode == "text"
|
|
161
|
+
|
|
162
|
+
case node.name
|
|
163
|
+
when /^h([1-6])$/
|
|
164
|
+
"#{"#" * Regexp.last_match(1).to_i} #{text}"
|
|
165
|
+
when "li"
|
|
166
|
+
"- #{text}"
|
|
167
|
+
when "pre", "code"
|
|
168
|
+
"```\n#{text}\n```"
|
|
169
|
+
when "blockquote"
|
|
170
|
+
"> #{text}"
|
|
171
|
+
else
|
|
172
|
+
text
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def clean_text(text)
|
|
177
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def truncate_bytes(text, max_bytes)
|
|
181
|
+
return text if text.bytesize <= max_bytes
|
|
182
|
+
|
|
183
|
+
"#{text.byteslice(0, max_bytes).to_s.scrub}\n... truncated to #{max_bytes} bytes"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def browser_headers(accept)
|
|
187
|
+
{
|
|
188
|
+
"Accept" => accept,
|
|
189
|
+
"Accept-Language" => "en-US,en;q=0.9",
|
|
190
|
+
"User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def args_value(args, key)
|
|
195
|
+
return nil unless args.is_a?(Hash)
|
|
196
|
+
return args[key] if args.key?(key)
|
|
197
|
+
return args[key.to_sym] if args.key?(key.to_sym)
|
|
198
|
+
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require_relative "../message_access"
|
|
2
3
|
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
5
|
module Kward
|
|
6
|
+
# Reads and normalizes model tool-call hashes.
|
|
7
|
+
#
|
|
8
|
+
# Tool calls arrive from several providers and may be restored from session
|
|
9
|
+
# files. This module keeps provider/string/symbol compatibility in one place
|
|
10
|
+
# and exposes small helpers used by the agent loop, tool registry, transcript
|
|
11
|
+
# formatters, and RPC event normalizers.
|
|
4
12
|
module ToolCall
|
|
5
13
|
TOOL_NAME_MAP = {
|
|
6
14
|
"read_file" => "read",
|
|
@@ -8,26 +16,37 @@ module Kward
|
|
|
8
16
|
"write_file" => "write",
|
|
9
17
|
"run_shell_command" => "bash",
|
|
10
18
|
"list_directory" => "list_directory",
|
|
19
|
+
"code_search" => "code_search",
|
|
11
20
|
"web_search" => "web_search",
|
|
21
|
+
"fetch_content" => "fetch_content",
|
|
22
|
+
"fetch_raw" => "fetch_raw",
|
|
12
23
|
"read_skill" => "read_skill",
|
|
13
24
|
"ask_user_question" => "ask_user_question"
|
|
14
25
|
}.freeze
|
|
15
26
|
|
|
16
27
|
module_function
|
|
17
28
|
|
|
29
|
+
# @return [String, nil] provider tool-call id
|
|
18
30
|
def id(tool_call)
|
|
19
31
|
value(tool_call, :id)
|
|
20
32
|
end
|
|
21
33
|
|
|
34
|
+
# @return [String, nil] requested tool/function name
|
|
22
35
|
def name(tool_call)
|
|
23
36
|
value(function(tool_call), :name)
|
|
24
37
|
end
|
|
25
38
|
|
|
39
|
+
# Returns the short name used in compact UI labels.
|
|
40
|
+
#
|
|
41
|
+
# @return [String] display label such as `read`, `edit`, or `bash`
|
|
26
42
|
def display_name(tool_call)
|
|
27
43
|
raw_name = name(tool_call)
|
|
28
44
|
normalized_name(raw_name) || raw_name || "unknown_tool"
|
|
29
45
|
end
|
|
30
46
|
|
|
47
|
+
# Parses the requested tool arguments.
|
|
48
|
+
#
|
|
49
|
+
# @return [Hash] decoded argument object, or an empty hash for invalid JSON
|
|
31
50
|
def arguments(tool_call)
|
|
32
51
|
parse_arguments(raw_arguments(tool_call))
|
|
33
52
|
end
|
|
@@ -44,6 +63,10 @@ module Kward
|
|
|
44
63
|
TOOL_NAME_MAP[name.to_s]
|
|
45
64
|
end
|
|
46
65
|
|
|
66
|
+
# Converts provider argument payloads into hashes.
|
|
67
|
+
#
|
|
68
|
+
# Providers normally send JSON strings, while tests and compatibility callers
|
|
69
|
+
# may pass hashes directly.
|
|
47
70
|
def parse_arguments(arguments)
|
|
48
71
|
return {} if arguments.nil? || (arguments.respond_to?(:empty?) && arguments.empty?)
|
|
49
72
|
return arguments if arguments.is_a?(Hash)
|
|
@@ -53,6 +76,9 @@ module Kward
|
|
|
53
76
|
{}
|
|
54
77
|
end
|
|
55
78
|
|
|
79
|
+
# Recursively converts snake_case hash keys to camelCase symbols.
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash] camelized copy of `args`
|
|
56
82
|
def camelize_args(args)
|
|
57
83
|
return {} unless args.is_a?(Hash)
|
|
58
84
|
|
|
@@ -62,11 +88,7 @@ module Kward
|
|
|
62
88
|
end
|
|
63
89
|
|
|
64
90
|
def value(object, key)
|
|
65
|
-
|
|
66
|
-
return object[key] if object.key?(key)
|
|
67
|
-
return object[key.to_s] if object.key?(key.to_s)
|
|
68
|
-
|
|
69
|
-
nil
|
|
91
|
+
MessageAccess.value(object, key)
|
|
70
92
|
end
|
|
71
93
|
|
|
72
94
|
def camelize_value(item)
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
require_relative "base"
|
|
2
|
+
require_relative "search/web"
|
|
2
3
|
|
|
4
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
5
|
module Kward
|
|
6
|
+
# Model-callable tool wrappers and their argument schemas.
|
|
4
7
|
module Tools
|
|
8
|
+
# Live web-search implementation with provider fallbacks.
|
|
5
9
|
class WebSearch < Base
|
|
10
|
+
# Builds the tool schema and stores the execution dependency.
|
|
6
11
|
def initialize(web_search:)
|
|
7
12
|
@web_search = web_search
|
|
8
13
|
super(
|
|
@@ -22,7 +27,7 @@ module Kward
|
|
|
22
27
|
},
|
|
23
28
|
provider: {
|
|
24
29
|
type: "string",
|
|
25
|
-
enum:
|
|
30
|
+
enum: Kward::WebSearch::PROVIDERS,
|
|
26
31
|
description: "Provider override; default auto."
|
|
27
32
|
},
|
|
28
33
|
recency_filter: {
|
|
@@ -40,6 +45,7 @@ module Kward
|
|
|
40
45
|
)
|
|
41
46
|
end
|
|
42
47
|
|
|
48
|
+
# Executes the tool and returns model-facing output text.
|
|
43
49
|
def call(args, _conversation, cancellation: nil)
|
|
44
50
|
@web_search.search(args)
|
|
45
51
|
end
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
require_relative "base"
|
|
2
2
|
|
|
3
|
+
# Namespace for the Kward CLI agent runtime.
|
|
3
4
|
module Kward
|
|
5
|
+
# Model-callable tool wrappers and their argument schemas.
|
|
4
6
|
module Tools
|
|
7
|
+
# Tool wrapper for guarded full-file writes.
|
|
5
8
|
class WriteFile < Base
|
|
9
|
+
# Builds the tool schema and stores the execution dependency.
|
|
6
10
|
def initialize(workspace:)
|
|
7
11
|
@workspace = workspace
|
|
8
12
|
super(
|
|
@@ -16,6 +20,7 @@ module Kward
|
|
|
16
20
|
)
|
|
17
21
|
end
|
|
18
22
|
|
|
23
|
+
# Executes the tool and returns model-facing output text.
|
|
19
24
|
def call(args, conversation, cancellation: nil)
|
|
20
25
|
path = argument(args, :path, "")
|
|
21
26
|
content = argument(args, :content, "")
|
data/lib/kward/version.rb
CHANGED
data/lib/kward/workspace.rb
CHANGED
|
@@ -3,7 +3,19 @@ require "pathname"
|
|
|
3
3
|
require "timeout"
|
|
4
4
|
require_relative "session_diff"
|
|
5
5
|
|
|
6
|
+
# Namespace for the Kward CLI agent runtime.
|
|
6
7
|
module Kward
|
|
8
|
+
# Filesystem and shell-command boundary for workspace tools.
|
|
9
|
+
#
|
|
10
|
+
# `Workspace` is deliberately low-level: it validates paths, enforces output
|
|
11
|
+
# limits, applies exact edits, writes files, and runs shell commands from one
|
|
12
|
+
# root directory. It should not know about model prompts, sessions, telemetry,
|
|
13
|
+
# or UI confirmation. Tool wrappers and frontends provide those policies.
|
|
14
|
+
#
|
|
15
|
+
# Guardrails are enabled by default and require all file paths to resolve under
|
|
16
|
+
# `root`. RPC may report when guardrails are disabled, but callers should avoid
|
|
17
|
+
# bypassing this class for local filesystem mutation so read-before-write and
|
|
18
|
+
# path safety remain consistent.
|
|
7
19
|
class Workspace
|
|
8
20
|
MAX_FILE_BYTES = 256 * 1024
|
|
9
21
|
MAX_READ_OUTPUT_BYTES = 50 * 1024
|
|
@@ -12,6 +24,7 @@ module Kward
|
|
|
12
24
|
MAX_EDIT_DIFF_BYTES = 8 * 1024
|
|
13
25
|
DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
|
|
14
26
|
|
|
27
|
+
# Creates an object for workspace filesystem and shell operations.
|
|
15
28
|
def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true)
|
|
16
29
|
@root = Pathname.new(root).realpath
|
|
17
30
|
@guardrails = guardrails
|
|
@@ -21,8 +34,10 @@ module Kward
|
|
|
21
34
|
@max_command_output_bytes = max_command_output_bytes
|
|
22
35
|
end
|
|
23
36
|
|
|
37
|
+
# @return [Pathname] canonical workspace root used as the base for file and shell tools
|
|
24
38
|
attr_reader :root
|
|
25
39
|
|
|
40
|
+
# Lists immediate directory children after resolving `path` through workspace guardrails.
|
|
26
41
|
def list_directory(path)
|
|
27
42
|
resolved = workspace_path(path)
|
|
28
43
|
return "Error: not a directory: #{path}" unless File.directory?(resolved)
|
|
@@ -34,6 +49,11 @@ module Kward
|
|
|
34
49
|
"Error: #{e.message}"
|
|
35
50
|
end
|
|
36
51
|
|
|
52
|
+
# Reads a bounded text slice from a workspace file.
|
|
53
|
+
#
|
|
54
|
+
# The returned string is user/model-facing and includes continuation notices
|
|
55
|
+
# when output is truncated. Errors are returned as `"Error: ..."` strings so
|
|
56
|
+
# tool calls can be persisted in the conversation without raising.
|
|
37
57
|
def read_file(path, offset: nil, limit: nil)
|
|
38
58
|
resolved = workspace_path(path)
|
|
39
59
|
return "Error: not a file: #{path}" unless File.file?(resolved)
|
|
@@ -41,11 +61,20 @@ module Kward
|
|
|
41
61
|
size = File.size(resolved)
|
|
42
62
|
return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
|
|
43
63
|
|
|
44
|
-
|
|
64
|
+
content = File.read(resolved)
|
|
65
|
+
return "Error: not a text file: #{path}" if binary_content?(content)
|
|
66
|
+
|
|
67
|
+
read_file_slice(content, offset: offset, limit: limit)
|
|
45
68
|
rescue SecurityError, Errno::ENOENT => e
|
|
46
69
|
"Error: #{e.message}"
|
|
47
70
|
end
|
|
48
71
|
|
|
72
|
+
# Writes complete file content after enforcing read-before-write for
|
|
73
|
+
# existing files.
|
|
74
|
+
#
|
|
75
|
+
# `read_paths` must contain resolved paths previously observed by
|
|
76
|
+
# `ReadFile`; this keeps tool-driven edits explicit and prevents overwriting
|
|
77
|
+
# unseen user files.
|
|
49
78
|
def write_file(path, content, read_paths:)
|
|
50
79
|
resolved = workspace_write_path(path)
|
|
51
80
|
|
|
@@ -53,10 +82,6 @@ module Kward
|
|
|
53
82
|
return "Error: existing file must be read before writing: #{path}"
|
|
54
83
|
end
|
|
55
84
|
|
|
56
|
-
if block_given? && !yield(relative_path(resolved), content.bytesize)
|
|
57
|
-
return "Declined: write_file was not approved for #{path}"
|
|
58
|
-
end
|
|
59
|
-
|
|
60
85
|
old_content = File.exist?(resolved) ? File.read(resolved) : nil
|
|
61
86
|
File.write(resolved, content)
|
|
62
87
|
output = "Wrote #{content.bytesize} bytes to #{path}"
|
|
@@ -66,6 +91,11 @@ module Kward
|
|
|
66
91
|
"Error: #{e.message}"
|
|
67
92
|
end
|
|
68
93
|
|
|
94
|
+
# Applies exact non-overlapping replacements to a previously read file.
|
|
95
|
+
#
|
|
96
|
+
# Each `old_text` must match exactly once. This favors predictable model edits
|
|
97
|
+
# over fuzzy patching and returns readable error strings when more context is
|
|
98
|
+
# needed.
|
|
69
99
|
def edit_file(path, edits, read_paths:)
|
|
70
100
|
resolved = workspace_path(path)
|
|
71
101
|
return "Error: not a file: #{path}" unless File.file?(resolved)
|
|
@@ -84,6 +114,11 @@ module Kward
|
|
|
84
114
|
"Error: #{e.message}"
|
|
85
115
|
end
|
|
86
116
|
|
|
117
|
+
# Runs a shell command from the workspace root with timeout, cancellation,
|
|
118
|
+
# and bounded combined output.
|
|
119
|
+
#
|
|
120
|
+
# This method intentionally does not ask for confirmation; CLI/RPC policy
|
|
121
|
+
# must decide whether a command is allowed before reaching this boundary.
|
|
87
122
|
def run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil)
|
|
88
123
|
command = command.to_s.strip
|
|
89
124
|
return "Error: command is required" if command.empty?
|
|
@@ -114,6 +149,7 @@ module Kward
|
|
|
114
149
|
"Error: #{e.message}"
|
|
115
150
|
end
|
|
116
151
|
|
|
152
|
+
# Resolves a path with the same guardrails used by file tools.
|
|
117
153
|
def resolved_path(path)
|
|
118
154
|
workspace_path(path)
|
|
119
155
|
end
|
|
@@ -189,6 +225,10 @@ module Kward
|
|
|
189
225
|
output
|
|
190
226
|
end
|
|
191
227
|
|
|
228
|
+
def binary_content?(content)
|
|
229
|
+
content.include?("\x00")
|
|
230
|
+
end
|
|
231
|
+
|
|
192
232
|
def read_start_index(offset)
|
|
193
233
|
return 0 if offset.nil?
|
|
194
234
|
|