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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +62 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +13 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/code-search.md +9 -6
  9. data/doc/composer.md +219 -0
  10. data/doc/configuration.md +114 -27
  11. data/doc/editor.md +18 -2
  12. data/doc/files.md +1 -1
  13. data/doc/getting-started.md +4 -0
  14. data/doc/git.md +2 -2
  15. data/doc/lifecycle-hooks.md +2 -14
  16. data/doc/pan.md +167 -0
  17. data/doc/permissions.md +179 -0
  18. data/doc/plugins.md +58 -0
  19. data/doc/rpc.md +61 -22
  20. data/doc/security.md +184 -0
  21. data/doc/tabs.md +4 -3
  22. data/doc/usage.md +15 -32
  23. data/doc/web-search.md +4 -2
  24. data/lib/kward/ansi.rb +2 -2
  25. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  26. data/lib/kward/auth/github_oauth.rb +3 -3
  27. data/lib/kward/auth/oauth_helpers.rb +4 -2
  28. data/lib/kward/auth/openai_oauth.rb +2 -1
  29. data/lib/kward/cancellation.rb +2 -2
  30. data/lib/kward/cli/commands.rb +1 -3
  31. data/lib/kward/cli/interactive_turn.rb +0 -17
  32. data/lib/kward/cli/plugins.rb +24 -7
  33. data/lib/kward/cli/prompt_interface.rb +1 -0
  34. data/lib/kward/cli/rendering.rb +7 -1
  35. data/lib/kward/cli/runtime_helpers.rb +11 -35
  36. data/lib/kward/cli/sessions.rb +14 -5
  37. data/lib/kward/cli/settings.rb +22 -0
  38. data/lib/kward/cli/slash_commands.rb +1 -561
  39. data/lib/kward/cli/tabs.rb +154 -36
  40. data/lib/kward/cli.rb +30 -26
  41. data/lib/kward/compactor.rb +3 -3
  42. data/lib/kward/config_files.rb +28 -0
  43. data/lib/kward/ekwsh.rb +0 -4
  44. data/lib/kward/hooks/catalog.rb +0 -5
  45. data/lib/kward/hooks/http_handler.rb +2 -1
  46. data/lib/kward/http.rb +18 -0
  47. data/lib/kward/image_attachments.rb +1 -1
  48. data/lib/kward/model/client.rb +33 -11
  49. data/lib/kward/model/payloads.rb +6 -1
  50. data/lib/kward/openrouter_model_cache.rb +2 -1
  51. data/lib/kward/pan/index.html.erb +459 -64
  52. data/lib/kward/pan/kward_logo.png +0 -0
  53. data/lib/kward/pan/server.rb +248 -23
  54. data/lib/kward/permissions/policy.rb +171 -0
  55. data/lib/kward/plugin_registry.rb +71 -1
  56. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  57. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  58. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  59. data/lib/kward/prompt_interface/layout.rb +0 -4
  60. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  61. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  62. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  63. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  64. data/lib/kward/prompt_interface/transcript_renderer.rb +0 -12
  65. data/lib/kward/prompt_interface.rb +20 -1
  66. data/lib/kward/prompts/commands.rb +1 -3
  67. data/lib/kward/prompts.rb +20 -0
  68. data/lib/kward/rpc/plugin_chat_manager.rb +299 -0
  69. data/lib/kward/rpc/server.rb +48 -41
  70. data/lib/kward/rpc/session_manager.rb +76 -25
  71. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  72. data/lib/kward/rpc/tool_metadata.rb +23 -0
  73. data/lib/kward/rpc/transcript_normalizer.rb +18 -5
  74. data/lib/kward/session_store.rb +55 -16
  75. data/lib/kward/skills/registry.rb +21 -1
  76. data/lib/kward/starter_pack_installer.rb +3 -1
  77. data/lib/kward/tab_driver.rb +87 -0
  78. data/lib/kward/tab_store.rb +74 -12
  79. data/lib/kward/tools/base.rb +14 -0
  80. data/lib/kward/tools/code_search.rb +8 -2
  81. data/lib/kward/tools/fetch_content.rb +4 -2
  82. data/lib/kward/tools/fetch_raw.rb +3 -1
  83. data/lib/kward/tools/registry.rb +45 -17
  84. data/lib/kward/tools/search/code.rb +47 -13
  85. data/lib/kward/tools/search/web.rb +61 -18
  86. data/lib/kward/tools/search/web_fetch.rb +206 -38
  87. data/lib/kward/tools/tool_call.rb +0 -4
  88. data/lib/kward/tools/web_search.rb +3 -1
  89. data/lib/kward/transcript_export.rb +1 -1
  90. data/lib/kward/update_check.rb +2 -1
  91. data/lib/kward/version.rb +1 -1
  92. data/templates/default/kward_navigation.rb +7 -2
  93. data/templates/default/layout/html/layout.erb +2 -2
  94. data/templates/default/layout/html/setup.rb +1 -0
  95. metadata +11 -12
  96. data/lib/kward/workers/git_guard.rb +0 -93
  97. data/lib/kward/workers/job.rb +0 -99
  98. data/lib/kward/workers/live_view.rb +0 -49
  99. data/lib/kward/workers/manager.rb +0 -328
  100. data/lib/kward/workers/queue_runner.rb +0 -166
  101. data/lib/kward/workers/queue_store.rb +0 -112
  102. data/lib/kward/workers/store.rb +0 -72
  103. data/lib/kward/workers/tool_policy.rb +0 -23
  104. data/lib/kward/workers/worker.rb +0 -82
  105. data/lib/kward/workers/write_lock.rb +0 -38
  106. data/lib/kward/workers.rb +0 -10
