kward 0.76.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/.github/workflows/ci.yml +8 -1
- data/CHANGELOG.md +62 -0
- data/Gemfile.lock +2 -2
- data/README.md +13 -4
- data/doc/agent-tools.md +0 -3
- data/doc/api.md +4 -0
- data/doc/code-search.md +9 -6
- data/doc/composer.md +219 -0
- data/doc/configuration.md +114 -27
- data/doc/editor.md +18 -2
- data/doc/files.md +1 -1
- data/doc/getting-started.md +4 -0
- data/doc/git.md +2 -2
- data/doc/lifecycle-hooks.md +2 -14
- data/doc/pan.md +167 -0
- data/doc/permissions.md +179 -0
- data/doc/plugins.md +58 -0
- data/doc/rpc.md +61 -22
- data/doc/security.md +184 -0
- data/doc/tabs.md +4 -3
- data/doc/usage.md +15 -32
- data/doc/web-search.md +4 -2
- data/lib/kward/ansi.rb +2 -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/cancellation.rb +2 -2
- data/lib/kward/cli/commands.rb +1 -3
- data/lib/kward/cli/interactive_turn.rb +0 -17
- data/lib/kward/cli/plugins.rb +24 -7
- data/lib/kward/cli/prompt_interface.rb +1 -0
- data/lib/kward/cli/rendering.rb +7 -1
- data/lib/kward/cli/runtime_helpers.rb +11 -35
- data/lib/kward/cli/sessions.rb +14 -5
- data/lib/kward/cli/settings.rb +22 -0
- data/lib/kward/cli/slash_commands.rb +1 -561
- data/lib/kward/cli/tabs.rb +154 -36
- data/lib/kward/cli.rb +30 -26
- data/lib/kward/compactor.rb +3 -3
- data/lib/kward/config_files.rb +28 -0
- data/lib/kward/ekwsh.rb +0 -4
- data/lib/kward/hooks/catalog.rb +0 -5
- data/lib/kward/hooks/http_handler.rb +2 -1
- data/lib/kward/http.rb +18 -0
- data/lib/kward/image_attachments.rb +1 -1
- 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/pan/index.html.erb +459 -64
- data/lib/kward/pan/kward_logo.png +0 -0
- data/lib/kward/pan/server.rb +248 -23
- data/lib/kward/permissions/policy.rb +171 -0
- data/lib/kward/plugin_registry.rb +71 -1
- data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
- data/lib/kward/prompt_interface/layout.rb +0 -4
- data/lib/kward/prompt_interface/project_browser.rb +40 -1
- data/lib/kward/prompt_interface/question_prompt.rb +12 -3
- data/lib/kward/prompt_interface/runtime_state.rb +4 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
- data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
- data/lib/kward/prompt_interface.rb +20 -1
- data/lib/kward/prompts/commands.rb +1 -3
- data/lib/kward/prompts.rb +20 -0
- data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
- data/lib/kward/rpc/server.rb +48 -41
- data/lib/kward/rpc/session_manager.rb +76 -25
- data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
- data/lib/kward/rpc/tool_metadata.rb +23 -0
- data/lib/kward/rpc/transcript_normalizer.rb +18 -5
- data/lib/kward/session_store.rb +55 -16
- data/lib/kward/skills/registry.rb +21 -1
- 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/base.rb +14 -0
- 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 +45 -17
- data/lib/kward/tools/search/code.rb +47 -13
- data/lib/kward/tools/search/web.rb +61 -18
- data/lib/kward/tools/search/web_fetch.rb +206 -38
- data/lib/kward/tools/tool_call.rb +0 -4
- data/lib/kward/tools/web_search.rb +3 -1
- data/lib/kward/transcript_export.rb +1 -1
- data/lib/kward/update_check.rb +2 -1
- data/lib/kward/version.rb +1 -1
- data/templates/default/kward_navigation.rb +7 -2
- data/templates/default/layout/html/layout.erb +2 -2
- data/templates/default/layout/html/setup.rb +1 -0
- metadata +11 -12
- data/lib/kward/workers/git_guard.rb +0 -93
- data/lib/kward/workers/job.rb +0 -99
- data/lib/kward/workers/live_view.rb +0 -49
- data/lib/kward/workers/manager.rb +0 -328
- data/lib/kward/workers/queue_runner.rb +0 -166
- data/lib/kward/workers/queue_store.rb +0 -112
- data/lib/kward/workers/store.rb +0 -72
- data/lib/kward/workers/tool_policy.rb +0 -23
- data/lib/kward/workers/worker.rb +0 -82
- data/lib/kward/workers/write_lock.rb +0 -38
- data/lib/kward/workers.rb +0 -10
|
@@ -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
|
|
|
@@ -67,10 +67,6 @@ module Kward
|
|
|
67
67
|
TOOL_NAME_MAP[name.to_s]
|
|
68
68
|
end
|
|
69
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
70
|
def file_change_tool?(name)
|
|
75
71
|
%w[edit_file write_file edit write].include?(name.to_s)
|
|
76
72
|
end
|
|
@@ -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
|
@@ -9,6 +9,7 @@ module KwardDocsNavigationData
|
|
|
9
9
|
["Usage", "file.usage.html"],
|
|
10
10
|
["Configuration", "file.configuration.html"],
|
|
11
11
|
["Authentication", "file.authentication.html"],
|
|
12
|
+
["Security and trust", "file.security.html"],
|
|
12
13
|
["Troubleshooting", "file.troubleshooting.html"]
|
|
13
14
|
]
|
|
14
15
|
],
|
|
@@ -16,10 +17,12 @@ module KwardDocsNavigationData
|
|
|
16
17
|
"Feature guides",
|
|
17
18
|
[
|
|
18
19
|
["Sessions", "file.session-management.html"],
|
|
20
|
+
["Interactive composer", "file.composer.html"],
|
|
19
21
|
["Tabs", "file.tabs.html"],
|
|
20
22
|
["Memory", "file.memory.html"],
|
|
21
23
|
["Personas", "file.personas.html"],
|
|
22
|
-
["Skills", "file.skills.html"]
|
|
24
|
+
["Skills", "file.skills.html"],
|
|
25
|
+
["Pan mode", "file.pan.html"]
|
|
23
26
|
]
|
|
24
27
|
],
|
|
25
28
|
[
|
|
@@ -39,12 +42,14 @@ module KwardDocsNavigationData
|
|
|
39
42
|
[
|
|
40
43
|
["Extensibility", "file.extensibility.html"],
|
|
41
44
|
["Plugins", "file.plugins.html"],
|
|
42
|
-
["Lifecycle hooks", "file.lifecycle-hooks.html"]
|
|
45
|
+
["Lifecycle hooks", "file.lifecycle-hooks.html"],
|
|
46
|
+
["Permissions", "file.permissions.html"]
|
|
43
47
|
]
|
|
44
48
|
],
|
|
45
49
|
[
|
|
46
50
|
"Integrate",
|
|
47
51
|
[
|
|
52
|
+
["MCP servers", "file.mcp.html"],
|
|
48
53
|
["RPC protocol", "file.rpc.html"],
|
|
49
54
|
["Releasing", "file.releasing.html"]
|
|
50
55
|
]
|
|
@@ -110,7 +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
|
|
113
|
+
<article><strong>◈</strong><h2>Human-in-the-loop permissions</h2><p>Optionally approve edits, commands, web, and MCP calls.</p></article>
|
|
114
114
|
<article><strong>◎</strong><h2>Live source intelligence</h2><p>Search the web and read public repos.</p></article>
|
|
115
115
|
<article><strong>✣</strong><h2>Ruby-native extensions</h2><p>Shape Kward with plugins, skills, and prompts.</p></article>
|
|
116
116
|
</section>
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
<li>Review diffs, stage files, and commit changes from the integrated Git workflow.</li>
|
|
138
138
|
<li>Pipe diffs, logs, code, and generated text through Kward as a Unix filter.</li>
|
|
139
139
|
<li>Organize long-running work with tabs, sessions, rewind, forks, compaction, and exports.</li>
|
|
140
|
-
<li>
|
|
140
|
+
<li>Use permission policies to approve or deny model-requested edits, commands, web, and MCP calls.</li>
|
|
141
141
|
<li>Ground answers with live web search and public source repositories before guessing.</li>
|
|
142
142
|
<li>Shape Kward with memory, skills, prompts, personas, and trusted Ruby plugins.</li>
|
|
143
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
|
|
@@ -129,6 +129,7 @@ files:
|
|
|
129
129
|
- doc/api.md
|
|
130
130
|
- doc/authentication.md
|
|
131
131
|
- doc/code-search.md
|
|
132
|
+
- doc/composer.md
|
|
132
133
|
- doc/configuration.md
|
|
133
134
|
- doc/context-budgeting.md
|
|
134
135
|
- doc/context-tools.md
|
|
@@ -140,10 +141,13 @@ files:
|
|
|
140
141
|
- doc/lifecycle-hooks.md
|
|
141
142
|
- doc/mcp.md
|
|
142
143
|
- doc/memory.md
|
|
144
|
+
- doc/pan.md
|
|
145
|
+
- doc/permissions.md
|
|
143
146
|
- doc/personas.md
|
|
144
147
|
- doc/plugins.md
|
|
145
148
|
- doc/releasing.md
|
|
146
149
|
- doc/rpc.md
|
|
150
|
+
- doc/security.md
|
|
147
151
|
- doc/session-management.md
|
|
148
152
|
- doc/shell.md
|
|
149
153
|
- doc/skills.md
|
|
@@ -211,6 +215,7 @@ files:
|
|
|
211
215
|
- lib/kward/hooks/http_handler.rb
|
|
212
216
|
- lib/kward/hooks/manager.rb
|
|
213
217
|
- lib/kward/hooks/matcher.rb
|
|
218
|
+
- lib/kward/http.rb
|
|
214
219
|
- lib/kward/image_attachments.rb
|
|
215
220
|
- lib/kward/interactive_pty_runner.rb
|
|
216
221
|
- lib/kward/local_command_runner.rb
|
|
@@ -234,13 +239,16 @@ files:
|
|
|
234
239
|
- lib/kward/model/stream_parser.rb
|
|
235
240
|
- lib/kward/openrouter_model_cache.rb
|
|
236
241
|
- lib/kward/pan/index.html.erb
|
|
242
|
+
- lib/kward/pan/kward_logo.png
|
|
237
243
|
- lib/kward/pan/server.rb
|
|
238
244
|
- lib/kward/path_guard.rb
|
|
245
|
+
- lib/kward/permissions/policy.rb
|
|
239
246
|
- lib/kward/plugin_registry.rb
|
|
240
247
|
- lib/kward/private_file.rb
|
|
241
248
|
- lib/kward/project_files.rb
|
|
242
249
|
- lib/kward/prompt_history.rb
|
|
243
250
|
- lib/kward/prompt_interface.rb
|
|
251
|
+
- lib/kward/prompt_interface/approval_prompt.rb
|
|
244
252
|
- lib/kward/prompt_interface/banner.rb
|
|
245
253
|
- lib/kward/prompt_interface/composer_controller.rb
|
|
246
254
|
- lib/kward/prompt_interface/composer_renderer.rb
|
|
@@ -292,6 +300,7 @@ files:
|
|
|
292
300
|
- lib/kward/rpc/config_manager.rb
|
|
293
301
|
- lib/kward/rpc/mcp_status.rb
|
|
294
302
|
- lib/kward/rpc/memory_methods.rb
|
|
303
|
+
- lib/kward/rpc/plugin_chat_manager.rb
|
|
295
304
|
- lib/kward/rpc/prompt_bridge.rb
|
|
296
305
|
- lib/kward/rpc/redactor.rb
|
|
297
306
|
- lib/kward/rpc/runtime_payloads.rb
|
|
@@ -316,6 +325,7 @@ files:
|
|
|
316
325
|
- lib/kward/skills/registry.rb
|
|
317
326
|
- lib/kward/starter_pack_installer.rb
|
|
318
327
|
- lib/kward/steering.rb
|
|
328
|
+
- lib/kward/tab_driver.rb
|
|
319
329
|
- lib/kward/tab_store.rb
|
|
320
330
|
- lib/kward/telemetry/logger.rb
|
|
321
331
|
- lib/kward/telemetry/stats.rb
|
|
@@ -348,17 +358,6 @@ files:
|
|
|
348
358
|
- lib/kward/transcript_export.rb
|
|
349
359
|
- lib/kward/update_check.rb
|
|
350
360
|
- lib/kward/version.rb
|
|
351
|
-
- lib/kward/workers.rb
|
|
352
|
-
- lib/kward/workers/git_guard.rb
|
|
353
|
-
- lib/kward/workers/job.rb
|
|
354
|
-
- lib/kward/workers/live_view.rb
|
|
355
|
-
- lib/kward/workers/manager.rb
|
|
356
|
-
- lib/kward/workers/queue_runner.rb
|
|
357
|
-
- lib/kward/workers/queue_store.rb
|
|
358
|
-
- lib/kward/workers/store.rb
|
|
359
|
-
- lib/kward/workers/tool_policy.rb
|
|
360
|
-
- lib/kward/workers/worker.rb
|
|
361
|
-
- lib/kward/workers/write_lock.rb
|
|
362
361
|
- lib/kward/workspace.rb
|
|
363
362
|
- lib/main.rb
|
|
364
363
|
- templates/default/fulldoc/html/css/kward.css
|
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
def stash(message)
|
|
45
|
-
before = stash_refs
|
|
46
|
-
result = run("stash", "push", "--include-untracked", "-m", message)
|
|
47
|
-
return Result.new(success: false, stdout: result.stdout, stderr: result.stderr) unless result.success?
|
|
48
|
-
return Result.new(success: true, stdout: result.stdout, stderr: result.stderr) if result.stdout.include?("No local changes")
|
|
49
|
-
|
|
50
|
-
ref = (stash_refs - before).first || stash_refs.first
|
|
51
|
-
Result.new(success: true, stdout: result.stdout, stderr: result.stderr, commit: ref)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def apply_stash(ref)
|
|
55
|
-
run("stash", "apply", ref.to_s)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def drop_stash(ref)
|
|
59
|
-
run("stash", "drop", ref.to_s)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
Result = Struct.new(:success, :stdout, :stderr, :commit, keyword_init: true) do
|
|
65
|
-
def success?
|
|
66
|
-
success
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def output
|
|
70
|
-
[stdout, stderr].compact.reject(&:empty?).join("\n")
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def success?(*args)
|
|
75
|
-
run(*args).success?
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def stash_refs
|
|
79
|
-
result = run("stash", "list", "--format=%gd")
|
|
80
|
-
return [] unless result.success?
|
|
81
|
-
|
|
82
|
-
result.stdout.lines.map(&:strip).reject(&:empty?)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def run(*args)
|
|
86
|
-
stdout, stderr, status = Open3.capture3("git", "-C", @root, *args)
|
|
87
|
-
Result.new(success: status.success?, stdout: stdout.to_s, stderr: stderr.to_s)
|
|
88
|
-
rescue Errno::ENOENT
|
|
89
|
-
Result.new(success: false, stdout: "", stderr: "git executable not found")
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|