kward 0.77.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +7 -2
  3. data/.github/workflows/pages.yml +1 -1
  4. data/CHANGELOG.md +50 -0
  5. data/Gemfile.lock +2 -2
  6. data/README.md +5 -2
  7. data/doc/agent-tools.md +1 -1
  8. data/doc/code-search.md +9 -6
  9. data/doc/configuration.md +106 -1
  10. data/doc/extensibility.md +2 -0
  11. data/doc/getting-started.md +1 -1
  12. data/doc/local-models.md +130 -0
  13. data/doc/permissions.md +180 -0
  14. data/doc/plugins.md +58 -0
  15. data/doc/releasing.md +1 -1
  16. data/doc/rpc.md +73 -1
  17. data/doc/sandboxing.md +120 -0
  18. data/doc/security.md +15 -5
  19. data/doc/skills.md +10 -0
  20. data/doc/tabs.md +4 -3
  21. data/doc/web-search.md +4 -2
  22. data/doc/workspace-tools.md +3 -3
  23. data/kward.gemspec +1 -1
  24. data/lib/kward/auth/anthropic_oauth.rb +2 -2
  25. data/lib/kward/auth/github_oauth.rb +3 -3
  26. data/lib/kward/auth/oauth_helpers.rb +4 -2
  27. data/lib/kward/auth/openai_oauth.rb +2 -1
  28. data/lib/kward/cli/doctor.rb +21 -0
  29. data/lib/kward/cli/plugins.rb +22 -0
  30. data/lib/kward/cli/rendering.rb +7 -1
  31. data/lib/kward/cli/runtime_helpers.rb +15 -1
  32. data/lib/kward/cli/settings.rb +66 -4
  33. data/lib/kward/cli/slash_commands.rb +109 -1
  34. data/lib/kward/cli/tabs.rb +140 -34
  35. data/lib/kward/cli.rb +31 -10
  36. data/lib/kward/config_files.rb +90 -1
  37. data/lib/kward/conversation.rb +14 -1
  38. data/lib/kward/hooks/http_handler.rb +2 -1
  39. data/lib/kward/http.rb +18 -0
  40. data/lib/kward/local_command_runner.rb +2 -2
  41. data/lib/kward/model/client.rb +173 -15
  42. data/lib/kward/model/model_info.rb +11 -1
  43. data/lib/kward/model/payloads.rb +7 -1
  44. data/lib/kward/model/stream_parser.rb +58 -26
  45. data/lib/kward/openrouter_model_cache.rb +2 -1
  46. data/lib/kward/pan/index.html.erb +50 -0
  47. data/lib/kward/pan/server.rb +49 -2
  48. data/lib/kward/permissions/policy.rb +171 -0
  49. data/lib/kward/plugin_registry.rb +54 -2
  50. data/lib/kward/prompt_interface/approval_prompt.rb +62 -0
  51. data/lib/kward/prompt_interface/editor/controller.rb +36 -3
  52. data/lib/kward/prompt_interface/question_prompt.rb +12 -3
  53. data/lib/kward/prompt_interface.rb +20 -0
  54. data/lib/kward/prompts/commands.rb +2 -0
  55. data/lib/kward/prompts.rb +16 -5
  56. data/lib/kward/rpc/plugin_chat_manager.rb +302 -0
  57. data/lib/kward/rpc/server.rb +76 -3
  58. data/lib/kward/rpc/session_manager.rb +43 -8
  59. data/lib/kward/rpc/transcript_normalizer.rb +14 -5
  60. data/lib/kward/sandbox/capabilities.rb +39 -0
  61. data/lib/kward/sandbox/command_runner.rb +28 -0
  62. data/lib/kward/sandbox/environment.rb +24 -0
  63. data/lib/kward/sandbox/linux_bubblewrap_runner.rb +71 -0
  64. data/lib/kward/sandbox/macos_seatbelt_runner.rb +96 -0
  65. data/lib/kward/sandbox/passthrough_runner.rb +13 -0
  66. data/lib/kward/sandbox/policy.rb +74 -0
  67. data/lib/kward/sandbox/runner_factory.rb +55 -0
  68. data/lib/kward/sandbox/unavailable_runner.rb +21 -0
  69. data/lib/kward/sandbox.rb +9 -0
  70. data/lib/kward/session_store.rb +26 -0
  71. data/lib/kward/skills/capture.rb +144 -0
  72. data/lib/kward/starter_pack_installer.rb +3 -1
  73. data/lib/kward/tab_driver.rb +87 -0
  74. data/lib/kward/tab_store.rb +74 -12
  75. data/lib/kward/tools/code_search.rb +8 -2
  76. data/lib/kward/tools/fetch_content.rb +4 -2
  77. data/lib/kward/tools/fetch_raw.rb +3 -1
  78. data/lib/kward/tools/registry.rb +56 -8
  79. data/lib/kward/tools/search/code.rb +46 -12
  80. data/lib/kward/tools/search/web.rb +60 -17
  81. data/lib/kward/tools/search/web_fetch.rb +206 -38
  82. data/lib/kward/tools/web_search.rb +3 -1
  83. data/lib/kward/update_check.rb +2 -1
  84. data/lib/kward/version.rb +1 -1
  85. data/lib/kward/workspace.rb +18 -3
  86. data/lib/kward/workspace_factory.rb +17 -0
  87. data/templates/default/fulldoc/html/js/kward.js +1 -0
  88. data/templates/default/kward_navigation.rb +5 -2
  89. data/templates/default/layout/html/layout.erb +2 -0
  90. data/templates/default/layout/html/setup.rb +2 -0
  91. metadata +22 -2
