kward 0.67.1 → 0.69.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -0,0 +1,202 @@
1
+ require "nokogiri"
2
+ require "uri"
3
+ require_relative "web"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Fetches specific web resources for agent research workflows.
8
+ class WebFetch
9
+ DEFAULT_MAX_BYTES = 16 * 1024
10
+ MAX_MAX_BYTES = 128 * 1024
11
+ MAX_REDIRECTS = 5
12
+ HTTP_TIMEOUT_SECONDS = 10
13
+
14
+ # Creates a fetcher for web content and raw resources.
15
+ def initialize(http_client: WebSearch::NetHttpClient.new)
16
+ @http_client = http_client
17
+ end
18
+
19
+ # Fetches a URL and extracts readable text for human-facing pages.
20
+ def fetch_content(args)
21
+ url = args_value(args, "url").to_s.strip
22
+ return "Error: url is required" if url.empty?
23
+
24
+ max_bytes = bounded_max_bytes(args_value(args, "max_bytes") || args_value(args, "maxBytes"))
25
+ extract = normalize_extract(args_value(args, "extract") || "auto")
26
+ return "Error: extract must be one of: auto, text, markdown" unless extract
27
+
28
+ response = fetch_url(url, max_bytes: max_bytes)
29
+ return response if response.is_a?(String)
30
+
31
+ body = response[:body].to_s
32
+ content_type = header_value(response[:headers], "content-type")
33
+ text = extract_readable_text(body, content_type: content_type, mode: extract)
34
+ text = truncate_bytes(text, max_bytes)
35
+
36
+ [
37
+ "# Fetched content",
38
+ "- URL: #{response[:url]}",
39
+ "- Content type: #{content_type.empty? ? "unknown" : content_type}",
40
+ "- Bytes returned: #{text.bytesize}",
41
+ "",
42
+ text.empty? ? "(No readable text extracted.)" : text
43
+ ].join("\n")
44
+ rescue StandardError => e
45
+ "Error: fetch_content failed: #{e.message}"
46
+ end
47
+
48
+ # Fetches a URL and returns bounded raw response content.
49
+ def fetch_raw(args)
50
+ url = args_value(args, "url").to_s.strip
51
+ return "Error: url is required" if url.empty?
52
+
53
+ max_bytes = bounded_max_bytes(args_value(args, "max_bytes") || args_value(args, "maxBytes"))
54
+ accept = args_value(args, "accept").to_s.strip
55
+ response = fetch_url(url, max_bytes: max_bytes, accept: accept.empty? ? "*/*" : accept)
56
+ return response if response.is_a?(String)
57
+
58
+ body = truncate_bytes(response[:body].to_s, max_bytes)
59
+ content_type = header_value(response[:headers], "content-type")
60
+ [
61
+ "# Fetched raw content",
62
+ "- URL: #{response[:url]}",
63
+ "- Content type: #{content_type.empty? ? "unknown" : content_type}",
64
+ "- Bytes returned: #{body.bytesize}",
65
+ "",
66
+ body
67
+ ].join("\n")
68
+ rescue StandardError => e
69
+ "Error: fetch_raw failed: #{e.message}"
70
+ end
71
+
72
+ private
73
+
74
+ def fetch_url(url, max_bytes:, accept: "text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8")
75
+ current_url = normalize_url(url)
76
+ redirects = 0
77
+
78
+ loop do
79
+ response = @http_client.get(current_url, headers: browser_headers(accept))
80
+ code = response.code.to_i
81
+ headers = response_headers(response)
82
+
83
+ if redirect?(code)
84
+ return "Error: too many redirects" if redirects >= MAX_REDIRECTS
85
+
86
+ location = header_value(headers, "location")
87
+ return "Error: redirect missing Location header" if location.empty?
88
+
89
+ current_url = normalize_url(URI.join(current_url, location).to_s)
90
+ redirects += 1
91
+ next
92
+ end
93
+
94
+ return "Error: fetch failed with HTTP #{response.code}" unless code.between?(200, 299)
95
+
96
+ body = response.body.to_s
97
+ body = truncate_bytes(body, max_bytes)
98
+ return { url: current_url, headers: headers, body: body }
99
+ end
100
+ end
101
+
102
+ def normalize_url(value)
103
+ uri = URI.parse(value.to_s.strip)
104
+ raise "url must use http or https" unless %w[http https].include?(uri.scheme)
105
+ raise "url host is required" if uri.host.to_s.empty?
106
+
107
+ uri.to_s
108
+ rescue URI::InvalidURIError
109
+ raise "invalid url"
110
+ end
111
+
112
+ def response_headers(response)
113
+ return {} unless response.respond_to?(:headers) && response.headers.is_a?(Hash)
114
+
115
+ response.headers.transform_keys { |key| key.to_s.downcase }
116
+ end
117
+
118
+ def header_value(headers, key)
119
+ headers[key.to_s.downcase].to_s
120
+ end
121
+
122
+ def redirect?(code)
123
+ [301, 302, 303, 307, 308].include?(code)
124
+ end
125
+
126
+ def bounded_max_bytes(value)
127
+ number = value.to_i
128
+ number = DEFAULT_MAX_BYTES if number <= 0
129
+ [number, MAX_MAX_BYTES].min
130
+ end
131
+
132
+ def normalize_extract(value)
133
+ normalized = value.to_s.strip.downcase
134
+ %w[auto text markdown].include?(normalized) ? normalized : nil
135
+ end
136
+
137
+ def extract_readable_text(body, content_type:, mode:)
138
+ return clean_text(body) if mode == "text" || !html_content?(content_type, body)
139
+
140
+ document = Nokogiri::HTML(body)
141
+ document.css("script, style, noscript, svg, nav, footer, form").remove
142
+ title = document.at_css("title")&.text.to_s.strip
143
+ root = document.at_css("article") || document.at_css("main") || document.at_css("body") || document
144
+ parts = []
145
+ parts << "# #{clean_text(title)}" unless title.empty?
146
+ root.css("h1, h2, h3, h4, h5, h6, p, li, pre, code, blockquote").each do |node|
147
+ text = clean_text(node.text)
148
+ next if text.empty?
149
+
150
+ parts << format_html_node(node, text, mode: mode)
151
+ end
152
+ parts.uniq.join("\n\n")
153
+ end
154
+
155
+ def html_content?(content_type, body)
156
+ content_type.to_s.include?("html") || body.to_s.lstrip.start_with?("<!DOCTYPE html", "<html", "<HTML")
157
+ end
158
+
159
+ def format_html_node(node, text, mode:)
160
+ return text if mode == "text"
161
+
162
+ case node.name
163
+ when /^h([1-6])$/
164
+ "#{"#" * Regexp.last_match(1).to_i} #{text}"
165
+ when "li"
166
+ "- #{text}"
167
+ when "pre", "code"
168
+ "```\n#{text}\n```"
169
+ when "blockquote"
170
+ "> #{text}"
171
+ else
172
+ text
173
+ end
174
+ end
175
+
176
+ def clean_text(text)
177
+ text.to_s.gsub(/\s+/, " ").strip
178
+ end
179
+
180
+ def truncate_bytes(text, max_bytes)
181
+ return text if text.bytesize <= max_bytes
182
+
183
+ "#{text.byteslice(0, max_bytes).to_s.scrub}\n... truncated to #{max_bytes} bytes"
184
+ end
185
+
186
+ def browser_headers(accept)
187
+ {
188
+ "Accept" => accept,
189
+ "Accept-Language" => "en-US,en;q=0.9",
190
+ "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"
191
+ }
192
+ end
193
+
194
+ def args_value(args, key)
195
+ return nil unless args.is_a?(Hash)
196
+ return args[key] if args.key?(key)
197
+ return args[key.to_sym] if args.key?(key.to_sym)
198
+
199
+ nil
200
+ end
201
+ end
202
+ end
@@ -1,6 +1,14 @@
1
1
  require "json"