@@ -3,11 +3,22 @@ module Kward
3
3
  # Model-callable tool wrappers and their argument schemas.
4
4
  module Tools
5
5
  # Base class for model-callable tools and their JSON schemas.
6
+ #
7
+ # Concrete built-in tools initialize this class with their model-facing name,
8
+ # description, and strict argument schema, then implement `call` to perform
9
+ # the operation. Tool execution is coordinated by {Kward::ToolRegistry}.
10
+ #
11
+ # @api public
6
12
  class Base
7
13
  # @return [String] function name exposed to the model
8
14
  attr_reader :name
9
15
 
10
16
  # Creates a tool schema definition shared by all concrete tool wrappers.
17
+ #
18
+ # @param name [String] function name exposed to the model
19
+ # @param description [String] model-facing description of the operation
20
+ # @param properties [Hash] JSON Schema properties keyed by argument name
21
+ # @param required [Array<String, Symbol>] required argument names
11
22
  def initialize(name, description, properties: {}, required: [])
12
23
  @name = name
13
24
  @description = description
@@ -16,6 +27,9 @@ module Kward
16
27
  end
17
28
 
18
29
  # Returns the strict JSON schema advertised to model providers.
30
+ #
31
+ # @return [Hash] function schema with additional properties disabled
32
+ # @api public
19
33
  def schema
20
34
  {
21
35
  type: "function",
@@ -37,7 +37,11 @@ module Kward
37
37
  },
38
38
  path: {
39
39
  type: "string",
40
- description: "Repo-relative path."
40
+ description: "Repo-relative path; inferred from GitHub blob URLs when omitted."
41
+ },
42
+ ref: {
43
+ type: "string",
44
+ description: "Git branch, tag, or commit; inferred from GitHub blob URLs when omitted."
41
45
  },
42
46
  start_line: {
43
47
  type: "integer",
@@ -63,7 +67,9 @@ module Kward
63
67
  # Executes the tool and returns model-facing output text.
64
68
  def call(args, _conversation, cancellation: nil)
65
69
  cancellation&.raise_if_cancelled!
66
- @code_search.call(args)
70
+ return @code_search.call(args) unless cancellation
71
+
72
+ @code_search.call(args, cancellation: cancellation)
67
73
  end
68
74
  end
69
75
  end
@@ -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
@@ -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"
@@ -46,11 +47,12 @@ module Kward
46
47
  # individual tools, and legacy-compatible shapes are accepted where already
47
48
  # supported. Required fields and invalid required values should still return
48
49
  # explicit tool errors.
50
+ # @api public
49
51
  class ToolRegistry
50
52
  # Tool schemas advertised to the model for the current frontend and config.
51
53
  #
52
54
  # @return [Array<Hash>] tool schemas currently advertised to the model
53
- attr_reader :schemas, :writer_id
55
+ attr_reader :schemas
54
56
 
55
57
  # Builds tool objects and the schema list for the current frontend/config.
56
58
  #
@@ -63,7 +65,7 @@ module Kward
63
65
  # @param web_search_enabled [Boolean, nil] override for web search exposure
64
66
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
65
67
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
66
- 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, write_lock: nil, writer_id: 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)
67
69
  @workspace = workspace