@@ -0,0 +1,87 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Adapts a session-backed agent to the tab runtime interface. Plugin tab
4
+ # drivers implement the same small surface without becoming Kward sessions.
5
+ class SessionTabDriver
6
+ attr_reader :session, :agent
7
+
8
+ def initialize(session:, agent:)
9
+ @session = session
10
+ @agent = agent
11
+ end
12
+
13
+ def messages
14
+ agent.conversation.messages
15
+ end
16
+
17
+ def conversation
18
+ agent.conversation
19
+ end
20
+
21
+ def submit(input, display_input:, cancellation:, steering: nil, &block)
22
+ options = { cancellation: cancellation }
23
+ options[:display_input] = display_input unless display_input.nil?
24
+ options[:steering] = steering if steering
25
+ agent.ask(input, **options, &block)
26
+ end
27
+
28
+ def descriptor
29
+ { "kind" => "session", "session_path" => session.path }
30
+ end
31
+
32
+ def session?
33
+ true
34
+ end
35
+
36
+ def supports_steering?
37
+ true
38
+ end
39
+
40
+ def assistant_label
41
+ nil
42
+ end
43
+ end
44
+
45
+ # Represents a persisted plugin tab whose provider plugin is unavailable.
46
+ # Its descriptor is retained so reinstalling the plugin restores the tab.
47
+ class UnavailableTabDriver
48
+ attr_reader :descriptor
49
+
50
+ def initialize(descriptor:, message:)
51
+ @descriptor = descriptor
52
+ @message = message
53
+ end
54
+
55
+ def messages
56
+ [{ role: "assistant", content: @message }]
57
+ end
58
+
59
+ def submit(*)
60
+ raise @message
61
+ end
62
+
63
+ def session?
64
+ false
65
+ end
66
+
67
+ def supports_steering?
68
+ false
69
+ end
70
+
71
+ def assistant_label
72
+ "Plugin"
73
+ end
74
+ end
75
+
76
+ # Dependencies made available to a plugin tab factory. The host deliberately
77
+ # exposes provider transport and frontend-neutral facts, not CLI internals or
78
+ # workspace session state.
79
+ class PluginTabHost
80
+ attr_reader :client, :workspace_root
81
+
82
+ def initialize(client:, workspace_root:)
83
+ @client = client
84
+ @workspace_root = workspace_root
85
+ end
86
+ end
87
+ end
@@ -5,32 +5,45 @@ require_relative "private_file"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
8
- # Persists the terminal UI's open session tabs per workspace.
8
+ # Persists the terminal UI's open tabs per workspace.
9
9
  class TabStore
10
+ VERSION = 2
11
+
10
12
  def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
11
13
  @config_dir = config_dir
12
14
  @cwd = File.expand_path(cwd)
13
15
  end
14
16
 
15
17
  def load
16
- return { "session_paths" => [], "active_index" => 0 } unless File.file?(path)
18
+ return empty_state unless File.file?(path)
17
19
 