2
+ require_relative "../message_access"
2
3
 
4
+ # Namespace for the Kward CLI agent runtime.
3
5
  module Kward
6
+ # Reads and normalizes model tool-call hashes.
7
+ #
8
+ # Tool calls arrive from several providers and may be restored from session
9
+ # files. This module keeps provider/string/symbol compatibility in one place
10
+ # and exposes small helpers used by the agent loop, tool registry, transcript
11
+ # formatters, and RPC event normalizers.
4
12
  module ToolCall
5
13
  TOOL_NAME_MAP = {
6
14
  "read_file" => "read",
@@ -8,26 +16,37 @@ module Kward
8
16
  "write_file" => "write",
9
17
  "run_shell_command" => "bash",
10
18
  "list_directory" => "list_directory",
19
+ "code_search" => "code_search",
11
20
  "web_search" => "web_search",
21
+ "fetch_content" => "fetch_content",
22
+ "fetch_raw" => "fetch_raw",
12
23
  "read_skill" => "read_skill",
13
24
  "ask_user_question" => "ask_user_question"
14
25
  }.freeze
15
26
 
16
27
  module_function
17
28
 
29
+ # @return [String, nil] provider tool-call id
18
30
  def id(tool_call)
19
31
  value(tool_call, :id)
20
32
  end