68
70
  @prompt = prompt
69
71
  @web_search = web_search
@@ -73,12 +75,12 @@ module Kward
73
75
  @web_search_enabled = web_search_enabled
74
76
  @ask_user_question_enabled = ask_user_question_enabled
75
77
  @allowed_tool_names = allowed_tool_names&.map(&:to_s)
76
- @write_lock = write_lock
77
- @writer_id = writer_id
78
78
  @tool_output_compactor = tool_output_compactor
79
79
  @telemetry_logger = telemetry_logger
80
80
  @context_budget_meter = context_budget_meter
81
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)
82
84
  @hook_manager = hook_manager
83
85
  @hook_context = hook_context
84
86
  @mcp_clients = if mcp_clients
@@ -115,12 +117,25 @@ module Kward
115
117
  hook_denied_content(before_tool, "tool call denied: #{name}")
116
118
  elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
117
119
  hook_denied_content(before_tool, "tool call approval denied: #{name}")
118
- elsif mutation_tool?(name) && !write_lock_owned?
119
- "Workspace write denied: another worker owns the write lock."
120
- elsif tool_approval_denied?(tool_call, name, args, cancellation)
121
- "Declined: tool execution denied by user: #{name}"
122
120
  else
123
- execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
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
124
139
  end
125
140
  else
126
141
  "Unknown tool: #{name}"
@@ -148,6 +163,14 @@ module Kward
148
163
  model_content
149
164
  end
150
165
 
166
+ # Returns frontend discovery metadata for a tool name.
167
+ #
168
+ # Unknown names return metadata with an unknown source rather than raising,
169
+ # which lets RPC clients render restored or unsupported tool calls safely.
170
+ #
171
+ # @param name [String, #to_s] exposed tool name
172
+ # @return [Hash] source, display name, and optional MCP identity
173
+ # @api public
151
174
  def metadata_for(name)
152
175
  tool = @tools[name.to_s]
153
176
  return unknown_tool_metadata(name) unless tool
@@ -179,6 +202,8 @@ module Kward
179
202
 
180
203
  def source_for_tool(tool)
181
204
  case tool
205
+ when Tools::MCPTool
206
+ "mcp"
182
207
  when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
183
208
  "web"
184
209
  when Tools::ReadSkill
@@ -415,20 +440,23 @@ module Kward
415
440
  (metadata[:source] || metadata["source"]).to_s == "mcp"
416
441
  end
417
442
 
418
- def mutation_tool?(name)
419
- ToolCall.write_lock_required?(name)
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)
420
447
  end
421
448
 
422
- def tool_approval_denied?(tool_call, name, args, cancellation)
423
- return false unless @tool_approval
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?
424
452
 
425
- @tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation) == false
453
+ "Declined: tool execution denied by user: #{name}"
426
454
  end
427
455
 
428
- def write_lock_owned?
429
- return true unless @write_lock
456
+ def tool_approval_denied?(tool_call, name, args, cancellation)
457
+ return false unless @tool_approval
430
458
 
431
- @write_lock.owned_by?(@writer_id)
459
+ @tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation) == false
432
460
  end
433
461
 
434
462
  def build_tools
@@ -1,4 +1,4 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require "fileutils"
3
3
  require "json"
4
4
  require "net/http"
@@ -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, = ensure_repo(repo, refresh: false)
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
- return "Error: no matches found in #{repo[:full_name]}" if matches.empty?
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, = ensure_repo(repo, refresh: false)
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
@@ -1,9 +1,10 @@
1
- require "cgi"
1
+ require "cgi/escape"
2
2
  require "json"
3
3
  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: normalize_recency(args_value(args, "recency_filter") || args_value(args, "recencyFilter")),
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", "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."]
77
+ sections = ["# Web search"]
71
78
  failures = []
72
- any_results = false
79
+ succeeded = 0
73
80
 
74
81
  queries.each do |query|
75
- response, error = search_query(query, options)
76
- any_results = true if successful_response?(response)
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
- unless any_results
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" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
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: nil)
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 = http.request(http_request)
767
- Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
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