kward 0.77.0 → 0.79.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/ci.yml +7 -2
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -2
- data/doc/agent-tools.md +1 -1
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +106 -1
- data/doc/extensibility.md +2 -0
- data/doc/getting-started.md +1 -1
- data/doc/local-models.md +130 -0
- data/doc/permissions.md +180 -0
- data/doc/plugins.md +58 -0
- data/doc/releasing.md +1 -1
- data/doc/rpc.md +73 -1
- data/doc/sandboxing.md +120 -0
- data/doc/security.md +15 -5
- data/doc/skills.md +10 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- data/doc/workspace-tools.md +3 -3
- data/kward.gemspec +1 -1
- data/lib/kward/auth/anthropic_oauth.rb +2 -2
- data/lib/kward/auth/github_oauth.rb +3 -3
- data/lib/kward/auth/oauth_helpers.rb +4 -2
- data/lib/kward/auth/openai_oauth.rb +2 -1
- data/lib/kward/cli/doctor.rb +21 -0
- data/lib/kward/cli/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +15 -1
- data/lib/kward/cli/settings.rb +66 -4
- data/lib/kward/cli/slash_commands.rb +109 -1
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +31 -10
- data/lib/kward/config_files.rb +90 -1
- data/lib/kward/conversation.rb +14 -1
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/local_command_runner.rb +2 -2
- data/lib/kward/model/client.rb +173 -15
- data/lib/kward/model/model_info.rb +11 -1
- data/lib/kward/model/payloads.rb +7 -1
- data/lib/kward/model/stream_parser.rb +58 -26
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/pan/index.html.erb +50 -0
- data/lib/kward/pan/server.rb +49 -2
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +54 -2
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/controller.rb +36 -3
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +20 -0
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts.rb +16 -5
- data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
- data/lib/kward/rpc/server.rb +76 -3
- data/lib/kward/rpc/session_manager.rb +43 -8
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- data/lib/kward/sandbox/capabilities.rb +39 -0
- data/lib/kward/sandbox/command_runner.rb +28 -0
- data/lib/kward/sandbox/environment.rb +24 -0
- data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
- data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
- data/lib/kward/sandbox/passthrough_runner.rb +13 -0
- data/lib/kward/sandbox/policy.rb +74 -0
- data/lib/kward/sandbox/runner_factory.rb +55 -0
- data/lib/kward/sandbox/unavailable_runner.rb +21 -0
- data/lib/kward/sandbox.rb +9 -0
- data/lib/kward/session_store.rb +26 -0
- data/lib/kward/skills/capture.rb +144 -0
- data/lib/kward/starter_pack_installer.rb +3 -1
- data/lib/kward/tab_driver.rb +87 -0
- data/lib/kward/tab_store.rb +74 -12
- data/lib/kward/tools/code_search.rb +8 -2
- data/lib/kward/tools/fetch_content.rb +4 -2
- data/lib/kward/tools/fetch_raw.rb +3 -1
- data/lib/kward/tools/registry.rb +56 -8
- data/lib/kward/tools/search/code.rb +46 -12
- data/lib/kward/tools/search/web.rb +60 -17
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +18 -3
- data/lib/kward/workspace_factory.rb +17 -0
- data/templates/default/fulldoc/html/js/kward.js +1 -0
- data/templates/default/kward_navigation.rb +5 -2
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +2 -0
- metadata +22 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "nokogiri"
|
|
2
2
|
require "uri"
|
|
3
3
|
require_relative "web"
|
|
4
|
+
require_relative "../../http"
|
|
4
5
|
|
|
5
6
|
# Namespace for the Kward CLI agent runtime.
|
|
6
7
|
module Kward
|
|
@@ -9,7 +10,9 @@ module Kward
|
|
|
9
10
|
DEFAULT_MAX_BYTES = 16 * 1024
|
|
10
11
|
MAX_MAX_BYTES = 128 * 1024
|
|
11
12
|
MAX_REDIRECTS = 5
|
|
13
|
+
MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024
|
|
12
14
|
HTTP_TIMEOUT_SECONDS = 10
|
|
15
|
+
MAX_DISCOVERED_LINKS = 50
|
|
13
16
|
|
|
14
17
|
# Creates a fetcher for web content and raw resources.
|
|
15
18
|
def initialize(http_client: WebSearch::NetHttpClient.new)
|
|
@@ -17,7 +20,9 @@ module Kward
|
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
# Fetches a URL and extracts readable text for human-facing pages.
|
|
20
|
-
def fetch_content(args)
|
|
23
|
+
def fetch_content(args = nil, cancellation: nil, **keyword_args)
|
|
24
|
+
args ||= keyword_args
|
|
25
|
+
cancellation&.raise_if_cancelled!
|
|
21
26
|
url = args_value(args, "url").to_s.strip
|
|
22
27
|
return "Error: url is required" if url.empty?
|
|
23
28
|
|
|
@@ -25,58 +30,70 @@ module Kward
|
|
|
25
30
|
extract = normalize_extract(args_value(args, "extract") || "auto")
|
|
26
31
|
return "Error: extract must be one of: auto, text, markdown" unless extract
|
|
27
32
|
|
|
28
|
-
response = fetch_url(url, max_bytes:
|
|
33
|
+
response = fetch_url(url, max_bytes: MAX_DOWNLOAD_BYTES, cancellation: cancellation)
|
|
29
34
|
return response if response.is_a?(String)
|
|
35
|
+
return "Error: response exceeds #{MAX_DOWNLOAD_BYTES} byte download limit" if response[:truncated]
|
|
30
36
|
|
|
31
37
|
body = response[:body].to_s
|
|
32
38
|
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)
|
|
39
|
+
text = extract_readable_text(body, content_type: content_type, mode: extract, base_url: response[:url])
|
|
40
|
+
text, truncated = truncate_bytes(text, max_bytes)
|
|
35
41
|
|
|
36
42
|
[
|
|
37
43
|
"# Fetched content",
|
|
38
44
|
"- URL: #{response[:url]}",
|
|
39
45
|
"- Content type: #{content_type.empty? ? "unknown" : content_type}",
|
|
46
|
+
"- Downloaded bytes: #{body.bytesize}",
|
|
40
47
|
"- Bytes returned: #{text.bytesize}",
|
|
48
|
+
"- Truncated: #{truncated ? "yes" : "no"}",
|
|
41
49
|
"",
|
|
42
50
|
text.empty? ? "(No readable text extracted.)" : text
|
|
43
51
|
].join("\n")
|
|
52
|
+
rescue Cancellation::CancelledError
|
|
53
|
+
raise
|
|
44
54
|
rescue StandardError => e
|
|
45
55
|
"Error: fetch_content failed: #{e.message}"
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
# Fetches a URL and returns bounded raw response content.
|
|
49
|
-
def fetch_raw(args)
|
|
59
|
+
def fetch_raw(args = nil, cancellation: nil, **keyword_args)
|
|
60
|
+
args ||= keyword_args
|
|
61
|
+
cancellation&.raise_if_cancelled!
|
|
50
62
|
url = args_value(args, "url").to_s.strip
|
|
51
63
|
return "Error: url is required" if url.empty?
|
|
52
64
|
|
|
53
65
|
max_bytes = bounded_max_bytes(args_value(args, "max_bytes") || args_value(args, "maxBytes"))
|
|
54
66
|
accept = args_value(args, "accept").to_s.strip
|
|
55
|
-
response = fetch_url(url, max_bytes: max_bytes, accept: accept.empty? ? "*/*" : accept)
|
|
67
|
+
response = fetch_url(url, max_bytes: max_bytes, accept: accept.empty? ? "*/*" : accept, cancellation: cancellation)
|
|
56
68
|
return response if response.is_a?(String)
|
|
57
69
|
|
|
58
|
-
body =
|
|
70
|
+
body = response[:body].to_s
|
|
59
71
|
content_type = header_value(response[:headers], "content-type")
|
|
72
|
+
body = "#{body.to_s.scrub}\n... truncated to #{max_bytes} bytes" if response[:truncated]
|
|
60
73
|
[
|
|
61
74
|
"# Fetched raw content",
|
|
62
75
|
"- URL: #{response[:url]}",
|
|
63
76
|
"- Content type: #{content_type.empty? ? "unknown" : content_type}",
|
|
64
|
-
"- Bytes returned: #{body.bytesize}",
|
|
77
|
+
"- Bytes returned: #{response[:body].to_s.bytesize}",
|
|
78
|
+
"- Truncated: #{response[:truncated] ? "yes" : "no"}",
|
|
65
79
|
"",
|
|
66
80
|
body
|
|
67
81
|
].join("\n")
|
|
82
|
+
rescue Cancellation::CancelledError
|
|
83
|
+
raise
|
|
68
84
|
rescue StandardError => e
|
|
69
85
|
"Error: fetch_raw failed: #{e.message}"
|
|
70
86
|
end
|
|
71
87
|
|
|
72
88
|
private
|
|
73
89
|
|
|
74
|
-
def fetch_url(url, max_bytes:, accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8")
|
|
90
|
+
def fetch_url(url, max_bytes:, accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8", cancellation: nil)
|
|
75
91
|
current_url = normalize_url(url)
|
|
76
92
|
redirects = 0
|
|
77
93
|
|
|
78
94
|
loop do
|
|
79
|
-
|
|
95
|
+
cancellation&.raise_if_cancelled!
|
|
96
|
+
response = @http_client.get(current_url, headers: browser_headers(accept), max_bytes: max_bytes)
|
|
80
97
|
code = response.code.to_i
|
|
81
98
|
headers = response_headers(response)
|
|
82
99
|
|
|
@@ -93,9 +110,7 @@ module Kward
|
|
|
93
110
|
|
|
94
111
|
return "Error: fetch failed with HTTP #{response.code}" unless code.between?(200, 299)
|
|
95
112
|
|
|
96
|
-
body
|
|
97
|
-
body = truncate_bytes(body, max_bytes)
|
|
98
|
-
return { url: current_url, headers: headers, body: body }
|
|
113
|
+
return { url: current_url, headers: headers, body: response.body.to_s, truncated: response.respond_to?(:truncated) && response.truncated }
|
|
99
114
|
end
|
|
100
115
|
end
|
|
101
116
|
|
|
@@ -134,42 +149,121 @@ module Kward
|
|
|
134
149
|
%w[auto text markdown].include?(normalized) ? normalized : nil
|
|
135
150
|
end
|
|
136
151
|
|
|
137
|
-
def extract_readable_text(body, content_type:, mode:)
|
|
138
|
-
return clean_text(body)
|
|
152
|
+
def extract_readable_text(body, content_type:, mode:, base_url:)
|
|
153
|
+
return clean_text(body) unless html_content?(content_type, body)
|
|
139
154
|
|
|
140
155
|
document = Nokogiri::HTML(body)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
156
|
+
links = discovered_links(document, base_url)
|
|
157
|
+
document.css("script, style, noscript, template, svg, canvas, nav, footer, #footer, [role='contentinfo'], form, iframe, [hidden], [aria-hidden='true']").remove
|
|
158
|
+
title = clean_text(document.at_css("title")&.text)
|
|
159
|
+
root = document.at_css("main") || document.at_css("[role='main']") || document.at_css("article") || document.at_css("body") || document
|
|
144
160
|
parts = []
|
|
145
|
-
parts << "# #{
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
parts << format_html_node(node, text, mode: mode)
|
|
151
|
-
end
|
|
152
|
-
parts.uniq.join("\n\n")
|
|
161
|
+
parts << (mode == "text" ? title : "# #{title}") unless title.empty?
|
|
162
|
+
parts.concat(readable_blocks(root, mode: mode, base_url: base_url))
|
|
163
|
+
parts << format_discovered_links(links, mode: mode) unless links.empty?
|
|
164
|
+
parts.reject(&:empty?).uniq.join("\n\n")
|
|
153
165
|
end
|
|
154
166
|
|
|
155
167
|
def html_content?(content_type, body)
|
|
156
168
|
content_type.to_s.include?("html") || body.to_s.lstrip.start_with?("<!DOCTYPE html", "<html", "<HTML")
|
|
157
169
|
end
|
|
158
170
|
|
|
159
|
-
def
|
|
160
|
-
|
|
171
|
+
def readable_blocks(root, mode:, base_url:)
|
|
172
|
+
selector = "h1, h2, h3, h4, h5, h6, p, ul, ol, pre, blockquote, table, a[href]"
|
|
173
|
+
root.css(selector).filter_map do |node|
|
|
174
|
+
next if nested_readable_block?(node)
|
|
175
|
+
|
|
176
|
+
format_html_node(node, mode: mode, base_url: base_url)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def nested_readable_block?(node)
|
|
181
|
+
block_names = %w[p ul ol pre blockquote table]
|
|
182
|
+
return true if node.ancestors.any? { |ancestor| block_names.include?(ancestor.name) }
|
|
183
|
+
return false unless node.name == "a"
|
|
184
|
+
|
|
185
|
+
node.ancestors.any? { |ancestor| ancestor.name.match?(/\Ah[1-6]\z/) }
|
|
186
|
+
end
|
|
161
187
|
|
|
188
|
+
def format_html_node(node, mode:, base_url:)
|
|
162
189
|
case node.name
|
|
163
190
|
when /^h([1-6])$/
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
|
|
168
|
-
"
|
|
191
|
+
level = Regexp.last_match(1).to_i
|
|
192
|
+
text = render_inline(node, mode: mode, base_url: base_url)
|
|
193
|
+
return "" if text.empty?
|
|
194
|
+
|
|
195
|
+
mode == "text" ? text : "#{"#" * level} #{text}"
|
|
196
|
+
when "p"
|
|
197
|
+
render_inline(node, mode: mode, base_url: base_url)
|
|
198
|
+
when "a"
|
|
199
|
+
format_link(node, mode: mode, base_url: base_url)
|
|
200
|
+
when "ul", "ol"
|
|
201
|
+
format_list(node, mode: mode, base_url: base_url)
|
|
202
|
+
when "pre"
|
|
203
|
+
text = node.text.to_s.strip
|
|
204
|
+
mode == "text" ? text : "```\n#{text}\n```"
|
|
169
205
|
when "blockquote"
|
|
170
|
-
|
|
206
|
+
text = render_inline(node, mode: mode, base_url: base_url)
|
|
207
|
+
mode == "text" ? text : text.lines.map { |line| "> #{line}" }.join
|
|
208
|
+
when "table"
|
|
209
|
+
format_table(node, mode: mode, base_url: base_url)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def render_inline(node, mode:, base_url:)
|
|
214
|
+
text = node.children.map do |child|
|
|
215
|
+
if child.text?
|
|
216
|
+
child.text
|
|
217
|
+
elsif child.name == "a"
|
|
218
|
+
format_link(child, mode: mode, base_url: base_url)
|
|
219
|
+
elsif child.name == "code" && mode != "text"
|
|
220
|
+
"`#{clean_text(child.text)}`"
|
|
221
|
+
elsif child.name == "br"
|
|
222
|
+
"\n"
|
|
223
|
+
else
|
|
224
|
+
render_inline(child, mode: mode, base_url: base_url)
|
|
225
|
+
end
|
|
226
|
+
end.join
|
|
227
|
+
clean_text(text)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def format_link(node, mode:, base_url:)
|
|
231
|
+
label = clean_text(node.text)
|
|
232
|
+
href = resolved_link(node["href"], base_url)
|
|
233
|
+
return label unless href
|
|
234
|
+
|
|
235
|
+
label = href if label.empty?
|
|
236
|
+
mode == "text" ? "#{label} (#{href})" : "[#{label}](#{href})"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def format_list(list, mode:, base_url:, depth: 0)
|
|
240
|
+
ordered = list.name == "ol"
|
|
241
|
+
list.element_children.select { |child| child.name == "li" }.each_with_index.map do |item, index|
|
|
242
|
+
nested_lists = item.element_children.select { |child| %w[ul ol].include?(child.name) }
|
|
243
|
+
copy = item.dup
|
|
244
|
+
copy.css("ul, ol").remove
|
|
245
|
+
text = render_inline(copy, mode: mode, base_url: base_url)
|
|
246
|
+
marker = ordered ? "#{index + 1}." : "-"
|
|
247
|
+
line = "#{" " * depth}#{marker} #{text}".rstrip
|
|
248
|
+
nested = nested_lists.map { |nested_list| format_list(nested_list, mode: mode, base_url: base_url, depth: depth + 1) }
|
|
249
|
+
([line] + nested).reject(&:empty?).join("\n")
|
|
250
|
+
end.join("\n")
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def format_table(table, mode:, base_url:)
|
|
254
|
+
rows = table.css("tr").map do |row|
|
|
255
|
+
row.css("th, td").map { |cell| render_inline(cell, mode: mode, base_url: base_url) }
|
|
256
|
+
end.reject(&:empty?)
|
|
257
|
+
return "" if rows.empty?
|
|
258
|
+
|
|
259
|
+
width = rows.map(&:length).max
|
|
260
|
+
rows.each { |row| row.fill("", row.length...width) }
|
|
261
|
+
if mode == "text"
|
|
262
|
+
rows.map { |row| row.join(" | ") }.join("\n")
|
|
171
263
|
else
|
|
172
|
-
|
|
264
|
+
header = rows.first
|
|
265
|
+
body = rows.drop(1)
|
|
266
|
+
(["| #{header.join(" | ")} |", "| #{Array.new(width, "---").join(" | ")} |"] + body.map { |row| "| #{row.join(" | ")} |" }).join("\n")
|
|
173
267
|
end
|
|
174
268
|
end
|
|
175
269
|
|
|
@@ -177,17 +271,91 @@ module Kward
|
|
|
177
271
|
text.to_s.gsub(/\s+/, " ").strip
|
|
178
272
|
end
|
|
179
273
|
|
|
274
|
+
def discovered_links(document, base_url)
|
|
275
|
+
candidates = document.css("a[href]").each_with_index.filter_map do |link, index|
|
|
276
|
+
href = resolved_link(link["href"], base_url)
|
|
277
|
+
label = clean_text(link.text)
|
|
278
|
+
label = clean_text(link["aria-label"] || link["title"]) if label.empty?
|
|
279
|
+
next if href.nil? || label.empty? || skip_link?(link, label)
|
|
280
|
+
|
|
281
|
+
[link_group(link), label, href, index]
|
|
282
|
+
end
|
|
283
|
+
priorities = { "Main content" => 0, "Primary navigation" => 1, "Page links" => 2, "Footer" => 3 }
|
|
284
|
+
candidates.sort_by! { |group, _label, _href, index| [priorities.fetch(group, 4), index] }
|
|
285
|
+
|
|
286
|
+
groups = Hash.new { |hash, key| hash[key] = [] }
|
|
287
|
+
seen = {}
|
|
288
|
+
candidates.each do |group, label, href, _index|
|
|
289
|
+
next if seen[href]
|
|
290
|
+
|
|
291
|
+
seen[href] = true
|
|
292
|
+
groups[group] << [label, href]
|
|
293
|
+
end
|
|
294
|
+
omitted = [seen.length - MAX_DISCOVERED_LINKS, 0].max
|
|
295
|
+
allowed = MAX_DISCOVERED_LINKS
|
|
296
|
+
groups.each_value do |links|
|
|
297
|
+
links.replace(links.shift(allowed))
|
|
298
|
+
allowed -= links.length
|
|
299
|
+
end
|
|
300
|
+
groups.delete_if { |_group, links| links.empty? }
|
|
301
|
+
groups["Omitted"] << [omitted.to_s, nil] if omitted.positive?
|
|
302
|
+
groups
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def link_group(link)
|
|
306
|
+
return "Primary navigation" unless link.ancestors("nav").empty?
|
|
307
|
+
return "Footer" unless link.ancestors("footer, #footer, [role='contentinfo']").empty?
|
|
308
|
+
return "Main content" unless link.ancestors("main, [role='main']").empty?
|
|
309
|
+
|
|
310
|
+
"Page links"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def skip_link?(link, label)
|
|
314
|
+
link["class"].to_s.split.include?("kward-skip-link") || label.match?(/\Askip to (?:content|main)/i)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def resolved_link(href, base_url)
|
|
318
|
+
value = href.to_s.strip
|
|
319
|
+
return nil if value.empty? || value.start_with?("javascript:", "data:")
|
|
320
|
+
|
|
321
|
+
uri = URI.join(base_url, value)
|
|
322
|
+
return nil unless %w[http https].include?(uri.scheme)
|
|
323
|
+
|
|
324
|
+
uri.to_s
|
|
325
|
+
rescue URI::InvalidURIError
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def format_discovered_links(groups, mode:)
|
|
330
|
+
omitted = groups.delete("Omitted")&.first&.first.to_i
|
|
331
|
+
heading = mode == "text" ? "Links" : "## Links"
|
|
332
|
+
sections = groups.filter_map do |group, links|
|
|
333
|
+
next if links.empty?
|
|
334
|
+
|
|
335
|
+
group_heading = mode == "text" ? group : "### #{group}"
|
|
336
|
+
entries = links.map do |label, href|
|
|
337
|
+
mode == "text" ? "- #{label}: #{href}" : "- [#{label}](#{href})"
|
|
338
|
+
end
|
|
339
|
+
([group_heading] + entries).join("\n")
|
|
340
|
+
end
|
|
341
|
+
sections << "#{omitted} additional links omitted." if omitted.positive?
|
|
342
|
+
([heading] + sections).join("\n\n")
|
|
343
|
+
end
|
|
344
|
+
|
|
180
345
|
def truncate_bytes(text, max_bytes)
|
|
181
|
-
return text if text.bytesize <= max_bytes
|
|
346
|
+
return [text, false] if text.bytesize <= max_bytes
|
|
182
347
|
|
|
183
|
-
"
|
|
348
|
+
marker = "\n... extracted content truncated ..."
|
|
349
|
+
available = [max_bytes - marker.bytesize, 0].max
|
|
350
|
+
truncated = text.byteslice(0, available).to_s.scrub.sub(/\n?[^\n]*\z/, "")
|
|
351
|
+
["#{truncated}#{marker}", true]
|
|
184
352
|
end
|
|
185
353
|
|
|
186
354
|
def browser_headers(accept)
|
|
187
355
|
{
|
|
188
356
|
"Accept" => accept,
|
|
189
357
|
"Accept-Language" => "en-US,en;q=0.9",
|
|
190
|
-
"User-Agent" =>
|
|
358
|
+
"User-Agent" => Http.user_agent
|
|
191
359
|
}
|
|
192
360
|
end
|
|
193
361
|
|
|
@@ -47,7 +47,9 @@ module Kward
|
|
|
47
47
|
|
|
48
48
|
# Executes the tool and returns model-facing output text.
|
|
49
49
|
def call(args, _conversation, cancellation: nil)
|
|
50
|
-
@web_search.search(args)
|
|
50
|
+
return @web_search.search(args) unless cancellation
|
|
51
|
+
|
|
52
|
+
@web_search.search(args, cancellation: cancellation)
|
|
51
53
|
end
|
|
52
54
|
end
|
|
53
55
|
end
|
data/lib/kward/update_check.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "json"
|
|
|
3
3
|
require "net/http"
|
|
4
4
|
require "time"
|
|
5
5
|
require "uri"
|
|
6
|
+
require_relative "http"
|
|
6
7
|
|
|
7
8
|
# Namespace for the Kward CLI agent runtime.
|
|
8
9
|
module Kward
|
|
@@ -102,7 +103,7 @@ module Kward
|
|
|
102
103
|
|
|
103
104
|
def fetch_latest_version
|
|
104
105
|
request = Net::HTTP::Get.new(LATEST_VERSION_URL)
|
|
105
|
-
request
|
|
106
|
+
Http.apply_user_agent(request)
|
|
106
107
|
response = Net::HTTP.start(LATEST_VERSION_URL.hostname, LATEST_VERSION_URL.port, use_ssl: true, open_timeout: 2, read_timeout: 2) do |http|
|
|
107
108
|
http.request(request)
|
|
108
109
|
end
|
data/lib/kward/version.rb
CHANGED
data/lib/kward/workspace.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "pathname"
|
|
2
2
|
require_relative "local_command_runner"
|
|
3
|
+
require_relative "sandbox"
|
|
3
4
|
require_relative "session_diff"
|
|
4
5
|
|
|
5
6
|
require_relative "path_guard"
|
|
@@ -27,13 +28,14 @@ module Kward
|
|
|
27
28
|
EXPECTED_FILE_ERRORS = [SecurityError, Errno::ENOENT, Errno::EACCES, Errno::EPERM, Errno::EISDIR, Errno::ENOTDIR].freeze
|
|
28
29
|
|
|
29
30
|
# Creates an object for workspace filesystem and shell operations.
|
|
30
|
-
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)
|
|
31
|
+
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, command_runner: nil)
|
|
31
32
|
@root = Pathname.new(root).realpath
|
|
32
33
|
@guardrails = guardrails
|
|
33
34
|
@max_file_bytes = max_file_bytes
|
|
34
35
|
@max_read_output_bytes = max_read_output_bytes
|
|
35
36
|
@max_read_output_lines = max_read_output_lines
|
|
36
37
|
@max_command_output_bytes = max_command_output_bytes
|
|
38
|
+
@command_runner = command_runner
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
# @return [Pathname] canonical workspace root used as the base for file and shell tools
|
|
@@ -162,7 +164,13 @@ module Kward
|
|
|
162
164
|
timeout_seconds = DEFAULT_COMMAND_TIMEOUT_SECONDS if timeout_seconds <= 0
|
|
163
165
|
cancellation&.raise_if_cancelled!
|
|
164
166
|
|
|
165
|
-
result =
|
|
167
|
+
result = command_runner.run(
|
|
168
|
+
command,
|
|
169
|
+
cwd: @root.to_s,
|
|
170
|
+
timeout_seconds: timeout_seconds,
|
|
171
|
+
max_output_bytes: @max_command_output_bytes,
|
|
172
|
+
cancellation: cancellation
|
|
173
|
+
)
|
|
166
174
|
return "Error: command timed out after #{timeout_seconds} seconds" if result.timed_out
|
|
167
175
|
|
|
168
176
|
output = +"Exit status: #{result.exit_status}\n"
|
|
@@ -170,7 +178,7 @@ module Kward
|
|
|
170
178
|
output << "\nSTDERR:\n#{result.stderr}" unless result.stderr.empty?
|
|
171
179
|
output << "\n... truncated to #{@max_command_output_bytes} bytes" if result.truncated
|
|
172
180
|
truncate_output(output)
|
|
173
|
-
rescue Errno::ENOENT, ArgumentError => e
|
|
181
|
+
rescue Errno::ENOENT, ArgumentError, Sandbox::UnavailableError => e
|
|
174
182
|
"Error: #{e.message}"
|
|
175
183
|
end
|
|
176
184
|
|
|
@@ -181,6 +189,13 @@ module Kward
|
|
|
181
189
|
|
|
182
190
|
private
|
|
183
191
|
|
|
192
|
+
def command_runner
|
|
193
|
+
@command_runner ||= Sandbox::PassthroughRunner.new(
|
|
194
|
+
policy: Sandbox::Policy.new(workspace_root: @root),
|
|
195
|
+
capabilities: Sandbox::RunnerFactory.off_capabilities
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
184
199
|
def workspace_path(path)
|
|
185
200
|
target = Pathname.new(path.to_s)
|
|
186
201
|
target = @root.join(target) unless target.absolute?
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require_relative "config_files"
|
|
2
|
+
require_relative "sandbox"
|
|
3
|
+
require_relative "workspace"
|
|
4
|
+
|
|
5
|
+
# Namespace for the Kward CLI agent runtime.
|
|
6
|
+
module Kward
|
|
7
|
+
# Builds workspaces with the same user-configured command sandbox in every frontend.
|
|
8
|
+
module WorkspaceFactory
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def build(root:, guardrails: true, config: ConfigFiles.read_config)
|
|
12
|
+
policy = ConfigFiles.sandbox_policy(root, config)
|
|
13
|
+
runner = Sandbox::RunnerFactory.build(policy)
|
|
14
|
+
Workspace.new(root:, guardrails:, command_runner: runner)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -4,6 +4,7 @@ const guideLinks = {
|
|
|
4
4
|
'doc/usage.md': 'file.usage.html',
|
|
5
5
|
'doc/configuration.md': 'file.configuration.html',
|
|
6
6
|
'doc/authentication.md': 'file.authentication.html',
|
|
7
|
+
'doc/sandboxing.md': 'file.sandboxing.html',
|
|
7
8
|
'doc/troubleshooting.md': 'file.troubleshooting.html',
|
|
8
9
|
'doc/session-management.md': 'file.session-management.html',
|
|
9
10
|
'doc/memory.md': 'file.memory.html',
|
|
@@ -22,7 +22,8 @@ module KwardDocsNavigationData
|
|
|
22
22
|
["Memory", "file.memory.html"],
|
|
23
23
|
["Personas", "file.personas.html"],
|
|
24
24
|
["Skills", "file.skills.html"],
|
|
25
|
-
["Pan mode", "file.pan.html"]
|
|
25
|
+
["Pan mode", "file.pan.html"],
|
|
26
|
+
["Local models", "file.local-models.html"]
|
|
26
27
|
]
|
|
27
28
|
],
|
|
28
29
|
[
|
|
@@ -42,7 +43,9 @@ module KwardDocsNavigationData
|
|
|
42
43
|
[
|
|
43
44
|
["Extensibility", "file.extensibility.html"],
|
|
44
45
|
["Plugins", "file.plugins.html"],
|
|
45
|
-
["Lifecycle hooks", "file.lifecycle-hooks.html"]
|
|
46
|
+
["Lifecycle hooks", "file.lifecycle-hooks.html"],
|
|
47
|
+
["Permissions", "file.permissions.html"],
|
|
48
|
+
["Command sandboxing", "file.sandboxing.html"]
|
|
46
49
|
]
|
|
47
50
|
],
|
|
48
51
|
[
|
|
@@ -110,6 +110,7 @@
|
|
|
110
110
|
<article><strong>⌘</strong><h2>Project-aware agent loop</h2><p>Inspect, reason, patch, verify.</p></article>
|
|
111
111
|
<article><strong>▤</strong><h2>Unix filter mode</h2><p>Pipe diffs, logs, and code through Kward.</p></article>
|
|
112
112
|
<article><strong>▰</strong><h2>Branchable sessions</h2><p>Resume, rewind, fork, compact, export.</p></article>
|
|
113
|
+
<article><strong>◈</strong><h2>Human-in-the-loop permissions</h2><p>Optionally approve edits, commands, web, and MCP calls.</p></article>
|
|
113
114
|
<article><strong>◎</strong><h2>Live source intelligence</h2><p>Search the web and read public repos.</p></article>
|
|
114
115
|
<article><strong>✣</strong><h2>Ruby-native extensions</h2><p>Shape Kward with plugins, skills, and prompts.</p></article>
|
|
115
116
|
</section>
|
|
@@ -136,6 +137,7 @@
|
|
|
136
137
|
<li>Review diffs, stage files, and commit changes from the integrated Git workflow.</li>
|
|
137
138
|
<li>Pipe diffs, logs, code, and generated text through Kward as a Unix filter.</li>
|
|
138
139
|
<li>Organize long-running work with tabs, sessions, rewind, forks, compaction, and exports.</li>
|
|
140
|
+
<li>Use permission policies to approve or deny model-requested edits, commands, web, and MCP calls.</li>
|
|
139
141
|
<li>Ground answers with live web search and public source repositories before guessing.</li>
|
|
140
142
|
<li>Shape Kward with memory, skills, prompts, personas, and trusted Ruby plugins.</li>
|
|
141
143
|
<li>Build custom UIs on top of Kward’s JSON-RPC backend.</li>
|
|
@@ -29,6 +29,8 @@ module KwardDocsNavigation
|
|
|
29
29
|
"doc/usage.md" => "file.usage.html",
|
|
30
30
|
"doc/configuration.md" => "file.configuration.html",
|
|
31
31
|
"doc/authentication.md" => "file.authentication.html",
|
|
32
|
+
"doc/permissions.md" => "file.permissions.html",
|
|
33
|
+
"doc/sandboxing.md" => "file.sandboxing.html",
|
|
32
34
|
"doc/troubleshooting.md" => "file.troubleshooting.html",
|
|
33
35
|
"doc/session-management.md" => "file.session-management.html",
|
|
34
36
|
"doc/tabs.md" => "file.tabs.html",
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: kward
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.79.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kai Wood
|
|
@@ -139,13 +139,16 @@ files:
|
|
|
139
139
|
- doc/getting-started.md
|
|
140
140
|
- doc/git.md
|
|
141
141
|
- doc/lifecycle-hooks.md
|
|
142
|
+
- doc/local-models.md
|
|
142
143
|
- doc/mcp.md
|
|
143
144
|
- doc/memory.md
|
|
144
145
|
- doc/pan.md
|
|
146
|
+
- doc/permissions.md
|
|
145
147
|
- doc/personas.md
|
|
146
148
|
- doc/plugins.md
|
|
147
149
|
- doc/releasing.md
|
|
148
150
|
- doc/rpc.md
|
|
151
|
+
- doc/sandboxing.md
|
|
149
152
|
- doc/security.md
|
|
150
153
|
- doc/session-management.md
|
|
151
154
|
- doc/shell.md
|
|
@@ -214,6 +217,7 @@ files:
|
|
|
214
217
|
- lib/kward/hooks/http_handler.rb
|
|
215
218
|
- lib/kward/hooks/manager.rb
|
|
216
219
|
- lib/kward/hooks/matcher.rb
|
|
220
|
+
- lib/kward/http.rb
|
|
217
221
|
- lib/kward/image_attachments.rb
|
|
218
222
|
- lib/kward/interactive_pty_runner.rb
|
|
219
223
|
- lib/kward/local_command_runner.rb
|
|
@@ -240,11 +244,13 @@ files:
|
|
|
240
244
|
- lib/kward/pan/kward_logo.png
|
|
241
245
|
- lib/kward/pan/server.rb
|
|
242
246
|
- lib/kward/path_guard.rb
|
|
247
|
+
- lib/kward/permissions/policy.rb
|
|
243
248
|
- lib/kward/plugin_registry.rb
|
|
244
249
|
- lib/kward/private_file.rb
|
|
245
250
|
- lib/kward/project_files.rb
|
|
246
251
|
- lib/kward/prompt_history.rb
|
|
247
252
|
- lib/kward/prompt_interface.rb
|
|
253
|
+
- lib/kward/prompt_interface/approval_prompt.rb
|
|
248
254
|
- lib/kward/prompt_interface/banner.rb
|
|
249
255
|
- lib/kward/prompt_interface/composer_controller.rb
|
|
250
256
|
- lib/kward/prompt_interface/composer_renderer.rb
|
|
@@ -296,6 +302,7 @@ files:
|
|
|
296
302
|
- lib/kward/rpc/config_manager.rb
|
|
297
303
|
- lib/kward/rpc/mcp_status.rb
|
|
298
304
|
- lib/kward/rpc/memory_methods.rb
|
|
305
|
+
- lib/kward/rpc/plugin_chat_manager.rb
|
|
299
306
|
- lib/kward/rpc/prompt_bridge.rb
|
|
300
307
|
- lib/kward/rpc/redactor.rb
|
|
301
308
|
- lib/kward/rpc/runtime_payloads.rb
|
|
@@ -309,6 +316,16 @@ files:
|
|
|
309
316
|
- lib/kward/rpc/transcript_normalizer.rb
|
|
310
317
|
- lib/kward/rpc/transport.rb
|
|
311
318
|
- lib/kward/rpc/turn_context.rb
|
|
319
|
+
- lib/kward/sandbox.rb
|
|
320
|
+
- lib/kward/sandbox/capabilities.rb
|
|
321
|
+
- lib/kward/sandbox/command_runner.rb
|
|
322
|
+
- lib/kward/sandbox/environment.rb
|
|
323
|
+
- lib/kward/sandbox/linux_bubblewrap_runner.rb
|
|
324
|
+
- lib/kward/sandbox/macos_seatbelt_runner.rb
|
|
325
|
+
- lib/kward/sandbox/passthrough_runner.rb
|
|
326
|
+
- lib/kward/sandbox/policy.rb
|
|
327
|
+
- lib/kward/sandbox/runner_factory.rb
|
|
328
|
+
- lib/kward/sandbox/unavailable_runner.rb
|
|
312
329
|
- lib/kward/scratchpad_runner.rb
|
|
313
330
|
- lib/kward/session_diff.rb
|
|
314
331
|
- lib/kward/session_naming.rb
|
|
@@ -317,9 +334,11 @@ files:
|
|
|
317
334
|
- lib/kward/session_tree_nodes.rb
|
|
318
335
|
- lib/kward/session_tree_renderer.rb
|
|
319
336
|
- lib/kward/session_tree_tool_display.rb
|
|
337
|
+
- lib/kward/skills/capture.rb
|
|
320
338
|
- lib/kward/skills/registry.rb
|
|
321
339
|
- lib/kward/starter_pack_installer.rb
|
|
322
340
|
- lib/kward/steering.rb
|
|
341
|
+
- lib/kward/tab_driver.rb
|
|
323
342
|
- lib/kward/tab_store.rb
|
|
324
343
|
- lib/kward/telemetry/logger.rb
|
|
325
344
|
- lib/kward/telemetry/stats.rb
|
|
@@ -353,6 +372,7 @@ files:
|
|
|
353
372
|
- lib/kward/update_check.rb
|
|
354
373
|
- lib/kward/version.rb
|
|
355
374
|
- lib/kward/workspace.rb
|
|
375
|
+
- lib/kward/workspace_factory.rb
|
|
356
376
|
- lib/main.rb
|
|
357
377
|
- templates/default/fulldoc/html/css/kward.css
|
|
358
378
|
- templates/default/fulldoc/html/full_list.erb
|
|
@@ -380,7 +400,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
380
400
|
requirements:
|
|
381
401
|
- - ">="
|
|
382
402
|
- !ruby/object:Gem::Version
|
|
383
|
-
version: '3.
|
|
403
|
+
version: '3.4'
|
|
384
404
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
385
405
|
requirements:
|
|
386
406
|
- - ">="
|