21
33
 
34
+ # @return [String, nil] requested tool/function name
22
35
  def name(tool_call)
23
36
  value(function(tool_call), :name)
24
37
  end
25
38
 
39
+ # Returns the short name used in compact UI labels.
40
+ #
41
+ # @return [String] display label such as `read`, `edit`, or `bash`
26
42
  def display_name(tool_call)
27
43
  raw_name = name(tool_call)
28
44
  normalized_name(raw_name) || raw_name || "unknown_tool"
29
45
  end
30
46
 
47
+ # Parses the requested tool arguments.
48
+ #
49
+ # @return [Hash] decoded argument object, or an empty hash for invalid JSON
31
50
  def arguments(tool_call)
32
51
  parse_arguments(raw_arguments(tool_call))
33
52
  end
@@ -44,6 +63,10 @@ module Kward
44
63
  TOOL_NAME_MAP[name.to_s]
45
64
  end
46
65
 
66
+ # Converts provider argument payloads into hashes.
67
+ #
68
+ # Providers normally send JSON strings, while tests and compatibility callers
69
+ # may pass hashes directly.
47
70
  def parse_arguments(arguments)
48
71
  return {} if arguments.nil? || (arguments.respond_to?(:empty?) && arguments.empty?)
49
72
  return arguments if arguments.is_a?(Hash)
@@ -53,6 +76,9 @@ module Kward
53
76
  {}
54
77
  end
55
78
 
79
+ # Recursively converts snake_case hash keys to camelCase symbols.
80
+ #
81
+ # @return [Hash] camelized copy of `args`
56
82
  def camelize_args(args)
57
83
  return {} unless args.is_a?(Hash)
58
84
 
@@ -62,11 +88,7 @@ module Kward
62
88
  end
63
89
 
64
90
  def value(object, key)
65
- return nil unless object.respond_to?(:key?)
66
- return object[key] if object.key?(key)
67
- return object[key.to_s] if object.key?(key.to_s)
68
-
69
- nil
91
+ MessageAccess.value(object, key)
70
92
  end
71
93
 
72
94
  def camelize_value(item)
@@ -1,8 +1,13 @@
1
1
  require_relative "base"
2
+ require_relative "search/web"
2
3
 
4
+ # Namespace for the Kward CLI agent runtime.
3
5
  module Kward
