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
|
@@ -12,7 +12,7 @@ module Kward
|
|
|
12
12
|
@web_fetch = web_fetch
|
|
13
13
|
super(
|
|
14
14
|
"fetch_content",
|
|
15
|
-
"Fetch a specific URL and extract readable bounded content.",
|
|
15
|
+
"Fetch a specific URL and extract readable bounded content, including links and simple tables.",
|
|
16
16
|
properties: {
|
|
17
17
|
url: {
|
|
18
18
|
type: "string",
|
|
@@ -34,7 +34,9 @@ module Kward
|
|
|
34
34
|
|
|
35
35
|
# Executes the tool and returns model-facing output text.
|
|
36
36
|
def call(args, _conversation, cancellation: nil)
|
|
37
|
-
@web_fetch.fetch_content(args)
|
|
37
|
+
return @web_fetch.fetch_content(args) unless cancellation
|
|
38
|
+
|
|
39
|
+
@web_fetch.fetch_content(args, cancellation: cancellation)
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
end
|
|
@@ -33,7 +33,9 @@ module Kward
|
|
|
33
33
|
|
|
34
34
|
# Executes the tool and returns model-facing output text.
|
|
35
35
|
def call(args, _conversation, cancellation: nil)
|
|
36
|
-
@web_fetch.fetch_raw(args)
|
|
36
|
+
return @web_fetch.fetch_raw(args) unless cancellation
|
|
37
|
+
|
|
38
|
+
@web_fetch.fetch_raw(args, cancellation: cancellation)
|
|
37
39
|
end
|
|
38
40
|
end
|
|
39
41
|
end
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative "search/web"
|
|
|
22
22
|
require_relative "search/web_fetch"
|
|
23
23
|
require_relative "tool_call"
|
|
24
24
|
require_relative "../mcp/server_config"
|
|
25
|
+
require_relative "../permissions/policy"
|
|
25
26
|
require_relative "../telemetry/logger"
|
|
26
27
|
require_relative "../tool_output_compactor"
|
|
27
28
|
require_relative "../workspace"
|
|
@@ -64,7 +65,7 @@ module Kward
|
|
|
64
65
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
65
66
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
66
67
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
67
|
-
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
|
|
68
|
+
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, allowed_tool_names: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new, context_budget_meter: nil, mcp_clients: nil, tool_approval: nil, approval_for_allowed_tools: false, permission_policy: nil, hook_manager: nil, hook_context: nil)
|
|
68
69
|
@workspace = workspace
|
|
69
70
|
@prompt = prompt
|
|
70
71
|
@web_search = web_search
|
|
@@ -78,6 +79,8 @@ module Kward
|
|
|
78
79
|
@telemetry_logger = telemetry_logger
|
|
79
80
|
@context_budget_meter = context_budget_meter
|
|
80
81
|
@tool_approval = tool_approval
|
|
82
|
+
@approval_for_allowed_tools = approval_for_allowed_tools == true
|
|
83
|
+
@permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
|
|
81
84
|
@hook_manager = hook_manager
|
|
82
85
|
@hook_context = hook_context
|
|
83
86
|
@mcp_clients = if mcp_clients
|
|
@@ -114,10 +117,25 @@ module Kward
|
|
|
114
117
|
hook_denied_content(before_tool, "tool call denied: #{name}")
|
|
115
118
|
elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
|
|
116
119
|
hook_denied_content(before_tool, "tool call approval denied: #{name}")
|
|
117
|
-
elsif tool_approval_denied?(tool_call, name, args, cancellation)
|
|
118
|
-
"Declined: tool execution denied by user: #{name}"
|
|
119
120
|
else
|
|
120
|
-
|
|
121
|
+
permission_decision = @permission_policy.decision_for(name, args, source: source_for_tool(tool))
|
|
122
|
+
if permission_decision.denied?
|
|
123
|
+
"Declined: #{permission_decision.reason}: #{name}"
|
|
124
|
+
elsif permission_decision.approval_required?
|
|
125
|
+
approval = permission_approval_result(tool_call, name, args, cancellation)
|
|
126
|
+
if approval == :allow_for_session
|
|
127
|
+
@permission_policy.allow_for_session!(name)
|
|
128
|
+
execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
129
|
+
elsif approval == true
|
|
130
|
+
execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
131
|
+
else
|
|
132
|
+
approval_denied_content(approval, name)
|
|
133
|
+
end
|
|
134
|
+
elsif @approval_for_allowed_tools && tool_approval_denied?(tool_call, name, args, cancellation)
|
|
135
|
+
"Declined: tool execution denied by user: #{name}"
|
|
136
|
+
else
|
|
137
|
+
execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
|
|
138
|
+
end
|
|
121
139
|
end
|
|
122
140
|
else
|
|
123
141
|
"Unknown tool: #{name}"
|
|
@@ -184,6 +202,8 @@ module Kward
|
|
|
184
202
|
|
|
185
203
|
def source_for_tool(tool)
|
|
186
204
|
case tool
|
|
205
|
+
when Tools::MCPTool
|
|
206
|
+
"mcp"
|
|
187
207
|
when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
|
|
188
208
|
"web"
|
|
189
209
|
when Tools::ReadSkill
|
|
@@ -420,6 +440,19 @@ module Kward
|
|
|
420
440
|
(metadata[:source] || metadata["source"]).to_s == "mcp"
|
|
421
441
|
end
|
|
422
442
|
|
|
443
|
+
def permission_approval_result(tool_call, name, args, cancellation)
|
|
444
|
+
return false unless @tool_approval
|
|
445
|
+
|
|
446
|
+
@tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def approval_denied_content(approval, name)
|
|
450
|
+
message = approval[:denied_message] if approval.is_a?(Hash)
|
|
451
|
+
return "Declined: #{message}" unless message.to_s.empty?
|
|
452
|
+
|
|
453
|
+
"Declined: tool execution denied by user: #{name}"
|
|
454
|
+
end
|
|
455
|
+
|
|
423
456
|
def tool_approval_denied?(tool_call, name, args, cancellation)
|
|
424
457
|
return false unless @tool_approval
|
|
425
458
|
|
|
@@ -33,7 +33,10 @@ module Kward
|
|
|
33
33
|
@max_output_bytes = max_output_bytes
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def call(args)
|
|
36
|
+
def call(args = nil, cancellation: nil, **keyword_args)
|
|
37
|
+
args ||= keyword_args
|
|
38
|
+
cancellation&.raise_if_cancelled!
|
|
39
|
+
@cancellation = cancellation
|
|
37
40
|
action = value(args, "action").to_s
|
|
38
41
|
return "Error: action must be one of: #{ACTIONS.join(", ")}" unless ACTIONS.include?(action)
|
|
39
42
|
|
|
@@ -47,6 +50,8 @@ module Kward
|
|
|
47
50
|
when "refresh_cache" then refresh_cache(args)
|
|
48
51
|
when "clear_cache" then clear_cache(args)
|
|
49
52
|
end
|
|
53
|
+
rescue Cancellation::CancelledError
|
|
54
|
+
raise
|
|
50
55
|
rescue StandardError => e
|
|
51
56
|
"Error: code_search failed: #{redact(e.message)}"
|
|
52
57
|
end
|
|
@@ -129,13 +134,16 @@ module Kward
|
|
|
129
134
|
query = value(args, "query").to_s
|
|
130
135
|
return "Error: query is required" if query.strip.empty?
|
|
131
136
|
|
|
132
|
-
path, =
|
|
137
|
+
path, revision = synchronized_repo(repo, value(args, "ref") || repo[:requested_ref])
|
|
133
138
|
max_results = bounded_max_results(value(args, "max_results"))
|
|
134
139
|
context = bounded_integer(value(args, "context_lines"), DEFAULT_CONTEXT_LINES, 0, 5)
|
|
135
140
|
matches = search_files(path, query, max_results: max_results, context_lines: context)
|
|
136
|
-
|
|
141
|
+
if matches.empty?
|
|
142
|
+
suffix = revision == "unknown" ? "" : " at #{revision}"
|
|
143
|
+
return "Error: no matches found in #{repo[:full_name]}#{suffix}"
|
|
144
|
+
end
|
|
137
145
|
|
|
138
|
-
lines = ["# Code search", "- Repository: #{repo[:full_name]}", "- Query: #{query}", ""]
|
|
146
|
+
lines = ["# Code search", "- Repository: #{repo[:full_name]}", "- Revision: #{revision}", "- Query: #{query}", ""]
|
|
139
147
|
matches.each do |match|
|
|
140
148
|
lines << "## #{match[:path]}:#{match[:line]}"
|
|
141
149
|
lines << "```"
|
|
@@ -148,10 +156,10 @@ module Kward
|
|
|
148
156
|
def repo_read(args)
|
|
149
157
|
repo = require_repo(args)
|
|
150
158
|
return repo if repo.is_a?(String)
|
|
151
|
-
file = value(args, "path") || value(args, "file")
|
|
159
|
+
file = value(args, "path") || value(args, "file") || repo[:requested_path]
|
|
152
160
|
return "Error: path is required" if file.to_s.empty?
|
|
153
161
|
|
|
154
|
-
root, =
|
|
162
|
+
root, revision = synchronized_repo(repo, value(args, "ref") || repo[:requested_ref])
|
|
155
163
|
target = safe_existing_path(root, file.to_s)
|
|
156
164
|
return "Error: path is not a file: #{file}" unless File.file?(target)
|
|
157
165
|
return "Error: file is too large: #{file}" if File.size(target) > MAX_FILE_BYTES
|
|
@@ -161,7 +169,7 @@ module Kward
|
|
|
161
169
|
lines = File.readlines(target, chomp: true)
|
|
162
170
|
selected = lines.drop(start_line - 1).first(line_count)
|
|
163
171
|
numbered = selected.each_with_index.map { |line, index| "#{start_line + index}: #{line}" }
|
|
164
|
-
truncate((["# Code read", "- Repository: #{repo[:full_name]}", "- Path: #{file}", ""] + numbered).join("\n"))
|
|
172
|
+
truncate((["# Code read", "- Repository: #{repo[:full_name]}", "- Revision: #{revision}", "- Path: #{file}", ""] + numbered).join("\n"))
|
|
165
173
|
rescue ArgumentError => e
|
|
166
174
|
"Error: #{e.message}"
|
|
167
175
|
end
|
|
@@ -231,10 +239,7 @@ module Kward
|
|
|
231
239
|
path = cache_path(repo)
|
|
232
240
|
FileUtils.mkdir_p(@cache_root, mode: 0o700)
|
|
233
241
|
if Dir.exist?(File.join(path, ".git"))
|
|
234
|
-
if refresh
|
|
235
|
-
@git_runner.run("fetch", "--depth", "1", "origin", chdir: path)
|
|
236
|
-
@git_runner.run("reset", "--hard", "FETCH_HEAD", chdir: path)
|
|
237
|
-
end
|
|
242
|
+
synchronize_checkout(path) if refresh
|
|
238
243
|
return [path, false]
|
|
239
244
|
end
|
|
240
245
|
|
|
@@ -243,11 +248,35 @@ module Kward
|
|
|
243
248
|
[path, true]
|
|
244
249
|
end
|
|
245
250
|
|
|
251
|
+
def synchronized_repo(repo, ref)
|
|
252
|
+
path = cache_path(repo)
|
|
253
|
+
lock_path = "#{path}.lock"
|
|
254
|
+
FileUtils.mkdir_p(@cache_root, mode: 0o700)
|
|
255
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
|
|
256
|
+
lock.flock(File::LOCK_EX)
|
|
257
|
+
path, created = ensure_repo(repo, refresh: false)
|
|
258
|
+
synchronize_checkout(path, ref: ref) unless created && ref.to_s.empty?
|
|
259
|
+
revision = @git_runner.run("rev-parse", "HEAD", chdir: path).strip
|
|
260
|
+
revision = "unknown" if revision.empty?
|
|
261
|
+
[path, revision]
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def synchronize_checkout(path, ref: nil)
|
|
266
|
+
if ref.to_s.empty?
|
|
267
|
+
@git_runner.run("fetch", "--depth", "1", "origin", chdir: path)
|
|
268
|
+
else
|
|
269
|
+
@git_runner.run("fetch", "--depth", "1", "origin", ref.to_s, chdir: path)
|
|
270
|
+
end
|
|
271
|
+
@git_runner.run("reset", "--hard", "FETCH_HEAD", chdir: path)
|
|
272
|
+
end
|
|
273
|
+
|
|
246
274
|
def search_files(root, query, max_results:, context_lines:)
|
|
247
275
|
matches = []
|
|
248
276
|
base = File.realpath(root)
|
|
249
277
|
scanned = 0
|
|
250
278
|
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).sort.each do |path|
|
|
279
|
+
@cancellation&.raise_if_cancelled!
|
|
251
280
|
next if matches.length >= max_results || scanned >= MAX_SCANNED_FILES
|
|
252
281
|
next if skip_search_path?(base, path)
|
|
253
282
|
|
|
@@ -327,7 +356,12 @@ module Kward
|
|
|
327
356
|
return nil unless owner.match?(%r{\A[A-Za-z0-9_.-]+\z}) && name.match?(%r{\A[A-Za-z0-9_.-]+\z})
|
|
328
357
|
|
|
329
358
|
full_name = "#{owner}/#{name}"
|
|
330
|
-
{ full_name: full_name, html_url: "https://github.com/#{full_name}", clone_url: "https://github.com/#{full_name}.git" }
|
|
359
|
+
result = { full_name: full_name, html_url: "https://github.com/#{full_name}", clone_url: "https://github.com/#{full_name}.git" }
|
|
360
|
+
if parts[2] == "blob" && parts.length >= 5
|
|
361
|
+
result[:requested_ref] = parts[3]
|
|
362
|
+
result[:requested_path] = parts[4..].join("/")
|
|
363
|
+
end
|
|
364
|
+
result
|
|
331
365
|
rescue URI::InvalidURIError
|
|
332
366
|
nil
|
|
333
367
|
end
|
|
@@ -4,6 +4,7 @@ require "net/http"
|
|
|
4
4
|
require "nokogiri"
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "../../config_files"
|
|
7
|
+
require_relative "../../http"
|
|
7
8
|
|
|
8
9
|
# Namespace for the Kward CLI agent runtime.
|
|
9
10
|
module Kward
|
|
@@ -49,7 +50,9 @@ module Kward
|
|
|
49
50
|
true
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
def search(args)
|
|
53
|
+
def search(args = nil, cancellation: nil, **keyword_args)
|
|
54
|
+
args ||= keyword_args
|
|
55
|
+
cancellation&.raise_if_cancelled!
|
|
53
56
|
queries = args_value(args, "queries")
|
|
54
57
|
return "Error: queries must be an array with 1-#{MAX_QUERIES} strings" unless valid_queries?(queries)
|
|
55
58
|
|
|
@@ -60,36 +63,44 @@ module Kward
|
|
|
60
63
|
provider = normalize_provider(provider_value)
|
|
61
64
|
return "Error: provider must be one of: #{PROVIDERS.join(", ")}" unless provider
|
|
62
65
|
|
|
66
|
+
recency_value = args_value(args, "recency_filter") || args_value(args, "recencyFilter")
|
|
67
|
+
recency = normalize_recency(recency_value)
|
|
68
|
+
return "Error: recency_filter must be one of: day, week, month, year" if recency_value && !recency
|
|
69
|
+
|
|
63
70
|
options = {
|
|
64
71
|
max_results: max_results,
|
|
65
|
-
recency_filter:
|
|
72
|
+
recency_filter: recency,
|
|
66
73
|
domain_filter: normalize_domain_filter(args_value(args, "domain_filter") || args_value(args, "domainFilter")),
|
|
67
74
|
provider: provider
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
sections = ["# Web search"
|
|
77
|
+
sections = ["# Web search"]
|
|
71
78
|
failures = []
|
|
72
|
-
|
|
79
|
+
succeeded = 0
|
|
73
80
|
|
|
74
81
|
queries.each do |query|
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
cancellation&.raise_if_cancelled!
|
|
83
|
+
response, error = search_query(query, options, cancellation: cancellation)
|
|
84
|
+
succeeded += 1 if successful_response?(response)
|
|
77
85
|
failures << "#{query}: #{error}" if error && !successful_response?(response)
|
|
78
86
|
sections << format_query_results(query, response, error)
|
|
79
87
|
end
|
|
80
88
|
|
|
81
|
-
|
|
89
|
+
if succeeded.zero?
|
|
82
90
|
return "Error: web_search found no results\n#{failures.map { |failure| "- #{failure}" }.join("\n")}".strip
|
|
83
91
|
end
|
|
84
92
|
|
|
93
|
+
sections.insert(1, "- Queries: #{queries.length}\n- Succeeded: #{succeeded}\n- Failed: #{queries.length - succeeded}")
|
|
94
|
+
sections.insert(2, "Use fetch_content with a result URL to verify human-readable pages, or fetch_raw for specs, JSON, YAML, XML, and other machine-readable resources.")
|
|
85
95
|
truncate_output(sections.join("\n\n"))
|
|
86
96
|
end
|
|
87
97
|
|
|
88
98
|
private
|
|
89
99
|
|
|
90
|
-
def search_query(query, options)
|
|
100
|
+
def search_query(query, options, cancellation: nil)
|
|
91
101
|
errors = []
|
|
92
102
|
provider_order(options[:provider]).each do |provider|
|
|
103
|
+
cancellation&.raise_if_cancelled!
|
|
93
104
|
begin
|
|
94
105
|
response = case provider
|
|
95
106
|
when "exa"
|
|
@@ -514,7 +525,7 @@ module Kward
|
|
|
514
525
|
lines << answer
|
|
515
526
|
end
|
|
516
527
|
|
|
517
|
-
results = response.results || []
|
|
528
|
+
results = unique_results(response.results || [])
|
|
518
529
|
unless results.empty?
|
|
519
530
|
lines << "Sources:" unless answer.empty?
|
|
520
531
|
results.each_with_index do |result, index|
|
|
@@ -527,6 +538,25 @@ module Kward
|
|
|
527
538
|
lines.join("\n")
|
|
528
539
|
end
|
|
529
540
|
|
|
541
|
+
def unique_results(results)
|
|
542
|
+
seen = {}
|
|
543
|
+
results.each_with_object([]) do |result, unique|
|
|
544
|
+
url = clean_result_url(result.url)
|
|
545
|
+
uri = URI.parse(url)
|
|
546
|
+
next unless %w[http https].include?(uri.scheme) && uri.host
|
|
547
|
+
|
|
548
|
+
uri.fragment = nil
|
|
549
|
+
key = uri.to_s
|
|
550
|
+
next if seen[key]
|
|
551
|
+
|
|
552
|
+
seen[key] = true
|
|
553
|
+
result.url = key
|
|
554
|
+
unique << result
|
|
555
|
+
rescue URI::InvalidURIError
|
|
556
|
+
next
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
530
560
|
def answer_from_results(results)
|
|
531
561
|
results.filter_map do |result|
|
|
532
562
|
excerpt = result.excerpt.to_s.strip
|
|
@@ -590,7 +620,7 @@ module Kward
|
|
|
590
620
|
{
|
|
591
621
|
"Accept" => accept,
|
|
592
622
|
"Accept-Language" => "en-US,en;q=0.9",
|
|
593
|
-
"User-Agent" =>
|
|
623
|
+
"User-Agent" => Http.user_agent,
|
|
594
624
|
"Sec-Fetch-Dest" => "document",
|
|
595
625
|
"Sec-Fetch-Mode" => "navigate",
|
|
596
626
|
"Sec-Fetch-Site" => "none",
|
|
@@ -720,14 +750,14 @@ module Kward
|
|
|
720
750
|
|
|
721
751
|
# HTTP adapter used by web-search providers and fallbacks.
|
|
722
752
|
class NetHttpClient
|
|
723
|
-
Response = Struct.new(:code, :body, :headers, keyword_init: true)
|
|
753
|
+
Response = Struct.new(:code, :body, :headers, :truncated, keyword_init: true)
|
|
724
754
|
|
|
725
|
-
def initialize(user_agent:
|
|
755
|
+
def initialize(user_agent: Http.user_agent)
|
|
726
756
|
@user_agent = user_agent
|
|
727
757
|
end
|
|
728
758
|
|
|
729
|
-
def get(url, headers: {})
|
|
730
|
-
request(url, Net::HTTP::Get, headers: headers)
|
|
759
|
+
def get(url, headers: {}, max_bytes: nil)
|
|
760
|
+
request(url, Net::HTTP::Get, headers: headers, max_bytes: max_bytes)
|
|
731
761
|
end
|
|
732
762
|
|
|
733
763
|
def get_json(url, headers: {})
|
|
@@ -756,15 +786,28 @@ module Kward
|
|
|
756
786
|
|
|
757
787
|
private
|
|
758
788
|
|
|
759
|
-
def request(url, request_class, headers: {})
|
|
789
|
+
def request(url, request_class, headers: {}, max_bytes: nil)
|
|
760
790
|
uri = URI.parse(url)
|
|
761
791
|
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
|
|
762
792
|
http_request = request_class.new(uri)
|
|
763
793
|
http_request["User-Agent"] = @user_agent if @user_agent
|
|
764
794
|
headers.each { |key, value| http_request[key] = value }
|
|
765
795
|
yield http_request if block_given?
|
|
766
|
-
response =
|
|
767
|
-
|
|
796
|
+
response = nil
|
|
797
|
+
body = +""
|
|
798
|
+
truncated = false
|
|
799
|
+
http.request(http_request) do |http_response|
|
|
800
|
+
response = http_response
|
|
801
|
+
http_response.read_body do |chunk|
|
|
802
|
+
if max_bytes && body.bytesize + chunk.bytesize > max_bytes
|
|
803
|
+
body << chunk.byteslice(0, max_bytes - body.bytesize)
|
|
804
|
+
truncated = true
|
|
805
|
+
break
|
|
806
|
+
end
|
|
807
|
+
body << chunk
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
Response.new(code: response.code, body: body, headers: response.each_header.to_h, truncated: truncated)
|
|
768
811
|
end
|
|
769
812
|
end
|
|
770
813
|
|