kward 0.66.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 (93) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +9 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Gemfile +8 -0
  5. data/Gemfile.lock +90 -0
  6. data/LICENSE +21 -0
  7. data/README.md +101 -0
  8. data/Rakefile +20 -0
  9. data/doc/authentication.md +105 -0
  10. data/doc/code-search.md +56 -0
  11. data/doc/configuration.md +310 -0
  12. data/doc/extensibility.md +186 -0
  13. data/doc/getting-started.md +127 -0
  14. data/doc/memory.md +192 -0
  15. data/doc/plugins.md +223 -0
  16. data/doc/releasing.md +36 -0
  17. data/doc/rpc.md +635 -0
  18. data/doc/usage.md +179 -0
  19. data/doc/web-search.md +28 -0
  20. data/exe/kward +5 -0
  21. data/kward.gemspec +33 -0
  22. data/lib/kward/agent.rb +234 -0
  23. data/lib/kward/ansi.rb +276 -0
  24. data/lib/kward/auth/file.rb +11 -0
  25. data/lib/kward/auth/github_oauth.rb +222 -0
  26. data/lib/kward/auth/openai_oauth.rb +323 -0
  27. data/lib/kward/auth/openrouter_api_key.rb +40 -0
  28. data/lib/kward/cancellation.rb +54 -0
  29. data/lib/kward/cli.rb +2122 -0
  30. data/lib/kward/clipboard.rb +84 -0
  31. data/lib/kward/compactor.rb +998 -0
  32. data/lib/kward/config_files.rb +564 -0
  33. data/lib/kward/conversation.rb +148 -0
  34. data/lib/kward/events.rb +13 -0
  35. data/lib/kward/export_path.rb +28 -0
  36. data/lib/kward/image_attachments.rb +331 -0
  37. data/lib/kward/markdown_transcript.rb +72 -0
  38. data/lib/kward/memory/manager.rb +652 -0
  39. data/lib/kward/message_access.rb +42 -0
  40. data/lib/kward/model/chat_invocation.rb +23 -0
  41. data/lib/kward/model/client.rb +875 -0
  42. data/lib/kward/model/context_overflow.rb +55 -0
  43. data/lib/kward/model/context_usage.rb +104 -0
  44. data/lib/kward/model/model_info.rb +188 -0
  45. data/lib/kward/model/retry_message.rb +11 -0
  46. data/lib/kward/model/stream_parser.rb +205 -0
  47. data/lib/kward/pan/index.html.erb +143 -0
  48. data/lib/kward/pan/server.rb +397 -0
  49. data/lib/kward/plugin_registry.rb +327 -0
  50. data/lib/kward/private_file.rb +18 -0
  51. data/lib/kward/prompt_interface.rb +2437 -0
  52. data/lib/kward/prompts/commands.rb +50 -0
  53. data/lib/kward/prompts/templates.rb +60 -0
  54. data/lib/kward/prompts.rb +58 -0
  55. data/lib/kward/resources/avatar_kward_logo.rb +48 -0
  56. data/lib/kward/resources/pixel_logo.rb +230 -0
  57. data/lib/kward/rpc/auth_manager.rb +265 -0
  58. data/lib/kward/rpc/config_manager.rb +58 -0
  59. data/lib/kward/rpc/prompt_bridge.rb +104 -0
  60. data/lib/kward/rpc/redactor.rb +47 -0
  61. data/lib/kward/rpc/server.rb +639 -0
  62. data/lib/kward/rpc/session_manager.rb +1122 -0
  63. data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
  64. data/lib/kward/rpc/tool_metadata.rb +80 -0
  65. data/lib/kward/rpc/transcript_normalizer.rb +307 -0
  66. data/lib/kward/rpc/transport.rb +58 -0
  67. data/lib/kward/session_diff.rb +125 -0
  68. data/lib/kward/session_store.rb +493 -0
  69. data/lib/kward/skills/registry.rb +76 -0
  70. data/lib/kward/starter_pack_installer.rb +110 -0
  71. data/lib/kward/steering.rb +56 -0
  72. data/lib/kward/telemetry/logger.rb +195 -0
  73. data/lib/kward/telemetry/stats.rb +466 -0
  74. data/lib/kward/tools/ask_user_question.rb +107 -0
  75. data/lib/kward/tools/base.rb +45 -0
  76. data/lib/kward/tools/code_search.rb +65 -0
  77. data/lib/kward/tools/edit_file.rb +41 -0
  78. data/lib/kward/tools/list_directory.rb +21 -0
  79. data/lib/kward/tools/read_file.rb +30 -0
  80. data/lib/kward/tools/read_skill.rb +27 -0
  81. data/lib/kward/tools/registry.rb +117 -0
  82. data/lib/kward/tools/run_shell_command.rb +28 -0
  83. data/lib/kward/tools/search/code.rb +445 -0
  84. data/lib/kward/tools/search/web.rb +747 -0
  85. data/lib/kward/tools/tool_call.rb +87 -0
  86. data/lib/kward/tools/web_search.rb +48 -0
  87. data/lib/kward/tools/write_file.rb +29 -0
  88. data/lib/kward/transcript_export.rb +40 -0
  89. data/lib/kward/version.rb +4 -0
  90. data/lib/kward/workspace.rb +377 -0
  91. data/lib/kward.rb +6 -0
  92. data/lib/main.rb +3 -0
  93. metadata +232 -0