6
+ # Model-callable tool wrappers and their argument schemas.
4
7
  module Tools
8
+ # Live web-search implementation with provider fallbacks.
5
9
  class WebSearch < Base
10
+ # Builds the tool schema and stores the execution dependency.
6
11
  def initialize(web_search:)
7
12
  @web_search = web_search
8
13
  super(
@@ -22,7 +27,7 @@ module Kward
22
27
  },
23
28
  provider: {
24
29
  type: "string",
25
- enum: %w[auto exa perplexity gemini legacy duckduckgo],
30
+ enum: Kward::WebSearch::PROVIDERS,
26
31
  description: "Provider override; default auto."
27
32
  },
28
33
  recency_filter: {
@@ -40,6 +45,7 @@ module Kward
40
45
  )
41
46
  end
42
47
 
48
+ # Executes the tool and returns model-facing output text.
43
49
  def call(args, _conversation, cancellation: nil)
44
50
  @web_search.search(args)
45
51
  end
@@ -1,8 +1,12 @@
1
1
  require_relative "base"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
4
6
  module Tools
7
+ # Tool wrapper for guarded full-file writes.
5
8
  class WriteFile < Base
9
+ # Builds the tool schema and stores the execution dependency.
6
10
  def initialize(workspace:)
7
11
  @workspace = workspace
8
12
  super(
@@ -16,6 +20,7 @@ module Kward
16
20
  )
17
21
  end
18
22
 
23
+ # Executes the tool and returns model-facing output text.
19
24
  def call(args, conversation, cancellation: nil)
20
25
  path = argument(args, :path, "")
21
26
  content = argument(args, :content, "")
@@ -1,7 +1,9 @@
1
1
  require "cgi"
2
2
  require_relative "markdown_transcript"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Serializes conversations for transcript export formats.
5
7
  class TranscriptExport
6
8
  SUPPORTED_FORMATS = ["markdown", "html"].freeze
7
9
 
data/lib/kward/version.rb CHANGED
@@ -1,4 +1,5 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
2
3
  # Current gem version.
3
- VERSION = "0.67.1"
4
+ VERSION = "0.69.0"
4
5
  end
@@ -3,7 +3,19 @@ require "pathname"
3
3
  require "timeout"
4
4
  require_relative "session_diff"
5
5
 
6
+ # Namespace for the Kward CLI agent runtime.
6
7
  module Kward
8
+ # Filesystem and shell-command boundary for workspace tools.
9
+ #
10
+ # `Workspace` is deliberately low-level: it validates paths, enforces output
11
+ # limits, applies exact edits, writes files, and runs shell commands from one
12
+ # root directory. It should not know about model prompts, sessions, telemetry,
13
+ # or UI confirmation. Tool wrappers and frontends provide those policies.
14
+ #
15
+ # Guardrails are enabled by default and require all file paths to resolve under
16
+ # `root`. RPC may report when guardrails are disabled, but callers should avoid
17
+ # bypassing this class for local filesystem mutation so read-before-write and
18
+ # path safety remain consistent.
7
19
  class Workspace
8
20
  MAX_FILE_BYTES = 256 * 1024
9
21
  MAX_READ_OUTPUT_BYTES = 50 * 1024
@@ -12,6 +24,7 @@ module Kward
12
24
  MAX_EDIT_DIFF_BYTES = 8 * 1024
13
25
  DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
14
26
 
27
+ # Creates an object for workspace filesystem and shell operations.
15
28
  def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true)
16
29
  @root = Pathname.new(root).realpath
17
30
  @guardrails = guardrails
@@ -21,8 +34,10 @@ module Kward
21
34
  @max_command_output_bytes = max_command_output_bytes
22
35
  end
23
36
 
37
+ # @return [Pathname] canonical workspace root used as the base for file and shell tools
24
38
  attr_reader :root
25
39
 
