kward 0.68.0 → 0.69.1

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +40 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +32 -25
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +74 -56
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +18 -0
  11. data/doc/extensibility.md +89 -128
  12. data/doc/getting-started.md +52 -54
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -97
  16. data/doc/releasing.md +3 -1
  17. data/doc/rpc.md +1 -1
  18. data/doc/usage.md +125 -144
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/lib/kward/agent.rb +1 -1
  22. data/lib/kward/cli/commands.rb +10 -3
  23. data/lib/kward/cli/compaction.rb +3 -3
  24. data/lib/kward/cli/interactive_turn.rb +3 -1
  25. data/lib/kward/cli/memory_commands.rb +16 -16
  26. data/lib/kward/cli/plugins.rb +3 -3
  27. data/lib/kward/cli/prompt_interface.rb +15 -13
  28. data/lib/kward/cli/rendering.rb +35 -46
  29. data/lib/kward/cli/runtime_helpers.rb +13 -2
  30. data/lib/kward/cli/sessions.rb +21 -21
  31. data/lib/kward/cli/settings.rb +49 -43
  32. data/lib/kward/cli/slash_commands.rb +6 -4
  33. data/lib/kward/cli/stats.rb +2 -2
  34. data/lib/kward/cli/sysprompt.rb +57 -0
  35. data/lib/kward/cli/tool_summaries.rb +5 -1
  36. data/lib/kward/cli.rb +14 -2
  37. data/lib/kward/cli_transcript_formatter.rb +36 -5
  38. data/lib/kward/compactor.rb +2 -2
  39. data/lib/kward/config_files.rb +45 -10
  40. data/lib/kward/conversation.rb +41 -9
  41. data/lib/kward/memory/manager.rb +131 -14
  42. data/lib/kward/message_access.rb +6 -0
  43. data/lib/kward/model/context_usage.rb +11 -10
  44. data/lib/kward/model/model_info.rb +18 -1
  45. data/lib/kward/model/payloads.rb +89 -10
  46. data/lib/kward/model/stream_parser.rb +258 -25
  47. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  48. data/lib/kward/prompt_interface/transcript_renderer.rb +20 -11
  49. data/lib/kward/prompts.rb +61 -7
  50. data/lib/kward/rpc/server.rb +7 -2
  51. data/lib/kward/rpc/session_manager.rb +18 -2
  52. data/lib/kward/rpc/session_metrics.rb +2 -2
  53. data/lib/kward/rpc/session_tree_rows.rb +54 -13
  54. data/lib/kward/rpc/transcript_normalizer.rb +47 -0
  55. data/lib/kward/session_store.rb +45 -2
  56. data/lib/kward/session_tree_renderer.rb +54 -13
  57. data/lib/kward/starter_pack_installer.rb +2 -2
  58. data/lib/kward/tools/fetch_content.rb +41 -0
  59. data/lib/kward/tools/fetch_raw.rb +40 -0
  60. data/lib/kward/tools/registry.rb +9 -2
  61. data/lib/kward/tools/search/web.rb +3 -3
  62. data/lib/kward/tools/search/web_fetch.rb +202 -0
  63. data/lib/kward/tools/tool_call.rb +2 -0
  64. data/lib/kward/version.rb +1 -1
  65. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  66. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  67. data/templates/default/fulldoc/html/js/kward.js +296 -0
  68. data/templates/default/fulldoc/html/setup.rb +8 -0
  69. data/templates/default/layout/html/breadcrumb.erb +11 -0
  70. data/templates/default/layout/html/layout.erb +141 -0
  71. data/templates/default/layout/html/setup.rb +139 -0
  72. metadata +14 -1
@@ -34,7 +34,12 @@ module Kward
34
34
  multiple_roots = visible_roots.length > 1
35
35
  result = []
36
36
 