18
20
  data = JSON.parse(File.read(path))
19
- paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
20
- labels = Array(data["labels"]).map(&:to_s)
21
- active_index = data["active_index"].to_i
22
- { "session_paths" => paths, "labels" => labels, "active_index" => active_index }
21
+ return typed_state(data) if data["tabs"].is_a?(Array)
22
+
23
+ legacy_state(data)
23
24
  rescue JSON::ParserError
24
- { "session_paths" => [], "active_index" => 0 }
25
+ empty_state
25
26
  end
26
27
 
27
- def save(session_paths:, active_index:, labels: [])
28
- paths = Array(session_paths).map(&:to_s).reject(&:empty?)
28
+ # `session_paths` remains supported for callers and layouts written before
29
+ # typed tab descriptors were introduced.
30
+ def save(session_paths: nil, tabs: nil, active_index:, labels: [])
31
+ tabs ||= Array(session_paths).filter_map.with_index do |session_path, index|
32
+ path = session_path.to_s
33
+ next if path.empty?
34
+
35
+ {
36
+ "kind" => "session",
37
+ "session_path" => path,
38
+ "label" => Array(labels)[index].to_s
39
+ }
40
+ end
41
+ tabs = normalize_tabs(tabs)
29
42
  PrivateFile.write_json(path, {
43
+ "version" => VERSION,
30
44
  "cwd" => @cwd,
31
- "session_paths" => paths,
32
- "labels" => Array(labels).map(&:to_s),
33
- "active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
45
+ "tabs" => tabs,
46
+ "active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
34
47
  })
35
48
  end
36
49
 
@@ -40,6 +53,55 @@ module Kward
40
53
 
41
54
  private
42
55
 
56
+ def empty_state
57
+ { "tabs" => [], "session_paths" => [], "labels" => [], "active_index" => 0 }
58
+ end
59
+
60
+ def typed_state(data)
61
+ tabs = normalize_tabs(data["tabs"])
62
+ state_for(tabs, data["active_index"])
63
+ end
64
+
65
+ def legacy_state(data)
66
+ paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
67
+ labels = Array(data["labels"]).map(&:to_s)
68
+ tabs = paths.each_with_index.map do |session_path, index|
69
+ { "kind" => "session", "session_path" => session_path, "label" => labels[index].to_s }
70
+ end
71
+ state_for(tabs, data["active_index"])
72
+ end
73
+
74
+ def state_for(tabs, active_index)
75
+ {
76
+ "tabs" => tabs,
77
+ "session_paths" => tabs.filter_map { |tab| tab["session_path"] if tab["kind"] == "session" },
78
+ "labels" => tabs.map { |tab| tab["label"].to_s },
79
+ "active_index" => [[active_index.to_i, 0].max, [tabs.length - 1, 0].max].min
80
+ }
81
+ end
82
+
83
+ def normalize_tabs(tabs)
84
+ session_paths = {}
85
+
86
+ Array(tabs).filter_map do |tab|
87
+ next unless tab.is_a?(Hash)
88
+
89
+ normalized = tab.transform_keys(&:to_s)
90
+ kind = normalized["kind"].to_s
91
+ next if kind.empty?
92
+ next if kind == "session" && normalized["session_path"].to_s.empty?
93
+
94
+ if kind == "session"
95
+ session_path = File.expand_path(normalized["session_path"].to_s, @cwd)
96
+ next if session_paths[session_path]
97
+
98
+ session_paths[session_path] = true
99
+ end
100
+
101
+ normalized
102
+ end
103
+ end
104
+
43
105
  def workspace_key
44
106
  Digest::SHA256.hexdigest(@cwd)[0, 24]
45
107
  end
@@ -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"
@@ -48,6 +49,19 @@ module Kward
48
49
  # explicit tool errors.
49
50
  # @api public
50
51
  class ToolRegistry
52
+ CORE_TOOL_NAMES = %w[
53
+ list_directory
54
+ read_file
55
+ write_file
56
+ edit_file
57
+ run_shell_command
58
+ code_search
59
+ summarize_file_structure
60
+ context_for_task
61
+ context_budget_stats
62
+ retrieve_tool_output
63
+ ].freeze
64
+
51
65
  # Tool schemas advertised to the model for the current frontend and config.
52
66
  #
53
67
  # @return [Array<Hash>] tool schemas currently advertised to the model