40
+ # Lists immediate directory children after resolving `path` through workspace guardrails.
26
41
  def list_directory(path)
27
42
  resolved = workspace_path(path)
28
43
  return "Error: not a directory: #{path}" unless File.directory?(resolved)
@@ -34,6 +49,11 @@ module Kward
34
49
  "Error: #{e.message}"
35
50
  end
36
51
 
52
+ # Reads a bounded text slice from a workspace file.
53
+ #
54
+ # The returned string is user/model-facing and includes continuation notices
55
+ # when output is truncated. Errors are returned as `"Error: ..."` strings so
56
+ # tool calls can be persisted in the conversation without raising.
37
57
  def read_file(path, offset: nil, limit: nil)
38
58
  resolved = workspace_path(path)
39
59
  return "Error: not a file: #{path}" unless File.file?(resolved)
@@ -41,11 +61,20 @@ module Kward
41
61
  size = File.size(resolved)
42
62
  return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
43
63
 
44
- read_file_slice(File.read(resolved), offset: offset, limit: limit)
64
+ content = File.read(resolved)
65
+ return "Error: not a text file: #{path}" if binary_content?(content)
66
+
67
+ read_file_slice(content, offset: offset, limit: limit)
45
68
  rescue SecurityError, Errno::ENOENT => e
46
69
  "Error: #{e.message}"
47
70
  end
48
71
 
72
+ # Writes complete file content after enforcing read-before-write for
73
+ # existing files.
74
+ #
75
+ # `read_paths` must contain resolved paths previously observed by
76
+ # `ReadFile`; this keeps tool-driven edits explicit and prevents overwriting
77
+ # unseen user files.
49
78
  def write_file(path, content, read_paths:)
50
79
  resolved = workspace_write_path(path)
51
80
 
@@ -53,10 +82,6 @@ module Kward
53
82
  return "Error: existing file must be read before writing: #{path}"
54
83
  end
55
84
 
56
- if block_given? && !yield(relative_path(resolved), content.bytesize)
57
- return "Declined: write_file was not approved for #{path}"
58
- end
59
-
60
85
  old_content = File.exist?(resolved) ? File.read(resolved) : nil
61
86
  File.write(resolved, content)
62
87
  output = "Wrote #{content.bytesize} bytes to #{path}"
@@ -66,6 +91,11 @@ module Kward
66
91
  "Error: #{e.message}"
67
92
  end
68
93
 
94
+ # Applies exact non-overlapping replacements to a previously read file.
95
+ #
96
+ # Each `old_text` must match exactly once. This favors predictable model edits
97
+ # over fuzzy patching and returns readable error strings when more context is
98
+ # needed.
69
99
  def edit_file(path, edits, read_paths:)
70
100
  resolved = workspace_path(path)
71
101
  return "Error: not a file: #{path}" unless File.file?(resolved)
@@ -84,6 +114,11 @@ module Kward
84
114
  "Error: #{e.message}"
85
115
  end
86
116
 
117
+ # Runs a shell command from the workspace root with timeout, cancellation,
118
+ # and bounded combined output.
119
+ #
120
+ # This method intentionally does not ask for confirmation; CLI/RPC policy
121
+ # must decide whether a command is allowed before reaching this boundary.
87
122
  def run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil)
88
123
  command = command.to_s.strip
89
124
  return "Error: command is required" if command.empty?
@@ -114,6 +149,7 @@ module Kward
114
149
  "Error: #{e.message}"
115
150
  end
116
151
 
152
+ # Resolves a path with the same guardrails used by file tools.
117
153
  def resolved_path(path)
118
154
  workspace_path(path)
119
155
  end
@@ -189,6 +225,10 @@ module Kward
189
225
  output
190
226
  end
191
227
 
228
+ def binary_content?(content)
229
+ content.include?("\x00")
230
+ end
231
+
192
232
  def read_start_index(offset)
193
233
  return 0 if offset.nil?
194
234