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,87 @@
1
+ require "json"
2
+
3
+ module Kward
4
+ module ToolCall
5
+ TOOL_NAME_MAP = {
6
+ "read_file" => "read",
7
+ "edit_file" => "edit",
8
+ "write_file" => "write",
9
+ "run_shell_command" => "bash",
10
+ "list_directory" => "list_directory",
11
+ "web_search" => "web_search",
12
+ "read_skill" => "read_skill",
13
+ "ask_user_question" => "ask_user_question"
14
+ }.freeze
15
+
16
+ module_function
17
+
18
+ def id(tool_call)
19
+ value(tool_call, :id)
20
+ end
21
+
22
+ def name(tool_call)
23
+ value(function(tool_call), :name)
24
+ end
25
+
26
+ def display_name(tool_call)
27
+ raw_name = name(tool_call)
28
+ normalized_name(raw_name) || raw_name || "unknown_tool"
29
+ end
30
+
31
+ def arguments(tool_call)
32
+ parse_arguments(raw_arguments(tool_call))
33
+ end
34
+
35
+ def raw_arguments(tool_call)
36
+ value(function(tool_call), :arguments)
37
+ end
38
+
39
+ def function(tool_call)
40
+ value(tool_call, :function) || {}
41
+ end
42
+
43
+ def normalized_name(name)
44
+ TOOL_NAME_MAP[name.to_s]
45
+ end
46
+
47
+ def parse_arguments(arguments)
48
+ return {} if arguments.nil? || (arguments.respond_to?(:empty?) && arguments.empty?)
49
+ return arguments if arguments.is_a?(Hash)
50
+
51
+ JSON.parse(arguments.to_s)
52
+ rescue JSON::ParserError
53
+ {}
54
+ end
55
+
56
+ def camelize_args(args)
57
+ return {} unless args.is_a?(Hash)
58
+
59
+ args.each_with_object({}) do |(key, item), result|
60
+ result[camelize_key(key)] = camelize_value(item)
61
+ end
62
+ end
63
+
64
+ 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
70
+ end
71
+
72
+ def camelize_value(item)
73
+ case item
74
+ when Hash
75
+ camelize_args(item)
76
+ when Array
77
+ item.map { |entry| camelize_value(entry) }
78
+ else
79
+ item
80
+ end
81
+ end
82
+
83
+ def camelize_key(key)
84
+ key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }.to_sym
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,48 @@
1
+ require_relative "base"
2
+
3
+ module Kward
4
+ module Tools
5
+ class WebSearch < Base
6
+ def initialize(web_search:)
7
+ @web_search = web_search
8
+ super(
9
+ "web_search",
10
+ "Search the live web with bounded results.",
11
+ properties: {
12
+ queries: {
13
+ type: "array",
14
+ description: "1-4 distinct queries; avoid near-duplicates.",
15
+ items: { type: "string" },
16
+ minItems: 1,
17
+ maxItems: 4
18
+ },
19
+ max_results: {
20
+ type: "integer",
21
+ description: "Results per query; default 5, max 20."
22
+ },
23
+ provider: {
24
+ type: "string",
25
+ enum: %w[auto exa perplexity gemini legacy duckduckgo],
26
+ description: "Provider override; default auto."
27
+ },
28
+ recency_filter: {
29
+ type: "string",
30
+ enum: %w[day week month year],
31
+ description: "Recency filter."
32
+ },
33
+ domain_filter: {
34
+ type: "array",
35
+ description: "Domains to include; prefix '-' to exclude.",
36
+ items: { type: "string" }
37
+ }
38
+ },
39
+ required: ["queries"]
40
+ )
41
+ end
42
+
43
+ def call(args, _conversation, cancellation: nil)
44
+ @web_search.search(args)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ require_relative "base"
2
+
3
+ module Kward
4
+ module Tools
5
+ class WriteFile < Base
6
+ def initialize(workspace:)
7
+ @workspace = workspace
8
+ super(
9
+ "write_file",
10
+ "Write a workspace file. Existing files must be read first.",
11
+ properties: {
12
+ path: { type: "string", description: "Workspace-relative path." },
13
+ content: { type: "string", description: "Complete file content." }
14
+ },
15
+ required: ["path", "content"]
16
+ )
17
+ end
18
+
19
+ def call(args, conversation, cancellation: nil)
20
+ path = argument(args, :path, "")
21
+ content = argument(args, :content, "")
22
+
23
+ result = @workspace.write_file(path, content, read_paths: conversation.read_paths)
24
+ conversation.refresh_system_message! if agents_file_changed?(@workspace, path, result)
25
+ result
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,40 @@
1
+ require "cgi"
2
+ require_relative "markdown_transcript"
3
+
4
+ module Kward
5
+ class TranscriptExport
6
+ SUPPORTED_FORMATS = ["markdown", "html"].freeze
7
+
8
+ def self.format(value)
9
+ format = value.to_s.strip.downcase
10
+ format = "markdown" if format.empty? || format == "md"
11
+ raise ArgumentError, "Unsupported export format: #{value}" unless SUPPORTED_FORMATS.include?(format)
12
+
13
+ format
14
+ end
15
+
16
+ def self.content(conversation, format: "markdown")
17
+ markdown = MarkdownTranscript.new(conversation).render
18
+ return markdown if format(format) == "markdown"
19
+
20
+ html(markdown)
21
+ end
22
+
23
+ def self.html(markdown)
24
+ escaped = CGI.escapeHTML(markdown)
25
+ <<~HTML
26
+ <!doctype html>
27
+ <html>
28
+ <head>
29
+ <meta charset="utf-8">
30
+ <title>Kward Session</title>
31
+ </head>
32
+ <body>
33
+ <pre>#{escaped}</pre>
34
+ </body>
35
+ </html>
36
+ HTML
37
+ end
38
+ private_class_method :html
39
+ end
40
+ end
@@ -0,0 +1,4 @@
1
+ module Kward
2
+ # Current gem version.
3
+ VERSION = "0.66.0"
4
+ end
@@ -0,0 +1,377 @@
1
+ require "open3"
2
+ require "pathname"
3
+ require "timeout"
4
+ require_relative "session_diff"
5
+
6
+ module Kward
7
+ class Workspace
8
+ MAX_FILE_BYTES = 256 * 1024
9
+ MAX_READ_OUTPUT_BYTES = 50 * 1024
10
+ MAX_READ_OUTPUT_LINES = 2_000
11
+ MAX_COMMAND_OUTPUT_BYTES = 20 * 1024
12
+ MAX_EDIT_DIFF_BYTES = 8 * 1024
13
+ DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
14
+
15
+ 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)
16
+ @root = Pathname.new(root).realpath
17
+ @max_file_bytes = max_file_bytes
18
+ @max_read_output_bytes = max_read_output_bytes
19
+ @max_read_output_lines = max_read_output_lines
20
+ @max_command_output_bytes = max_command_output_bytes
21
+ end
22
+
23
+ attr_reader :root
24
+
25
+ def list_directory(path)
26
+ resolved = workspace_path(path)
27
+ return "Error: not a directory: #{path}" unless File.directory?(resolved)
28
+
29
+ Dir.children(resolved).sort.map do |entry|
30
+ File.directory?(File.join(resolved, entry)) ? "#{entry}/" : entry
31
+ end.join("\n")
32
+ rescue SecurityError, Errno::ENOENT => e
33
+ "Error: #{e.message}"
34
+ end
35
+
36
+ def read_file(path, offset: nil, limit: nil)
37
+ resolved = workspace_path(path)
38
+ return "Error: not a file: #{path}" unless File.file?(resolved)
39
+
40
+ size = File.size(resolved)
41
+ return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
42
+
43
+ read_file_slice(File.read(resolved), offset: offset, limit: limit)
44
+ rescue SecurityError, Errno::ENOENT => e
45
+ "Error: #{e.message}"
46
+ end
47
+
48
+ def write_file(path, content, read_paths:)
49
+ resolved = workspace_write_path(path)
50
+
51
+ if File.exist?(resolved) && !read_paths.include?(resolved)
52
+ return "Error: existing file must be read before writing: #{path}"
53
+ end
54
+
55
+ if block_given? && !yield(relative_path(resolved), content.bytesize)
56
+ return "Declined: write_file was not approved for #{path}"
57
+ end
58
+
59
+ old_content = File.exist?(resolved) ? File.read(resolved) : nil
60
+ File.write(resolved, content)
61
+ output = "Wrote #{content.bytesize} bytes to #{path}"
62
+ output << "\n#{truncated_diff(path, old_content, content)}" if old_content && old_content != content
63
+ output
64
+ rescue SecurityError, Errno::ENOENT => e
65
+ "Error: #{e.message}"
66
+ end
67
+
68
+ def edit_file(path, edits, read_paths:)
69
+ resolved = workspace_path(path)
70
+ return "Error: not a file: #{path}" unless File.file?(resolved)
71
+ return "Error: existing file must be read before editing: #{path}" unless read_paths.include?(resolved)
72
+
73
+ size = File.size(resolved)
74
+ return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
75
+
76
+ content = File.read(resolved)
77
+ result = apply_edits(path, content, edits)
78
+ return result[:error] if result[:error]
79
+
80
+ File.write(resolved, result[:content])
81
+ "Edited #{path}: replaced #{result[:count]} block(s)\n#{truncated_diff(path, content, result[:content])}"
82
+ rescue SecurityError, Errno::ENOENT => e
83
+ "Error: #{e.message}"
84
+ end
85
+
86
+ def run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil)
87
+ command = command.to_s.strip
88
+ return "Error: command is required" if command.empty?
89
+
90
+ timeout_seconds = timeout_seconds.to_i
91
+ timeout_seconds = DEFAULT_COMMAND_TIMEOUT_SECONDS if timeout_seconds <= 0
92
+ cancellation&.raise_if_cancelled!
93
+
94
+ Open3.popen3(command, chdir: @root.to_s) do |stdin, stdout, stderr, wait_thread|
95
+ stdin.close
96
+ stdout_reader = Thread.new { stdout.read }
97
+ stderr_reader = Thread.new { stderr.read }
98
+ cancellation&.on_cancel { terminate_process(wait_thread.pid) }
99
+ status = wait_for_process(wait_thread, timeout_seconds, cancellation)
100
+
101
+ output = +"Exit status: #{status.exitstatus}\n"
102
+ output << "\nSTDOUT:\n#{stdout_reader.value}" unless stdout_reader.value.empty?
103
+ output << "\nSTDERR:\n#{stderr_reader.value}" unless stderr_reader.value.empty?
104
+ truncate_output(output)
105
+ rescue Timeout::Error
106
+ terminate_process(wait_thread.pid)
107
+ "Error: command timed out after #{timeout_seconds} seconds"
108
+ ensure
109
+ stdout_reader&.kill if stdout_reader&.alive?
110
+ stderr_reader&.kill if stderr_reader&.alive?
111
+ end
112
+ rescue Errno::ENOENT, ArgumentError => e
113
+ "Error: #{e.message}"
114
+ end
115
+
116
+ def resolved_path(path)
117
+ workspace_path(path)
118
+ end
119
+
120
+ private
121
+
122
+ def workspace_path(path)
123
+ target = Pathname.new(path.to_s)
124
+ target = @root.join(target) unless target.absolute?
125
+
126
+ expanded = target.expand_path
127
+ raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(expanded)
128
+
129
+ resolved = target.realpath
130
+ raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(resolved)
131
+
132
+ resolved.to_s
133
+ end
134
+
135
+ def workspace_write_path(path)
136
+ target = Pathname.new(path.to_s)
137
+ target = @root.join(target) unless target.absolute?
138
+
139
+ expanded = target.expand_path
140
+ raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(expanded)
141
+
142
+ return workspace_path(path) if File.exist?(expanded) || File.symlink?(expanded)
143
+
144
+ parent = expanded.dirname.realpath
145
+ raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(parent)
146
+
147
+ expanded.to_s
148
+ end
149
+
150
+ def inside_workspace?(path)
151
+ path.to_s == @root.to_s || path.to_s.start_with?("#{@root}/")
152
+ end
153
+
154
+ def relative_path(path)
155
+ Pathname.new(path).relative_path_from(@root).to_s
156
+ end
157
+
158
+ def read_file_slice(content, offset:, limit:)
159
+ lines = content.split("\n", -1)
160
+ lines = [""] if lines.empty?
161
+ start_index = read_start_index(offset)
162
+ return "Error: offset #{offset} is beyond end of file (#{lines.length} lines total)" if start_index >= lines.length
163
+
164
+ user_limit = read_limit(limit)
165
+ return user_limit if user_limit.is_a?(String)
166
+
167
+ selected_end = user_limit ? [start_index + user_limit, lines.length].min : lines.length
168
+ selected_lines = lines[start_index...selected_end]
169
+ truncated = truncate_read_lines(selected_lines)
170
+ return truncated[:error] if truncated[:error]
171
+
172
+ output = truncated[:content]
173
+ if truncated[:truncated]
174
+ output << read_truncation_notice(
175
+ start_index: start_index,
176
+ output_lines: truncated[:line_count],
177
+ total_lines: lines.length,
178
+ truncated_by: truncated[:truncated_by]
179
+ )
180
+ elsif user_limit && selected_end < lines.length
181
+ output << "\n\n[#{lines.length - selected_end} more lines in file. Use offset=#{selected_end + 1} to continue.]"
182
+ end
183
+
184
+ output
185
+ end
186
+
187
+ def read_start_index(offset)
188
+ return 0 if offset.nil?
189
+
190
+ [offset.to_i - 1, 0].max
191
+ end
192
+
193
+ def read_limit(limit)
194
+ return nil if limit.nil?
195
+
196
+ value = limit.to_i
197
+ return "Error: limit must be positive" unless value.positive?
198
+
199
+ value
200
+ end
201
+
202
+ def truncate_read_lines(lines)
203
+ first_line = lines.first.to_s
204
+ if first_line.bytesize > @max_read_output_bytes
205
+ return {
206
+ error: "Error: first line is #{first_line.bytesize} bytes, exceeds #{@max_read_output_bytes} byte read limit. Use run_shell_command with sed/head to inspect smaller chunks."
207
+ }
208
+ end
209
+
210
+ output_lines = []
211
+ bytes = 0
212
+ truncated_by = nil
213
+ lines.each do |line|
214
+ if output_lines.length >= @max_read_output_lines
215
+ truncated_by = "lines"
216
+ break
217
+ end
218
+
219
+ separator_bytes = output_lines.empty? ? 0 : 1
220
+ next_bytes = line.bytesize + separator_bytes
221
+ if bytes + next_bytes > @max_read_output_bytes
222
+ truncated_by = "bytes"
223
+ break
224
+ end
225
+
226
+ output_lines << line
227
+ bytes += next_bytes
228
+ end
229
+
230
+ {
231
+ content: output_lines.join("\n"),
232
+ line_count: output_lines.length,
233
+ truncated: output_lines.length < lines.length,
234
+ truncated_by: truncated_by
235
+ }
236
+ end
237
+
238
+ def read_truncation_notice(start_index:, output_lines:, total_lines:, truncated_by:)
239
+ end_line = start_index + output_lines
240
+ next_offset = end_line + 1
241
+ detail = truncated_by == "lines" ? "#{@max_read_output_lines} line limit" : "#{@max_read_output_bytes} byte limit"
242
+ "\n\n[Showing lines #{start_index + 1}-#{end_line} of #{total_lines} (#{detail}). Use offset=#{next_offset} to continue.]"
243
+ end
244
+
245
+ def apply_edits(path, content, edits)
246
+ return { error: "Error: edits must contain at least one replacement" } unless edits.is_a?(Array) && !edits.empty?
247
+
248
+ replacements = []
249
+ edits.each_with_index do |edit, index|
250
+ old_text = edit_value(edit, "old_text")
251
+ new_text = edit_value(edit, "new_text")
252
+ return { error: "Error: edits[#{index}].old_text must be a string" } unless old_text.is_a?(String)
253
+ return { error: "Error: edits[#{index}].new_text must be a string" } unless new_text.is_a?(String)
254
+ return { error: "Error: edits[#{index}].old_text must not be empty" } if old_text.empty?
255
+
256
+ matches = match_indexes(content, old_text)
257
+ return { error: "Error: edits[#{index}].old_text was not found in #{path}" } if matches.empty?
258
+ if matches.length > 1
259
+ return { error: "Error: edits[#{index}].old_text appears #{matches.length} times in #{path}; provide more context" }
260
+ end
261
+
262
+ replacements << { index: index, start: matches.first, length: old_text.length, new_text: new_text }
263
+ end
264
+
265
+ replacements.sort_by! { |replacement| replacement[:start] }
266
+ replacements.each_cons(2) do |left, right|
267
+ if left[:start] + left[:length] > right[:start]
268
+ return { error: "Error: edits[#{left[:index]}] and edits[#{right[:index]}] overlap in #{path}" }
269
+ end
270
+ end
271
+
272
+ new_content = content.dup
273
+ replacements.reverse_each do |replacement|
274
+ new_content[replacement[:start], replacement[:length]] = replacement[:new_text]
275
+ end
276
+ return { error: "Error: no changes made to #{path}" } if new_content == content
277
+
278
+ { content: new_content, count: replacements.length }
279
+ end
280
+
281
+ def edit_value(edit, key)
282
+ return nil unless edit.is_a?(Hash)
283
+
284
+ edit[key] || edit[key.to_sym]
285
+ end
286
+
287
+ def match_indexes(content, needle)
288
+ indexes = []
289
+ offset = 0
290
+ while (index = content.index(needle, offset))
291
+ indexes << index
292
+ offset = index + needle.length
293
+ end
294
+ indexes
295
+ end
296
+
297
+ def truncated_diff(path, old_content, new_content)
298
+ diff = unified_diff(path, old_content, new_content)
299
+ return diff if diff.bytesize <= MAX_EDIT_DIFF_BYTES
300
+
301
+ counts = SessionDiff.count(diff)
302
+ diff.byteslice(0, MAX_EDIT_DIFF_BYTES).to_s.scrub << "\n... diff truncated to #{MAX_EDIT_DIFF_BYTES} bytes; full diff stats: +#{counts[:additions]}|-#{counts[:deletions]}. Use read_file to inspect current content."
303
+ end
304
+
305
+ def unified_diff(path, old_content, new_content)
306
+ old_lines = old_content.lines(chomp: true)
307
+ new_lines = new_content.lines(chomp: true)
308
+ prefix = 0
309
+ prefix += 1 while prefix < old_lines.length && prefix < new_lines.length && old_lines[prefix] == new_lines[prefix]
310
+
311
+ old_suffix = old_lines.length - 1
312
+ new_suffix = new_lines.length - 1
313
+ while old_suffix >= prefix && new_suffix >= prefix && old_lines[old_suffix] == new_lines[new_suffix]
314
+ old_suffix -= 1
315
+ new_suffix -= 1
316
+ end
317
+
318
+ context_start = [prefix - 3, 0].max
319
+ old_context_end = [old_suffix + 3, old_lines.length - 1].min
320
+ new_context_end = [new_suffix + 3, new_lines.length - 1].min
321
+ old_hunk_length = old_context_end >= context_start ? old_context_end - context_start + 1 : 0
322
+ new_hunk_length = new_context_end >= context_start ? new_context_end - context_start + 1 : 0
323
+
324
+ lines = ["--- #{path}", "+++ #{path}", "@@ -#{context_start + 1},#{old_hunk_length} +#{context_start + 1},#{new_hunk_length} @@"]
325
+ old_lines[context_start...prefix].to_a.each { |line| lines << " #{line}" }
326
+ old_lines[prefix..old_suffix].to_a.each { |line| lines << "-#{line}" }
327
+ new_lines[prefix..new_suffix].to_a.each { |line| lines << "+#{line}" }
328
+ old_lines[(old_suffix + 1)..old_context_end].to_a.each { |line| lines << " #{line}" }
329
+ lines.join("\n")
330
+ end
331
+
332
+ def truncate_output(output)
333
+ return output if output.bytesize <= @max_command_output_bytes
334
+
335
+ output.byteslice(0, @max_command_output_bytes) << "\n... truncated to #{@max_command_output_bytes} bytes"
336
+ end
337
+
338
+ def wait_for_process(wait_thread, timeout_seconds, cancellation)
339
+ deadline = Time.now + timeout_seconds
340
+ loop do
341
+ cancellation&.raise_if_cancelled!
342
+ if wait_thread.join(0.05)
343
+ cancellation&.raise_if_cancelled!
344
+ return wait_thread.value
345
+ end
346
+ raise Timeout::Error if Time.now >= deadline
347
+ end
348
+ end
349
+
350
+ def terminate_process(pid)
351
+ return unless signal_process("TERM", pid)
352
+
353
+ deadline = Time.now + 0.2
354
+ while Time.now < deadline
355
+ return unless process_running?(pid)
356
+
357
+ sleep 0.02
358
+ end
359
+
360
+ signal_process("KILL", pid)
361
+ end
362
+
363
+ def process_running?(pid)
364
+ Process.kill(0, pid)
365
+ true
366
+ rescue Errno::ESRCH
367
+ false
368
+ end
369
+
370
+ def signal_process(signal, pid)
371
+ Process.kill(signal, pid)
372
+ true
373
+ rescue Errno::ESRCH
374
+ false
375
+ end
376
+ end
377
+ end
data/lib/kward.rb ADDED
@@ -0,0 +1,6 @@
1
+ # Top-level require for Kward.
2
+ #
3
+ # Requiring `kward` loads the CLI entrypoint and version constant used by the
4
+ # executable and gemspec.
5
+ require_relative "kward/version"
6
+ require_relative "kward/cli"
data/lib/main.rb ADDED
@@ -0,0 +1,3 @@
1
+ require_relative "kward/cli"
2
+
3
+ Kward::CLI.new.run if __FILE__ == $PROGRAM_NAME