37
- walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
37
+ stack = visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
38
+ [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
39
+ end.reverse
40
+
41
+ until stack.empty?
42
+ node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
38
43
  entry = node[:source]["entry"] || {}
39
44
  entry_id = entry["id"].to_s
40
45
  formatted = tree_entry_display(entry, tool_calls_by_id)
@@ -66,14 +71,10 @@ module Kward
66
71
  end
67
72
  connector_position = [display_indent - 1, 0].max
68
73
  child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
69
- children.each_with_index do |child, index|
70
- walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
74
+ children.each_with_index.reverse_each do |child, index|
75
+ stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
71
76
  end
72
77
  end
73
-
74
- visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
75
- walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
76
- end
77
78
  result
78
79
  end
79
80
 
@@ -83,7 +84,9 @@ module Kward
83
84
  by_id = tree_entries_by_id(roots)
84
85
  ids = []
85
86
  current = by_id[leaf_id.to_s]
86
- while current
87
+ seen = {}
88
+ while current && !seen[current["id"].to_s]
89
+ seen[current["id"].to_s] = true
87
90
  ids << current["id"].to_s
88
91
  current = by_id[current["parentId"].to_s]
89
92
  end
@@ -93,8 +96,12 @@ module Kward
93
96
  def tree_entries_by_id(roots)
94
97
  roots.each_with_object({}) do |root, map|
95
98
  stack = [root]
99
+ seen = {}
96
100
  until stack.empty?
97
101
  node = stack.pop
102
+ next if seen[node.object_id]
103
+
104
+ seen[node.object_id] = true
98
105
  entry = node["entry"] || {}
99
106
  map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
100
107
  stack.concat(Array(node["children"]))
@@ -103,10 +110,29 @@ module Kward
103
110
  end
104
111
 
105
112
  def visible_tree_nodes(node)
106
- children = Array(node["children"]).flat_map { |child| visible_tree_nodes(child) }
107
- return children if hidden_tree_entry?(node["entry"] || {})
113
+ results = {}
114
+ stack = [[node, false, {}]]
115
+
116
+ until stack.empty?
117
+ current, visited, seen = stack.pop
118
+ node_key = current.object_id
119
+ next if seen[node_key]
120
+
121
+ if visited
122
+ children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
123
+ results[node_key] = if hidden_tree_entry?(current["entry"] || {})
124
+ children
125
+ else
126
+ [{ source: current, children: children }]
127
+ end
128
+ else
129
+ branch_seen = seen.merge(node_key => true)
130
+ stack << [current, true, seen]
131
+ Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
132
+ end
133
+ end
108
134
 
109
- [{ source: node, children: children }]
135
+ results[node.object_id] || []
110
136
  end
111
137
 
112
138
  def hidden_tree_entry?(entry)
@@ -126,15 +152,30 @@ module Kward
126
152
  end
127
153
 
128
154
  def tree_contains_active_path?(node, active_path)
129
- entry_id = (node[:source]["entry"] || {})["id"].to_s
130
- active_path.include?(entry_id) || node[:children].any? { |child| tree_contains_active_path?(child, active_path) }
155
+ stack = [node]
156
+ seen = {}
157
+ until stack.empty?
158
+ current = stack.pop
159
+ next if seen[current.object_id]
160
+
161
+ seen[current.object_id] = true
162
+ entry_id = (current[:source]["entry"] || {})["id"].to_s
163
+ return true if active_path.include?(entry_id)
164
+
165
+ stack.concat(current[:children])
166
+ end
167
+ false
131
168
  end
132
169
 
133
170
  def tree_tool_calls(roots)
134
171
  roots.each_with_object({}) do |root, tool_calls_by_id|
135
172
  stack = [root]
173
+ seen = {}
136
174
  until stack.empty?
137
175
  node = stack.pop
176
+ next if seen[node.object_id]
177
+
178
+ seen[node.object_id] = true
138
179
  entry = node["entry"] || {}
139
180
  message = entry["message"]
140
181
  if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
@@ -61,6 +61,7 @@ module Kward
61
61
 
62
62
  def normalize_assistant_message(message)
63
63
  content = reasoning_first_content(normalize_content(ToolCall.value(message, :content), preserve_thinking: true))
64
+ content = response_item_content(message) if text_content_empty?(content)
64
65
  reasoning = normalize_reasoning_summary(message)
65
66
  content.unshift(reasoning) if reasoning && !thinking_content?(content)
66
67
  tool_calls(message).each do |tool_call|
@@ -154,6 +155,52 @@ module Kward
154
155
  summary.to_s.empty? ? nil : { type: "thinking", thinking: summary.to_s }
155
156
  end
156
157
 
158
+ def response_item_content(message)
159
+ response_items(message).filter_map do |item|
160
+ next unless item.is_a?(Hash)
161
+
162
+ case ToolCall.value(item, :type).to_s
163
+ when "reasoning"
164
+ thinking = reasoning_item_text(item)
165
+ { type: "thinking", thinking: thinking } unless thinking.empty?
166
+ when "message"
167
+ next if ToolCall.value(item, :phase).to_s == "commentary"
168
+
169
+ text = response_message_item_text(item)
170
+ { type: "text", text: text } unless text.empty?
171
+ end
172
+ end
173
+ end
174
+
175
+ def response_items(message)
176
+ items = ToolCall.value(message, :response_items) || ToolCall.value(message, :responseItems)
177
+ items.is_a?(Array) ? items : []
178
+ end
179
+
180
+ def reasoning_item_text(item)
181
+ summary = ToolCall.value(item, :summary)
182
+ content = ToolCall.value(item, :content)
183
+ response_text_parts(summary).empty? ? response_text_parts(content).join("\n\n") : response_text_parts(summary).join("\n\n")
184
+ end
185
+
186
+ def response_message_item_text(item)
187
+ response_text_parts(ToolCall.value(item, :content)).join
188
+ end
189
+
190
+ def response_text_parts(parts)
191
+ Array(parts).filter_map do |part|
192
+ next unless part.is_a?(Hash)
193
+
194
+ ToolCall.value(part, :text) || ToolCall.value(part, :refusal)
195
+ end.map(&:to_s)
196
+ end
197
+
198
+ def text_content_empty?(content)
199
+ Array(content).all? do |part|
200
+ !part.is_a?(Hash) || !["text", "thinking"].include?(ToolCall.value(part, :type).to_s) || ToolCall.value(part, :text).to_s.empty? && ToolCall.value(part, :thinking).to_s.empty?
201
+ end
202
+ end
203
+
157
204
  def thinking_content?(content)
158
205
  content.any? { |part| thinking_content_part?(part) }
159
206
  end
@@ -1,4 +1,5 @@
1
1
  require "fileutils"
2
+ require "digest"
2
3
  require "json"
3
4
  require "securerandom"
4
5
  require "time"
@@ -28,7 +29,7 @@ module Kward
28
29
  VERSION = 2
29
30
  LAST_SESSION_FILENAME = "last_session.json"
30
31
 
31
- SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
32
+ SessionInfo = Struct.new(:id, :path, :cwd, :created_at, :modified_at, :name, :first_message, :message_count, :provider, :model, :reasoning_effort, :parent_id, :parent_path, :depth, :is_last, :ancestor_continues, keyword_init: true)
32
33
 
33
34
  # Live handle that attaches persistence callbacks to a conversation.
34
35
  #
@@ -77,6 +78,8 @@ module Kward
77
78
  conversation.on_compact = lambda { |message| compact(message) }
78
79
  conversation.on_tool_execution = lambda { |tool_call, content| append_tool_execution(tool_call, content) }
79
80
  conversation.on_runtime_update = lambda { |provider:, model:, reasoning_effort:| update_runtime(provider: provider, model: model, reasoning_effort: reasoning_effort) }
81
+ conversation.on_system_message_change = lambda { |system_message| append_system_prompt_snapshot(system_message, reason: "changed") }
82
+ append_system_prompt_snapshot(conversation.system_message, reason: "attach")
80
83
  self
81
84
  end
82
85
 
@@ -99,6 +102,11 @@ module Kward
99
102
  @store.append_record(@path, RPC::ToolEventNormalizer.new(tool_call, content: content).execution_record)
100
103
  end
101
104
 
105
+ # Persists the current system prompt as audit metadata when it changes.
106
+ def append_system_prompt_snapshot(system_message, reason: "changed")
107
+ @store.append_system_prompt_snapshot(@path, system_message, reason: reason)
108
+ end
109
+
102
110
  # Persists the session memory snapshot used when the session is restored.
103
111
  def update_memory_state(session_memories:, last_retrieval: nil)
104
112
  @store.append_record(@path, {
@@ -427,12 +435,39 @@ module Kward
427
435
  end
428
436
  end
429
437
 
438
+ def append_system_prompt_snapshot(path, system_message, reason: "changed")
439
+ content = MessageAccess.content(system_message).to_s
440
+ return if content.empty?
441
+ return if latest_system_prompt_hash(records_from_file(path)) == system_prompt_hash(content)
442
+
443
+ append_record(path, {
444
+ type: "system_prompt",
445
+ timestamp: Time.now.utc.iso8601(3),
446
+ reason: reason.to_s,
447
+ hash: system_prompt_hash(content),
448
+ content: content
449
+ })
450
+ end
451
+
430
452
  def self.safe_cwd(cwd)
431
453
  "--#{File.expand_path(cwd).sub(%r{\A[/\\]}, "").gsub(%r{[/\\:]}, "-")}--"
432
454
  end
433
455
 
434
456
  private
435
457
 
458
+ def latest_system_prompt_hash(records)
459
+ records.reverse_each do |record|
460
+ next unless record["type"] == "system_prompt"
461
+
462
+ return record["hash"].to_s unless record["hash"].to_s.empty?
463
+ end
464
+ nil
465
+ end
466
+
467
+ def system_prompt_hash(content)
468
+ "sha256:#{Digest::SHA256.hexdigest(content.to_s)}"
469
+ end
470
+
436
471
  def resolve_session_path(path)
437
472
  expanded = path.to_s.start_with?("~/") ? File.join(Dir.home, path.to_s[2..]) : path.to_s
438
473
  resolved = File.expand_path(expanded, @cwd)
@@ -557,7 +592,11 @@ module Kward
557
592
  next unless node
558
593
 
559
594
  parent = nodes[entry["parentId"].to_s]
560
- parent ? parent["children"] << node : roots << node
595
+ if parent && !parent.equal?(node)
596
+ parent["children"] << node unless parent["children"].include?(node)
597
+ else
598
+ roots << node unless roots.include?(node)
599
+ end
561
600
  end
562
601
  roots
563
602
  end
@@ -722,6 +761,7 @@ module Kward
722
761
 
723
762
  messages = restored_messages(records)
724
763
  name = session_name(records)
764
+ runtime = session_runtime(records, header)
725
765
  first_message = messages.find { |message| ["user", "compactionSummary"].include?(message_role(message)) }
726
766
  stats = File.stat(path)
727
767
 
@@ -734,6 +774,9 @@ module Kward
734
774
  name: name,
735
775
  first_message: first_message ? message_text(first_message) : "",
736
776
  message_count: messages.count { |message| ["user", "assistant", "tool", "toolResult", "compactionSummary"].include?(message_role(message)) },
777
+ provider: runtime["provider"],
778
+ model: runtime["model"],
779
+ reasoning_effort: runtime["reasoningEffort"],
737
780
  parent_id: header["parentId"],
738
781
  parent_path: header["parentPath"],
739
782
  depth: 0,
@@ -19,7 +19,12 @@ module Kward
19
19
  multiple_roots = visible_roots.length > 1
20
20
  result = []
21
21
 
22
- walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
22
+ stack = visible_roots.sort_by { |root| session_tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
23
+ [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
24
+ end.reverse
25
+
26
+ until stack.empty?
27
+ node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
23
28
  entry = node[:source]["entry"] || {}
24
29
  display_indent = multiple_roots ? [indent - 1, 0].max : indent
25
30
  prefix = session_tree_visual_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
@@ -40,25 +45,40 @@ module Kward
40
45
  connector_position = [display_indent - 1, 0].max
41
46
  child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
42
47
 
43
- children.each_with_index do |child, index|
44
- walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
48
+ children.each_with_index.reverse_each do |child, index|
49
+ stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
45
50
  end
46
51
  end
47
52
 
48
- visible_roots.sort_by { |root| session_tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
49
- walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
50
- end
51
-
52
53
  result
53
54
  end
54
55
 
55
56
  private
56
57
 
57
58
  def visible_session_tree_nodes(node)
58
- children = Array(node["children"]).flat_map { |child| visible_session_tree_nodes(child) }
59
- return children if hidden_session_tree_entry?(node["entry"] || {})
59
+ results = {}
60
+ stack = [[node, false, {}]]
61
+
62
+ until stack.empty?
63
+ current, visited, seen = stack.pop
64
+ node_key = current.object_id
65
+ next if seen[node_key]
66
+
67
+ if visited
68
+ children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
69
+ results[node_key] = if hidden_session_tree_entry?(current["entry"] || {})
70
+ children
71
+ else
72
+ [{ source: current, children: children }]
73
+ end
74
+ else
75
+ branch_seen = seen.merge(node_key => true)
76
+ stack << [current, true, seen]
77
+ Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
78
+ end
79
+ end
60
80
 
61
- [{ source: node, children: children }]
81
+ results[node.object_id] || []
62
82
  end
63
83
 
64
84
  def hidden_session_tree_entry?(entry)
@@ -132,15 +152,28 @@ module Kward
132
152
  end
133
153
 
134
154
  def session_tree_contains_active_path?(node, active_path)
135
- entry_id = (node[:source]["entry"] || {})["id"].to_s
136
- active_path.include?(entry_id) || node[:children].any? { |child| session_tree_contains_active_path?(child, active_path) }
155
+ stack = [node]
156
+ seen = {}
157
+ until stack.empty?
158
+ current = stack.pop
159
+ next if seen[current.object_id]
160
+
161
+ seen[current.object_id] = true
162
+ entry_id = (current[:source]["entry"] || {})["id"].to_s
163
+ return true if active_path.include?(entry_id)
164
+
165
+ stack.concat(current[:children])
166
+ end
167
+ false
137
168
  end
138
169
 
139
170
  def session_tree_active_path(roots, leaf_id)
140
171
  by_id = session_tree_entries_by_id(roots)
141
172
  ids = []
142
173
  entry = by_id[leaf_id.to_s]
143
- while entry
174
+ seen = {}
175
+ while entry && !seen[entry["id"].to_s]
176
+ seen[entry["id"].to_s] = true
144
177
  ids << entry["id"].to_s
145
178
  entry = by_id[entry["parentId"].to_s]
146
179
  end
@@ -150,8 +183,12 @@ module Kward
150
183
  def session_tree_entries_by_id(roots)
151
184
  roots.each_with_object({}) do |root, map|
152
185
  stack = [root]
186
+ seen = {}
153
187
  until stack.empty?
154
188
  node = stack.pop
189
+ next if seen[node.object_id]
190
+
191
+ seen[node.object_id] = true
155
192
  entry = node["entry"] || {}
156
193
  map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
157
194
  stack.concat(Array(node["children"]))
@@ -162,8 +199,12 @@ module Kward
162
199
  def session_tree_tool_calls(roots)
163
200
  roots.each_with_object({}) do |root, tool_calls|
164
201
  stack = [root]
202
+ seen = {}
165
203
  until stack.empty?
166
204
  node = stack.pop
205
+ next if seen[node.object_id]
206
+
207
+ seen[node.object_id] = true
167
208
  entry = node["entry"] || {}
168
209
  message = entry["message"]
169
210
  if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
@@ -11,9 +11,9 @@ require_relative "config_files"
11
11
  module Kward
12
12
  # Installs Kward's starter prompt/instruction files into the user config dir.
13
13
  class StarterPackInstaller
14
- VERSION = "v1.0.0"
14
+ VERSION = "v1.0.1"
15
15
  ARCHIVE_URL = "https://codeload.github.com/kaiwood/kward-starter-pack/tar.gz/refs/tags/#{VERSION}".freeze
16
- ALLOWED_FILES = ["AGENTS.md"].freeze
16
+ ALLOWED_FILES = ["PRINCIPLES.md"].freeze
17
17
  ALLOWED_PREFIXES = ["prompts/", "skills/"].freeze
18
18
  Result = Struct.new(:installed, :skipped, keyword_init: true)
19
19
 
@@ -0,0 +1,41 @@
1
+ require_relative "base"
2
+ require_relative "search/web_fetch"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
7
+ module Tools
8
+ # Fetches a specific URL and extracts readable page content.
9
+ class FetchContent < Base
10
+ # Builds the tool schema and stores the execution dependency.
11
+ def initialize(web_fetch:)
12
+ @web_fetch = web_fetch
13
+ super(
14
+ "fetch_content",
15
+ "Fetch a specific URL and extract readable bounded content.",
16
+ properties: {
17
+ url: {
18
+ type: "string",
19
+ description: "HTTP or HTTPS URL to fetch."
20
+ },
21
+ max_bytes: {
22
+ type: "integer",
23
+ description: "Maximum returned content bytes; default 16384, max 131072."
24
+ },
25
+ extract: {
26
+ type: "string",
27
+ enum: %w[auto text markdown],
28
+ description: "Extraction mode; default auto."
29
+ }
30
+ },
31
+ required: ["url"]
32
+ )
33
+ end
34
+
35
+ # Executes the tool and returns model-facing output text.
36
+ def call(args, _conversation, cancellation: nil)
37
+ @web_fetch.fetch_content(args)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ require_relative "base"
2
+ require_relative "search/web_fetch"
3
+
4
+ # Namespace for the Kward CLI agent runtime.
5
+ module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
7
+ module Tools
8
+ # Fetches bounded raw content from a specific URL.
9
+ class FetchRaw < Base
10
+ # Builds the tool schema and stores the execution dependency.
11
+ def initialize(web_fetch:)
12
+ @web_fetch = web_fetch
13
+ super(
14
+ "fetch_raw",
15
+ "Fetch bounded raw content from a specific URL.",
16
+ properties: {
17
+ url: {
18
+ type: "string",
19
+ description: "HTTP or HTTPS URL to fetch."
20
+ },
21
+ max_bytes: {
22
+ type: "integer",
23
+ description: "Maximum returned content bytes; default 16384, max 131072."
24
+ },
25
+ accept: {
26
+ type: "string",
27
+ description: "Optional HTTP Accept header."
28
+ }
29
+ },
30
+ required: ["url"]
31
+ )
32
+ end
33
+
34
+ # Executes the tool and returns model-facing output text.
35
+ def call(args, _conversation, cancellation: nil)
36
+ @web_fetch.fetch_raw(args)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -2,6 +2,8 @@ require_relative "../config_files"
2
2
  require_relative "ask_user_question"
3
3
  require_relative "code_search"
4
4
  require_relative "edit_file"
5
+ require_relative "fetch_content"
6
+ require_relative "fetch_raw"
5
7
  require_relative "list_directory"
6
8
  require_relative "read_file"
7
9
  require_relative "read_skill"
@@ -10,6 +12,7 @@ require_relative "web_search"
10
12
  require_relative "write_file"
11
13
  require_relative "search/code"
12
14
  require_relative "search/web"
15
+ require_relative "search/web_fetch"
13
16
  require_relative "tool_call"
14
17
  require_relative "../workspace"
15
18
 
@@ -45,14 +48,16 @@ module Kward
45
48
  # @param prompt [Object, nil] interactive prompt bridge; must implement
46
49
  # `ask_user_question` before that tool is advertised
47
50
  # @param web_search [WebSearch] live web search implementation
51
+ # @param web_fetch [WebFetch] specific URL fetch implementation
48
52
  # @param code_search [CodeSearch] public source/package search implementation
49
53
  # @param web_search_enabled [Boolean, nil] override for web search exposure
50
54
  # @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
51
55
  # @param ask_user_question_enabled [Boolean, nil] override question exposure
52
- def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil)
56
+ 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)
53
57
  @workspace = workspace
54
58
  @prompt = prompt
55
59
  @web_search = web_search
60
+ @web_fetch = web_fetch
56
61
  @code_search = code_search
57
62
  @skills = skills
58
63
  @web_search_enabled = web_search_enabled
@@ -103,7 +108,7 @@ module Kward
103
108
  tools = @tools.values_at(
104
109
  "list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search"
105
110
  )
106
- tools << @tools["web_search"] if web_search_available?
111
+ tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
107
112
  tools << @tools["read_skill"] if skills_available?
108
113
  tools << @tools["ask_user_question"] if ask_user_question_available?
109
114
  tools
@@ -112,6 +117,8 @@ module Kward
112
117
  def all_tools
113
118
  core_tools + [
114
119
  Tools::WebSearch.new(web_search: @web_search),
120
+ Tools::FetchContent.new(web_fetch: @web_fetch),
121
+ Tools::FetchRaw.new(web_fetch: @web_fetch),
115
122
  Tools::ReadSkill.new,
116
123
  Tools::AskUserQuestion.new(prompt: @prompt)
117
124
  ]
@@ -64,7 +64,7 @@ module Kward
64
64
  provider: provider
65
65
  }
66
66
 
67
- sections = ["# Web search"]
67
+ 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."]
68
68
  failures = []
69
69
  any_results = false
70
70
 
@@ -715,7 +715,7 @@ module Kward
715
715
 
716
716
  # HTTP adapter used by web-search providers and fallbacks.
717
717
  class NetHttpClient
718
- Response = Struct.new(:code, :body, keyword_init: true)
718
+ Response = Struct.new(:code, :body, :headers, keyword_init: true)
719
719
 
720
720
  def get(url, headers: {})
721
721
  request(url, Net::HTTP::Get, headers: headers)
@@ -742,7 +742,7 @@ module Kward
742
742
  headers.each { |key, value| http_request[key] = value }
743
743
  yield http_request if block_given?
744
744
  response = http.request(http_request)
745
- Response.new(code: response.code, body: response.body)
745
+ Response.new(code: response.code, body: response.body, headers: response.each_header.to_h)
746
746
  end
747
747
  end
748
748
  end