@@ -0,0 +1,445 @@
1
+ require "cgi"
2
+ require "fileutils"
3
+ require "json"
4
+ require "net/http"
5
+ require "open3"
6
+ require "pathname"
7
+ require "uri"
8
+ require_relative "../../config_files"
9
+
10
+ module Kward
11
+ class CodeSearch
12
+ DEFAULT_MAX_RESULTS = 10
13
+ MAX_MAX_RESULTS = 50
14
+ DEFAULT_CONTEXT_LINES = 2
15
+ DEFAULT_LINE_COUNT = 200
16
+ MAX_LINE_COUNT = 400
17
+ MAX_FILE_BYTES = 512 * 1024
18
+ MAX_SCANNED_FILES = 5_000
19
+ MAX_OUTPUT_BYTES = 16 * 1024
20
+ HTTP_TIMEOUT_SECONDS = 10
21
+ ECOSYSTEMS = %w[rubygems npm pypi crates go].freeze
22
+ ACTIONS = %w[package_search github_search repo_clone repo_search repo_read list_cache refresh_cache clear_cache].freeze
23
+
24
+ def initialize(cache_root: nil, http_client: NetHttpClient.new, git_runner: GitRunner.new, max_output_bytes: MAX_OUTPUT_BYTES)
25
+ @cache_root = File.expand_path(cache_root || ConfigFiles.code_search_cache_dir)
26
+ @http_client = http_client
27
+ @git_runner = git_runner
28
+ @max_output_bytes = max_output_bytes
29
+ end
30
+
31
+ def call(args)
32
+ action = value(args, "action").to_s
33
+ return "Error: action must be one of: #{ACTIONS.join(", ")}" unless ACTIONS.include?(action)
34
+
35
+ case action
36
+ when "package_search" then package_search(args)
37
+ when "github_search" then github_search_action(args)
38
+ when "repo_clone" then repo_clone(args)
39
+ when "repo_search" then repo_search(args)
40
+ when "repo_read" then repo_read(args)
41
+ when "list_cache" then list_cache
42
+ when "refresh_cache" then refresh_cache(args)
43
+ when "clear_cache" then clear_cache(args)
44
+ end
45
+ rescue StandardError => e
46
+ "Error: code_search failed: #{redact(e.message)}"
47
+ end
48
+
49
+ class NetHttpClient
50
+ def get_json(url, headers: {})
51
+ JSON.parse(get_text(url, headers: headers.merge("Accept" => "application/json")))
52
+ end
53
+
54
+ def get_text(url, headers: {})
55
+ uri = URI(url)
56
+ request = Net::HTTP::Get.new(uri)
57
+ request["User-Agent"] = "Kward code_search"
58
+ headers.each { |key, val| request[key] = val }
59
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: HTTP_TIMEOUT_SECONDS, read_timeout: HTTP_TIMEOUT_SECONDS) do |http|
60
+ http.request(request)
61
+ end
62
+ raise "HTTP #{response.code} from #{uri.host}" unless response.is_a?(Net::HTTPSuccess)
63
+
64
+ response.body
65
+ end
66
+ end
67
+
68
+ class GitRunner
69
+ def run(*args, chdir: nil)
70
+ command = ["git", *args]
71
+ stdout, stderr, status = chdir ? Open3.capture3(*command, chdir: chdir) : Open3.capture3(*command)
72
+ raise "git #{args.join(" ")} failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}" unless status.success?
73
+
74
+ stdout
75
+ rescue Errno::ENOENT
76
+ raise "git executable not found"
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def package_search(args)
83
+ ecosystem = normalize_ecosystem(value(args, "ecosystem"))
84
+ return "Error: ecosystem must be one of: #{ECOSYSTEMS.join(", ")}" unless ecosystem
85
+
86
+ package = value(args, "package") || value(args, "query")
87
+ return "Error: package is required" if package.to_s.strip.empty?
88
+
89
+ data = fetch_package(ecosystem, package.to_s.strip)
90
+ repo = normalize_github_url(data[:source_url])
91
+ fallback = nil
92
+ fallback = github_search(data[:name], ecosystem: ecosystem, max_results: bounded_max_results(value(args, "max_results"))).first unless repo
93
+
94
+ lines = ["# Package search", "- Ecosystem: #{ecosystem}", "- Package: #{data[:name]}"]
95
+ lines << "- Version: #{data[:version]}" if data[:version]
96
+ lines << "- Description: #{data[:description]}" if data[:description]
97
+ if repo
98
+ lines << "- Source: #{repo[:html_url]}"
99
+ elsif fallback
100
+ lines << "- Source fallback: #{fallback[:html_url]}"
101
+ lines << "- Note: registry metadata did not include a public GitHub source URL; returned a GitHub search result."
102
+ else
103
+ lines << "- Source: not found"
104
+ end
105
+ truncate(lines.join("\n"))
106
+ end
107
+
108
+ def github_search_action(args)
109
+ query = value(args, "query") || value(args, "package")
110
+ return "Error: query is required" if query.to_s.strip.empty?
111
+
112
+ results = github_search(query.to_s.strip, ecosystem: normalize_ecosystem(value(args, "ecosystem")), max_results: bounded_max_results(value(args, "max_results")))
113
+ return "Error: no GitHub repositories found" if results.empty?
114
+
115
+ lines = ["# GitHub repository search"]
116
+ results.each_with_index do |repo, index|
117
+ lines << "#{index + 1}. #{repo[:full_name]} - #{repo[:html_url]}"
118
+ lines << " #{repo[:description]}" unless repo[:description].to_s.empty?
119
+ end
120
+ truncate(lines.join("\n"))
121
+ end
122
+
123
+ def repo_clone(args)
124
+ repo = require_repo(args)
125
+ return repo if repo.is_a?(String)
126
+
127
+ path, created = ensure_repo(repo, refresh: false)
128
+ "# Repository cached\n- Repository: #{repo[:full_name]}\n- Cache path: #{path}\n- Status: #{created ? "cloned" : "already cached"}"
129
+ end
130
+
131
+ def refresh_cache(args)
132
+ repo = require_repo(args)
133
+ return repo if repo.is_a?(String)
134
+
135
+ path, = ensure_repo(repo, refresh: true)
136
+ "# Repository refreshed\n- Repository: #{repo[:full_name]}\n- Cache path: #{path}"
137
+ end
138
+
139
+ def repo_search(args)
140
+ repo = require_repo(args)
141
+ return repo if repo.is_a?(String)
142
+ query = value(args, "query").to_s
143
+ return "Error: query is required" if query.strip.empty?
144
+
145
+ path, = ensure_repo(repo, refresh: false)
146
+ max_results = bounded_max_results(value(args, "max_results"))
147
+ context = bounded_integer(value(args, "context_lines"), DEFAULT_CONTEXT_LINES, 0, 5)
148
+ matches = search_files(path, query, max_results: max_results, context_lines: context)
149
+ return "Error: no matches found in #{repo[:full_name]}" if matches.empty?
150
+
151
+ lines = ["# Code search", "- Repository: #{repo[:full_name]}", "- Query: #{query}", ""]
152
+ matches.each do |match|
153
+ lines << "## #{match[:path]}:#{match[:line]}"
154
+ lines << "```"
155
+ lines.concat(match[:snippet])
156
+ lines << "```"
157
+ end
158
+ truncate(lines.join("\n"))
159
+ end
160
+
161
+ def repo_read(args)
162
+ repo = require_repo(args)
163
+ return repo if repo.is_a?(String)
164
+ file = value(args, "path") || value(args, "file")
165
+ return "Error: path is required" if file.to_s.empty?
166
+
167
+ root, = ensure_repo(repo, refresh: false)
168
+ target = safe_existing_path(root, file.to_s)
169
+ return "Error: path is not a file: #{file}" unless File.file?(target)
170
+ return "Error: file is too large: #{file}" if File.size(target) > MAX_FILE_BYTES
171
+
172
+ start_line = bounded_integer(value(args, "start_line"), 1, 1, 1_000_000)
173
+ line_count = bounded_integer(value(args, "line_count"), DEFAULT_LINE_COUNT, 1, MAX_LINE_COUNT)
174
+ lines = File.readlines(target, chomp: true)
175
+ selected = lines.drop(start_line - 1).first(line_count)
176
+ numbered = selected.each_with_index.map { |line, index| "#{start_line + index}: #{line}" }
177
+ truncate((["# Code read", "- Repository: #{repo[:full_name]}", "- Path: #{file}", ""] + numbered).join("\n"))
178
+ rescue ArgumentError => e
179
+ "Error: #{e.message}"
180
+ end
181
+
182
+ def list_cache
183
+ FileUtils.mkdir_p(@cache_root, mode: 0o700)
184
+ entries = Dir.children(@cache_root).sort.select { |entry| File.directory?(File.join(@cache_root, entry, ".git")) }
185
+ return "# Code search cache\nNo cached repositories." if entries.empty?
186
+
187
+ lines = ["# Code search cache"]
188
+ entries.each do |entry|
189
+ lines << "- #{entry}"
190
+ end
191
+ lines.join("\n")
192
+ end
193
+
194
+ def clear_cache(args)
195
+ repo = require_repo(args)
196
+ return repo if repo.is_a?(String)
197
+
198
+ path = cache_path(repo)
199
+ return "# Cache clear\n- Repository: #{repo[:full_name]}\n- Status: not cached" unless inside_cache?(path) && Dir.exist?(path)
200
+
201
+ FileUtils.rm_rf(path)
202
+ "# Cache clear\n- Repository: #{repo[:full_name]}\n- Status: removed"
203
+ end
204
+
205
+ def fetch_package(ecosystem, package)
206
+ case ecosystem
207
+ when "rubygems"
208
+ json = @http_client.get_json("https://rubygems.org/api/v1/gems/#{escape_path(package)}.json")
209
+ { name: json["name"] || package, version: json["version"], description: json["info"], source_url: json["source_code_uri"] || json["homepage_uri"] || json["bug_tracker_uri"] }
210
+ when "npm"
211
+ json = @http_client.get_json("https://registry.npmjs.org/#{escape_path(package)}")
212
+ latest = json.dig("dist-tags", "latest")
213
+ version = latest && json.dig("versions", latest)
214
+ repo = version&.dig("repository") || json["repository"]
215
+ { name: json["name"] || package, version: latest, description: json["description"], source_url: repository_url(repo) || json["homepage"] }
216
+ when "pypi"
217
+ json = @http_client.get_json("https://pypi.org/pypi/#{escape_path(package)}/json")
218
+ info = json["info"] || {}
219
+ urls = info["project_urls"].is_a?(Hash) ? info["project_urls"].values : []
220
+ { name: info["name"] || package, version: info["version"], description: info["summary"], source_url: ([info["project_url"], info["home_page"], *urls].find { |url| normalize_github_url(url) }) }
221
+ when "crates"
222
+ json = @http_client.get_json("https://crates.io/api/v1/crates/#{escape_path(package)}")
223
+ crate = json["crate"] || {}
224
+ { name: crate["id"] || package, version: crate["max_version"], description: crate["description"], source_url: crate["repository"] || crate["homepage"] }
225
+ when "go"
226
+ html = @http_client.get_text("https://pkg.go.dev/#{escape_path(package)}?tab=doc")
227
+ { name: package, source_url: extract_github_url(html.to_s) }
228
+ end
229
+ end
230
+
231
+ def github_search(query, ecosystem: nil, max_results: DEFAULT_MAX_RESULTS)
232
+ search = [query, ecosystem, "source repository"].compact.join(" ")
233
+ url = "https://api.github.com/search/repositories?q=#{CGI.escape(search)}&per_page=#{max_results}"
234
+ json = @http_client.get_json(url, headers: github_headers)
235
+ Array(json["items"]).filter_map do |item|
236
+ full_name = item["full_name"].to_s
237
+ next if full_name.empty?
238
+
239
+ { full_name: full_name, html_url: item["html_url"], clone_url: item["clone_url"] || "https://github.com/#{full_name}.git", description: item["description"] }
240
+ end
241
+ end
242
+
243
+ def ensure_repo(repo, refresh:)
244
+ path = cache_path(repo)
245
+ FileUtils.mkdir_p(@cache_root, mode: 0o700)
246
+ if Dir.exist?(File.join(path, ".git"))
247
+ if refresh
248
+ @git_runner.run("fetch", "--depth", "1", "origin", chdir: path)
249
+ @git_runner.run("reset", "--hard", "FETCH_HEAD", chdir: path)
250
+ end
251
+ return [path, false]
252
+ end
253
+
254
+ FileUtils.rm_rf(path) if Dir.exist?(path)
255
+ @git_runner.run("clone", "--depth", "1", repo[:clone_url], path)
256
+ [path, true]
257
+ end
258
+
259
+ def search_files(root, query, max_results:, context_lines:)
260
+ matches = []
261
+ base = File.realpath(root)
262
+ scanned = 0
263
+ Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).sort.each do |path|
264
+ next if matches.length >= max_results || scanned >= MAX_SCANNED_FILES
265
+ next if skip_search_path?(base, path)
266
+
267
+ scanned += 1
268
+ match = first_file_match(path, query, context_lines)
269
+ next unless match
270
+
271
+ matches << {
272
+ path: relative_path(root, path),
273
+ line: match[:line],
274
+ snippet: match[:snippet]
275
+ }
276
+ end
277
+ matches
278
+ end
279
+
280
+ def skip_search_path?(base, path)
281
+ return true unless File.file?(path)
282
+ return true if File.symlink?(path)
283
+ return true if path.include?("#{File::SEPARATOR}.git#{File::SEPARATOR}")
284
+ return true unless inside_root?(base, File.realpath(path))
285
+ return true if File.size(path) > MAX_FILE_BYTES
286
+
287
+ false
288
+ end
289
+
290
+ def first_file_match(path, query, context_lines)
291
+ previous = []
292
+ line_number = 0
293
+ File.open(path) do |file|
294
+ while (line = file.gets&.chomp)
295
+ line_number += 1
296
+ if line.include?(query)
297
+ match_line = line_number
298
+ following = []
299
+ context_lines.times do
300
+ next_line = file.gets&.chomp
301
+ break unless next_line
302
+
303
+ line_number += 1
304
+ following << "#{line_number}: #{next_line}"
305
+ end
306
+ return { line: match_line, snippet: previous + ["#{match_line}: #{line}"] + following }
307
+ end
308
+
309
+ previous << "#{line_number}: #{line}"
310
+ previous.shift while previous.length > context_lines
311
+ end
312
+ end
313
+ nil
314
+ rescue ArgumentError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
315
+ nil
316
+ end
317
+
318
+ def require_repo(args)
319
+ input = value(args, "repo") || value(args, "repository") || value(args, "url")
320
+ repo = normalize_github_url(input)
321
+ return repo if repo
322
+
323
+ "Error: repo must be a GitHub repository URL or owner/name"
324
+ end
325
+
326
+ def normalize_github_url(input)
327
+ text = input.to_s.strip
328
+ return nil if text.empty?
329
+ text = "https://github.com/#{text}" if text.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z})
330
+ text = text.sub(%r{\Agit\+}, "")
331
+ text = text.sub(%r{\Agit@github\.com:}, "https://github.com/")
332
+ uri = URI(text)
333
+ return nil unless uri.host.to_s.downcase == "github.com"
334
+
335
+ parts = uri.path.sub(%r{\A/}, "").sub(%r{\.git\z}, "").split("/")
336
+ return nil unless parts.length >= 2
337
+
338
+ owner = parts[0]
339
+ name = parts[1]
340
+ return nil unless owner.match?(%r{\A[A-Za-z0-9_.-]+\z}) && name.match?(%r{\A[A-Za-z0-9_.-]+\z})
341
+
342
+ full_name = "#{owner}/#{name}"
343
+ { full_name: full_name, html_url: "https://github.com/#{full_name}", clone_url: "https://github.com/#{full_name}.git" }
344
+ rescue URI::InvalidURIError
345
+ nil
346
+ end
347
+
348
+ def cache_path(repo)
349
+ key = repo[:full_name].downcase.gsub(%r{[^a-z0-9_.-]+}, "__")
350
+ File.join(@cache_root, key)
351
+ end
352
+
353
+ def safe_join(root, path)
354
+ raise ArgumentError, "path must be relative" if Pathname.new(path).absolute?
355
+
356
+ base = File.realpath(root)
357
+ target = File.expand_path(path, base)
358
+ raise ArgumentError, "path outside repository: #{path}" unless inside_root?(base, target)
359
+
360
+ target
361
+ end
362
+
363
+ def safe_existing_path(root, path)
364
+ target = safe_join(root, path)
365
+ return target unless File.exist?(target)
366
+
367
+ real_target = File.realpath(target)
368
+ raise ArgumentError, "path outside repository: #{path}" unless inside_root?(File.realpath(root), real_target)
369
+
370
+ real_target
371
+ end
372
+
373
+ def inside_root?(root, path)
374
+ path == root || path.start_with?(root + File::SEPARATOR)
375
+ end
376
+
377
+ def inside_cache?(path)
378
+ expanded = File.expand_path(path)
379
+ expanded == @cache_root || expanded.start_with?(@cache_root + File::SEPARATOR)
380
+ end
381
+
382
+ def normalize_ecosystem(value)
383
+ text = value.to_s.downcase
384
+ text = "rubygems" if text == "ruby" || text == "gem"
385
+ text = "pypi" if text == "python"
386
+ text = "crates" if text == "rust" || text == "crates.io"
387
+ ECOSYSTEMS.include?(text) ? text : nil
388
+ end
389
+
390
+ def bounded_max_results(value)
391
+ bounded_integer(value, DEFAULT_MAX_RESULTS, 1, MAX_MAX_RESULTS)
392
+ end
393
+
394
+ def bounded_integer(value, default, min, max)
395
+ integer = value.to_s.empty? ? default : value.to_i
396
+ [[integer, min].max, max].min
397
+ end
398
+
399
+ def repository_url(value)
400
+ return value["url"] || value[:url] if value.is_a?(Hash)
401
+
402
+ value
403
+ end
404
+
405
+ def escape_path(value)
406
+ value.to_s.split("/").map { |part| CGI.escape(part) }.join("/")
407
+ end
408
+
409
+ def extract_github_url(text)
410
+ text[%r{https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:\.git)?}]
411
+ end
412
+
413
+ def github_headers
414
+ token = ENV["GITHUB_TOKEN"] || ENV["GH_TOKEN"]
415
+ token.to_s.empty? ? {} : { "Authorization" => "Bearer #{token}" }
416
+ end
417
+
418
+ def value(args, key)
419
+ return args[key] if args.respond_to?(:key?) && args.key?(key)
420
+ return args[key.to_sym] if args.respond_to?(:key?) && args.key?(key.to_sym)
421
+
422
+ nil
423
+ end
424
+
425
+ def relative_path(root, path)
426
+ Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
427
+ end
428
+
429
+ def truncate(text)
430
+ return text if text.bytesize <= @max_output_bytes
431
+
432
+ text.byteslice(0, @max_output_bytes).to_s.scrub + "\n... truncated ..."
433
+ end
434
+
435
+ def redact(message)
436
+ text = message.to_s
437
+ [ENV["GITHUB_TOKEN"], ENV["GH_TOKEN"]].each do |token|
438
+ text = text.gsub(token, "[REDACTED]") unless token.to_s.empty?
439
+ end
440
+ text = text.gsub(@cache_root, "[CACHE]")
441
+ text = text.gsub(Dir.home, "~") if Dir.respond_to?(:home)
442
+ truncate(text)
443
+ end
444
+ end
445
+ end