kward 0.77.0 → 0.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +1 -0
- data/doc/code-search.md +9 -6
- data/doc/configuration.md +39 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -0
- data/doc/security.md +4 -0
- data/doc/tabs.md +4 -3
- data/doc/web-search.md +4 -2
- 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/plugins.rb +22 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +14 -0
- data/lib/kward/cli/tabs.rb +140 -34
- data/lib/kward/cli.rb +29 -9
- data/lib/kward/config_files.rb +10 -0
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/model/client.rb +33 -11
- data/lib/kward/model/payloads.rb +6 -1
- data/lib/kward/openrouter_model_cache.rb +2 -1
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +53 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface.rb +2 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +35 -0
- data/lib/kward/rpc/session_manager.rb +1 -0
- data/lib/kward/rpc/transcript_normalizer.rb +14 -5
- 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 +37 -4
- 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/templates/default/kward_navigation.rb +2 -1
- data/templates/default/layout/html/layout.erb +2 -0
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +7 -1
|
@@ -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
|
@@ -42,7 +42,8 @@ module KwardDocsNavigationData
|
|
|
42
42
|
[
|
|
43
43
|
["Extensibility", "file.extensibility.html"],
|
|
44
44
|
["Plugins", "file.plugins.html"],
|
|
45
|
-
["Lifecycle hooks", "file.lifecycle-hooks.html"]
|
|
45
|
+
["Lifecycle hooks", "file.lifecycle-hooks.html"],
|
|
46
|
+
["Permissions", "file.permissions.html"]
|
|
46
47
|
]
|
|
47
48
|
],
|
|
48
49
|
[
|
|
@@ -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,7 @@ 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",
|
|
32
33
|
"doc/troubleshooting.md" => "file.troubleshooting.html",
|
|
33
34
|
"doc/session-management.md" => "file.session-management.html",
|
|
34
35
|
"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.78.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kai Wood
|
|
@@ -142,6 +142,7 @@ files:
|
|
|
142
142
|
- doc/mcp.md
|
|
143
143
|
- doc/memory.md
|
|
144
144
|
- doc/pan.md
|
|
145
|
+
- doc/permissions.md
|
|
145
146
|
- doc/personas.md
|
|
146
147
|
- doc/plugins.md
|
|
147
148
|
- doc/releasing.md
|
|
@@ -214,6 +215,7 @@ files:
|
|
|
214
215
|
- lib/kward/hooks/http_handler.rb
|
|
215
216
|
- lib/kward/hooks/manager.rb
|
|
216
217
|
- lib/kward/hooks/matcher.rb
|
|
218
|
+
- lib/kward/http.rb
|
|
217
219
|
- lib/kward/image_attachments.rb
|
|
218
220
|
- lib/kward/interactive_pty_runner.rb
|
|
219
221
|
- lib/kward/local_command_runner.rb
|
|
@@ -240,11 +242,13 @@ files:
|
|
|
240
242
|
- lib/kward/pan/kward_logo.png
|
|
241
243
|
- lib/kward/pan/server.rb
|
|
242
244
|
- lib/kward/path_guard.rb
|
|
245
|
+
- lib/kward/permissions/policy.rb
|
|
243
246
|
- lib/kward/plugin_registry.rb
|
|
244
247
|
- lib/kward/private_file.rb
|
|
245
248
|
- lib/kward/project_files.rb
|
|
246
249
|
- lib/kward/prompt_history.rb
|
|
247
250
|
- lib/kward/prompt_interface.rb
|
|
251
|
+
- lib/kward/prompt_interface/approval_prompt.rb
|
|
248
252
|
- lib/kward/prompt_interface/banner.rb
|
|
249
253
|
- lib/kward/prompt_interface/composer_controller.rb
|
|
250
254
|
- lib/kward/prompt_interface/composer_renderer.rb
|
|
@@ -296,6 +300,7 @@ files:
|
|
|
296
300
|
- lib/kward/rpc/config_manager.rb
|
|
297
301
|
- lib/kward/rpc/mcp_status.rb
|
|
298
302
|
- lib/kward/rpc/memory_methods.rb
|
|
303
|
+
- lib/kward/rpc/plugin_chat_manager.rb
|
|
299
304
|
- lib/kward/rpc/prompt_bridge.rb
|
|
300
305
|
- lib/kward/rpc/redactor.rb
|
|
301
306
|
- lib/kward/rpc/runtime_payloads.rb
|
|
@@ -320,6 +325,7 @@ files:
|
|
|
320
325
|
- lib/kward/skills/registry.rb
|
|
321
326
|
- lib/kward/starter_pack_installer.rb
|
|
322
327
|
- lib/kward/steering.rb
|
|
328
|
+
- lib/kward/tab_driver.rb
|
|
323
329
|
- lib/kward/tab_store.rb
|
|
324
330
|
- lib/kward/telemetry/logger.rb
|
|
325
331
|
- lib/kward/telemetry/stats.rb
|