@@ -64,7 +78,7 @@ module Kward
64
78
  # @param web_search_enabled [Boolean, nil] override for web search exposure
65
79
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
66
80
  # @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)
81
+ 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
82
  @workspace = workspace
69
83
  @prompt = prompt
70
84
  @web_search = web_search
@@ -78,6 +92,8 @@ module Kward
78
92
  @telemetry_logger = telemetry_logger
79
93
  @context_budget_meter = context_budget_meter
80
94
  @tool_approval = tool_approval
95
+ @approval_for_allowed_tools = approval_for_allowed_tools == true
96
+ @permission_policy = permission_policy || Permissions::Policy.from_config(ConfigFiles.read_config)
81
97
  @hook_manager = hook_manager
82
98
  @hook_context = hook_context
83
99
  @mcp_clients = if mcp_clients
@@ -114,10 +130,25 @@ module Kward
114
130
  hook_denied_content(before_tool, "tool call denied: #{name}")
115
131
  elsif before_tool.approval_required? && hook_approval_denied?(before_tool, tool_call, name, args, cancellation)
116
132
  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
133
  else
120
- execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
134
+ permission_decision = @permission_policy.decision_for(name, args, source: source_for_tool(tool))
135
+ if permission_decision.denied?
136
+ "Declined: #{permission_decision.reason}: #{name}"
137
+ elsif permission_decision.approval_required?
138
+ approval = permission_approval_result(tool_call, name, args, cancellation)
139
+ if approval == :allow_for_session
140
+ @permission_policy.allow_for_session!(name)
141
+ execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
142
+ elsif approval == true
143
+ execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
144
+ else
145
+ approval_denied_content(approval, name)
146
+ end
147
+ elsif @approval_for_allowed_tools && tool_approval_denied?(tool_call, name, args, cancellation)
148
+ "Declined: tool execution denied by user: #{name}"
149
+ else
150
+ execute_tool_with_hooks(tool, name, args, tool_call, conversation, cancellation)
151
+ end
121
152
  end
122
153
  else
123
154
  "Unknown tool: #{name}"
@@ -184,6 +215,8 @@ module Kward
184
215
 
185
216
  def source_for_tool(tool)
186
217
  case tool
218
+ when Tools::MCPTool
219
+ "mcp"
187
220
  when Tools::WebSearch, Tools::FetchContent, Tools::FetchRaw
188
221
  "web"
189
222
  when Tools::ReadSkill
@@ -420,6 +453,19 @@ module Kward
420
453
  (metadata[:source] || metadata["source"]).to_s == "mcp"
421
454
  end
422
455
 
456
+ def permission_approval_result(tool_call, name, args, cancellation)
457
+ return false unless @tool_approval
458
+
459
+ @tool_approval.call(tool_call: tool_call, name: name, args: args, cancellation: cancellation)
460
+ end
461
+
462
+ def approval_denied_content(approval, name)
463
+ message = approval[:denied_message] if approval.is_a?(Hash)
464
+ return "Declined: #{message}" unless message.to_s.empty?
465
+
466
+ "Declined: tool execution denied by user: #{name}"
467
+ end
468
+
423
469
  def tool_approval_denied?(tool_call, name, args, cancellation)
424
470
  return false unless @tool_approval
425
471
 
@@ -429,13 +475,15 @@ module Kward
429
475
  def build_tools
430
476
  tools = all_tools
431
477
  tools = tools.select { |tool| @allowed_tool_names.include?(tool.name) } if @allowed_tool_names
432
- tools.to_h { |tool| [tool.name, tool] }
478
+ tools.each_with_object({}) do |tool, result|
479
+ raise ArgumentError, "Duplicate tool name: #{tool.name}" if result.key?(tool.name)
480
+
481
+ result[tool.name] = tool
482
+ end
433
483
  end
434
484
 
435
485
  def build_schema_tools
436
- tools = @tools.values_at(
437
- "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "context_for_task", "context_budget_stats", "retrieve_tool_output"
438
- )
486
+ tools = @tools.values_at(*CORE_TOOL_NAMES)
439
487
  tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
440
488
  tools.concat(@tools.values.select { |tool| tool.is_a?(Tools::MCPTool) })
441
489
  tools << @tools["read_skill"] if skills_available?
@@ -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
@@ -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: 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