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,68 @@
1
+ require "time"
2
+ require_relative "tool_metadata"
3
+
4
+ module Kward
5
+ module RPC
6
+ class ToolEventNormalizer
7
+ def initialize(tool_call, content: nil)
8
+ @tool_call = tool_call
9
+ @content = content
10
+ @fields = ToolMetadata.normalized_tool_fields(@tool_call)
11
+ end
12
+
13
+ def call_payload(legacy_tool: nil)
14
+ {
15
+ toolCall: @tool_call,
16
+ rawToolCall: @tool_call,
17
+ tool: legacy_tool,
18
+ toolCallId: @fields[:toolCallId],
19
+ toolName: @fields[:toolName],
20
+ args: @fields[:args]
21
+ }.compact
22
+ end
23
+
24
+ def result_payload(legacy_tool: nil)
25
+ call_payload(legacy_tool: legacy_tool).merge(
26
+ content: @content,
27
+ result: normalized_result
28
+ )
29
+ end
30
+
31
+ def execution_record(timestamp: Time.now.utc.iso8601(3))
32
+ result = normalized_result
33
+ {
34
+ type: "tool_execution_end",
35
+ timestamp: timestamp,
36
+ toolCallId: @fields[:toolCallId],
37
+ toolName: @fields[:toolName],
38
+ args: @fields[:args],
39
+ result: result,
40
+ isError: result[:isError]
41
+ }
42
+ end
43
+
44
+ private
45
+
46
+ def normalized_result
47
+ text = @content.to_s
48
+ is_error = ToolMetadata.error_result?(text)
49
+ result = { content: text, isError: is_error, images: [] }
50
+ unless is_error
51
+ diff = ToolMetadata.extract_unified_diff(text)
52
+ result[:diff] = diff if diff
53
+
54
+ files = changed_files
55
+ result[:changedFiles] = files if files.any?
56
+ end
57
+ result
58
+ end
59
+
60
+ def changed_files
61
+ return [] unless ["edit", "write"].include?(@fields[:toolName])
62
+
63
+ path = @fields.dig(:args, :path)
64
+ path.to_s.empty? ? [] : [path]
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,80 @@
1
+ require_relative "../tools/tool_call"
2
+ require_relative "../workspace"
3
+
4
+ module Kward
5
+ module RPC
6
+ module ToolMetadata
7
+ module_function
8
+
9
+ def normalized_tool_fields(tool_call)
10
+ raw_name = ToolCall.name(tool_call)
11
+ args = ToolCall.arguments(tool_call)
12
+ {
13
+ toolCallId: ToolCall.id(tool_call),
14
+ toolName: normalize_tool_name(raw_name) || raw_name,
15
+ args: normalize_tool_args(raw_name, args)
16
+ }.compact
17
+ end
18
+
19
+ def normalize_tool_name(name)
20
+ ToolCall.normalized_name(name)
21
+ end
22
+
23
+ def normalize_tool_args(name, args)
24
+ case name.to_s
25
+ when "edit_file", "edit"
26
+ normalize_edit_args(args)
27
+ when "write_file", "write"
28
+ normalize_write_args(args)
29
+ when "run_shell_command", "bash"
30
+ normalize_bash_args(args)
31
+ else
32
+ ToolCall.camelize_args(args)
33
+ end
34
+ end
35
+
36
+ def normalize_edit_args(args)
37
+ result = {}
38
+ path = ToolCall.value(args, :path)
39
+ result[:path] = path if path
40
+ edits = Array(ToolCall.value(args, :edits)).filter_map do |edit|
41
+ next unless edit.is_a?(Hash)
42
+
43
+ {
44
+ oldText: ToolCall.value(edit, :oldText) || ToolCall.value(edit, :old_text),
45
+ newText: ToolCall.value(edit, :newText) || ToolCall.value(edit, :new_text)
46
+ }.compact
47
+ end
48
+ result[:edits] = edits if edits.any?
49
+ result
50
+ end
51
+
52
+ def normalize_write_args(args)
53
+ result = {}
54
+ path = ToolCall.value(args, :path)
55
+ content = ToolCall.value(args, :content)
56
+ result[:path] = path if path
57
+ result[:content] = content if content
58
+ result
59
+ end
60
+
61
+ def normalize_bash_args(args)
62
+ result = {}
63
+ command = ToolCall.value(args, :command)
64
+ timeout = ToolCall.value(args, :timeout) || ToolCall.value(args, :timeout_seconds) || Workspace::DEFAULT_COMMAND_TIMEOUT_SECONDS
65
+ result[:command] = command if command
66
+ result[:timeout] = timeout if timeout
67
+ result
68
+ end
69
+
70
+ def extract_unified_diff(text)
71
+ index = text.to_s.index(/^--- /)
72
+ index ? text.to_s[index..] : nil
73
+ end
74
+
75
+ def error_result?(text)
76
+ text.to_s.start_with?("Error:", "Declined:", "Cancelled.")
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,307 @@
1
+ require_relative "../tools/tool_call"
2
+ require_relative "tool_metadata"
3
+
4
+ module Kward
5
+ module RPC
6
+ class TranscriptNormalizer
7
+ IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
8
+ THINKING_CONTENT_TYPES = ["thinking", "reasoning"].freeze
9
+
10
+ def initialize(messages)
11
+ @messages = Array(messages)
12
+ @tool_calls_by_id = {}
13
+ end
14
+
15
+ def normalize
16
+ @messages.filter_map { |message| normalize_message(message) }
17
+ end
18
+
19
+ private
20
+
21
+ def normalize_message(message)
22
+ return nil unless message.is_a?(Hash)
23
+
24
+ case value(message, :role).to_s
25
+ when "system"
26
+ nil
27
+ when "user"
28
+ normalize_user_message(message)
29
+ when "assistant"
30
+ normalize_assistant_message(message)
31
+ when "tool"
32
+ normalize_tool_result_message(message)
33
+ when "toolResult"
34
+ normalize_tool_result_message(message)
35
+ when "compactionSummary"
36
+ normalize_compaction_summary(message)
37
+ when "branchSummary", "custom"
38
+ message
39
+ else
40
+ nil
41
+ end
42
+ end
43
+
44
+ def normalize_compaction_summary(message)
45
+ summary = value(message, :summary) || value(message, :content)
46
+ result = { role: "compactionSummary", summary: summary.to_s }
47
+ tokens_before = value(message, :tokensBefore) || value(message, :tokens_before)
48
+ result[:tokensBefore] = tokens_before if tokens_before
49
+ result
50
+ end
51
+
52
+ def normalize_user_message(message)
53
+ {
54
+ role: "user",
55
+ content: normalize_content(value(message, :content))
56
+ }
57
+ end
58
+
59
+ def normalize_assistant_message(message)
60
+ content = reasoning_first_content(normalize_content(value(message, :content), preserve_thinking: true))
61
+ reasoning = normalize_reasoning_summary(message)
62
+ content.unshift(reasoning) if reasoning && !thinking_content?(content)
63
+ tool_calls(message).each do |tool_call|
64
+ normalized_tool_call = normalize_tool_call(tool_call)
65
+ next unless normalized_tool_call
66
+
67
+ @tool_calls_by_id[normalized_tool_call[:id]] = normalized_tool_call if normalized_tool_call[:id]
68
+ content << normalized_tool_call
69
+ end
70
+
71
+ result = { role: "assistant", content: content }
72
+ error_message = value(message, :errorMessage) || value(message, :error_message)
73
+ result[:errorMessage] = error_message unless error_message.to_s.empty?
74
+ result
75
+ end
76
+
77
+ def normalize_tool_result_message(message)
78
+ tool_call_id = value(message, :toolCallId) || value(message, :tool_call_id)
79
+ matching_call = @tool_calls_by_id[tool_call_id]
80
+ raw_name = value(message, :toolName) || value(message, :tool_name) || value(message, :name)
81
+ tool_name = normalize_tool_name(raw_name) || raw_name || matching_call&.dig(:name)
82
+ content = normalize_content(value(message, :content))
83
+
84
+ result = {
85
+ role: "toolResult",
86
+ toolCallId: tool_call_id,
87
+ isError: error_tool_result?(message, content),
88
+ content: content
89
+ }.compact
90
+ result[:toolName] = tool_name if tool_name
91
+
92
+ details = tool_result_details(message, matching_call, content)
93
+ result[:details] = details unless details.empty?
94
+ result
95
+ end
96
+
97
+ def normalize_tool_call(tool_call)
98
+ return nil unless tool_call.is_a?(Hash)
99
+
100
+ raw_name = ToolCall.name(tool_call)
101
+ {
102
+ type: "toolCall",
103
+ id: ToolCall.id(tool_call),
104
+ name: normalize_tool_name(raw_name) || raw_name,
105
+ arguments: normalize_tool_arguments(raw_name, ToolCall.raw_arguments(tool_call))
106
+ }.compact
107
+ end
108
+
109
+ def normalize_content(content, preserve_thinking: false)
110
+ case content
111
+ when Array
112
+ content.filter_map { |part| normalize_content_part(part, preserve_thinking: preserve_thinking) }
113
+ when nil
114
+ []
115
+ else
116
+ [{ type: "text", text: content.to_s }]
117
+ end
118
+ end
119
+
120
+ def normalize_content_part(part, preserve_thinking: false)
121
+ return { type: "text", text: part.to_s } unless part.is_a?(Hash)
122
+
123
+ type = value(part, :type).to_s
124
+ case type
125
+ when "text"
126
+ text = value(part, :text)
127
+ text.nil? ? nil : { type: "text", text: text.to_s }
128
+ when "image"
129
+ normalize_image_part(part)
130
+ when "toolCall"
131
+ normalize_existing_tool_call_part(part)
132
+ when *THINKING_CONTENT_TYPES
133
+ preserve_thinking ? normalize_thinking_part(part) : normalize_unknown_content_part(part)
134
+ else
135
+ normalize_unknown_content_part(part)
136
+ end
137
+ end
138
+
139
+ def normalize_unknown_content_part(part)
140
+ text = value(part, :text)
141
+ text.nil? ? nil : { type: "text", text: text.to_s }
142
+ end
143
+
144
+ def normalize_thinking_part(part)
145
+ thinking = value(part, :thinking) || value(part, :reasoning) || value(part, :text)
146
+ thinking.nil? ? nil : { type: "thinking", thinking: thinking.to_s }
147
+ end
148
+
149
+ def normalize_reasoning_summary(message)
150
+ summary = value(message, :reasoning_summary) || value(message, :reasoningSummary)
151
+ summary.to_s.empty? ? nil : { type: "thinking", thinking: summary.to_s }
152
+ end
153
+
154
+ def thinking_content?(content)
155
+ content.any? { |part| thinking_content_part?(part) }
156
+ end
157
+
158
+ def reasoning_first_content(content)
159
+ thinking, other = content.partition { |part| thinking_content_part?(part) }
160
+ thinking.empty? ? content : thinking + other
161
+ end
162
+
163
+ def thinking_content_part?(part)
164
+ part.is_a?(Hash) && value(part, :type) == "thinking"
165
+ end
166
+
167
+ def normalize_image_part(part)
168
+ mime_type = value(part, :mimeType) || value(part, :mime_type) || value(part, :media_type)
169
+ mime_type = normalize_mime_type(mime_type)
170
+ return nil unless IMAGE_MIME_TYPES.include?(mime_type)
171
+
172
+ data = value(part, :data)
173
+ return nil if data.to_s.empty?
174
+
175
+ result = { type: "image", data: data, mimeType: mime_type }
176
+ alt = value(part, :alt) || image_alt_from_path(value(part, :path))
177
+ result[:alt] = alt unless alt.to_s.empty?
178
+ result
179
+ end
180
+
181
+ def normalize_existing_tool_call_part(part)
182
+ raw_name = value(part, :name)
183
+ arguments = value(part, :arguments) || {}
184
+ {
185
+ type: "toolCall",
186
+ id: value(part, :id),
187
+ name: normalize_tool_name(raw_name) || raw_name,
188
+ arguments: normalize_tool_arguments(raw_name, arguments)
189
+ }.compact
190
+ end
191
+
192
+ def tool_result_details(message, matching_call, content)
193
+ explicit_details = value(message, :details)
194
+ details = explicit_details.is_a?(Hash) ? safe_details(explicit_details) : {}
195
+ text = content_text(content)
196
+
197
+ diff = details[:diff] || details["diff"] || extract_unified_diff(text)
198
+ details[:diff] = diff if diff
199
+
200
+ changed_files = details[:changedFiles] || details["changedFiles"] || changed_files_from_result(text, matching_call)
201
+ details[:changedFiles] = changed_files if changed_files && !changed_files.empty?
202
+
203
+ details
204
+ end
205
+
206
+ def safe_details(details)
207
+ allowed = {}
208
+ diff = value(details, :diff)
209
+ allowed[:diff] = diff if diff
210
+ changed_files = value(details, :changedFiles) || value(details, :changed_files)
211
+ allowed[:changedFiles] = changed_files if changed_files.is_a?(Array)
212
+ allowed
213
+ end
214
+
215
+ def extract_unified_diff(text)
216
+ ToolMetadata.extract_unified_diff(text)
217
+ end
218
+
219
+ def changed_files_from_result(text, matching_call)
220
+ path = matching_call&.dig(:arguments, :path) || matching_call&.dig(:arguments, "path")
221
+ return [path] if path
222
+
223
+ if (match = text.match(/\A(?:Wrote \d+ bytes to|Edited)\s+([^:\n]+)/))
224
+ [match[1].strip]
225
+ else
226
+ []
227
+ end
228
+ end
229
+
230
+ def error_tool_result?(message, content)
231
+ return value(message, :isError) if has_key?(message, :isError)
232
+ return value(message, :is_error) if has_key?(message, :is_error)
233
+
234
+ ToolMetadata.error_result?(content_text(content))
235
+ end
236
+
237
+ def content_text(content)
238
+ Array(content).filter_map do |part|
239
+ part[:text] || part["text"] if part.is_a?(Hash)
240
+ end.join("\n")
241
+ end
242
+
243
+ def tool_calls(message)
244
+ calls = value(message, :tool_calls) || value(message, :toolCalls)
245
+ calls.is_a?(Array) ? calls : []
246
+ end
247
+
248
+ def normalize_tool_name(name)
249
+ ToolMetadata.normalize_tool_name(name)
250
+ end
251
+
252
+ def normalize_tool_arguments(name, arguments)
253
+ args = ToolCall.parse_arguments(arguments)
254
+ case name.to_s
255
+ when "edit_file", "edit"
256
+ normalize_edit_args(args)
257
+ when "run_shell_command", "bash"
258
+ normalize_bash_args(args)
259
+ else
260
+ camelize_tool_args(args)
261
+ end
262
+ end
263
+
264
+ def normalize_edit_args(args)
265
+ normalized = camelize_tool_args(args)
266
+ edits = Array(value(args, :edits)).filter_map do |edit|
267
+ next unless edit.is_a?(Hash)
268
+
269
+ {
270
+ oldText: value(edit, :oldText) || value(edit, :old_text),
271
+ newText: value(edit, :newText) || value(edit, :new_text)
272
+ }.compact
273
+ end
274
+ normalized[:edits] = edits if edits.any?
275
+ normalized
276
+ end
277
+
278
+ def normalize_bash_args(args)
279
+ normalized = camelize_tool_args(args)
280
+ timeout = value(args, :timeoutSeconds) || value(args, :timeout_seconds)
281
+ normalized[:timeoutSeconds] = timeout if timeout
282
+ normalized.delete(:timeout_seconds)
283
+ normalized
284
+ end
285
+
286
+ def camelize_tool_args(args)
287
+ ToolCall.camelize_args(args)
288
+ end
289
+
290
+ def normalize_mime_type(mime_type)
291
+ mime_type.to_s.downcase.sub("image/jpg", "image/jpeg")
292
+ end
293
+
294
+ def image_alt_from_path(path)
295
+ path ? File.basename(path.to_s) : nil
296
+ end
297
+
298
+ def value(object, key)
299
+ ToolCall.value(object, key)
300
+ end
301
+
302
+ def has_key?(object, key)
303
+ object.respond_to?(:key?) && (object.key?(key) || object.key?(key.to_s))
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,58 @@
1
+ require "json"
2
+
3
+ module Kward
4
+ module RPC
5
+ class Transport
6
+ def initialize(input:, output:)
7
+ @input = input
8
+ @output = output
9
+ @write_mutex = Mutex.new
10
+ end
11
+
12
+ def read_message
13
+ headers = read_headers
14
+ return nil unless headers
15
+
16
+ length = headers["content-length"].to_i
17
+ raise "Missing Content-Length header" if length <= 0
18
+
19
+ body = @input.read(length)
20
+ raise "Unexpected EOF while reading JSON-RPC body" unless body && body.bytesize == length
21
+
22
+ JSON.parse(body)
23
+ end
24
+
25
+ def write_message(message)
26
+ body = JSON.generate(message)
27
+ @write_mutex.synchronize do
28
+ @output.write("Content-Length: #{body.bytesize}\r\n\r\n")
29
+ @output.write(body)
30
+ @output.flush if @output.respond_to?(:flush)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def read_headers
37
+ headers = {}
38
+ saw_header = false
39
+
40
+ loop do
41
+ line = @input.gets
42
+ return nil unless line
43
+
44
+ line = line.delete_suffix("\n").delete_suffix("\r")
45
+ break if line.empty?
46
+
47
+ saw_header = true
48
+ name, value = line.split(":", 2)
49
+ raise "Invalid JSON-RPC header: #{line}" unless name && value
50
+
51
+ headers[name.downcase] = value.strip
52
+ end
53
+
54
+ saw_header ? headers : nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,125 @@
1
+ require "json"
2
+
3
+ module Kward
4
+ class SessionDiff
5
+ attr_reader :additions, :deletions
6
+
7
+ def initialize(additions: 0, deletions: 0)
8
+ @additions = additions.to_i
9
+ @deletions = deletions.to_i
10
+ end
11
+
12
+ def self.from_session_file(path)
13
+ records = File.readlines(path, chomp: true).filter_map { |line| parse_record(line) }
14
+ from_records(records)
15
+ rescue Errno::ENOENT, Errno::EACCES
16
+ new
17
+ end
18
+
19
+ def self.from_records(records)
20
+ execution_records = records.select { |record| record["type"] == "tool_execution_end" }
21
+ source_records = execution_records.empty? ? records : execution_records
22
+ source_records.each_with_object(new) do |record, diff|
23
+ if record["type"] == "tool_execution_end"
24
+ next if record["isError"] || record.dig("result", "isError")
25
+
26
+ diff.add_diff(record.dig("result", "diff"))
27
+ elsif record["type"] == "message" && (record.dig("message", "role") == "tool" || record.dig("message", :role) == "tool")
28
+ diff.add_tool_result(record.dig("message", "content") || record.dig("message", :content))
29
+ end
30
+ end
31
+ end
32
+
33
+ def self.count(diff)
34
+ if (stats = truncated_diff_stats(diff))
35
+ return stats
36
+ elsif truncated_diff?(diff)
37
+ return { additions: 0, deletions: 0 }
38
+ end
39
+
40
+ additions = 0
41
+ deletions = 0
42
+ removed = []
43
+ added = []
44
+ flush = lambda do
45
+ common = common_line_count(removed, added)
46
+ additions += added.length - common
47
+ deletions += removed.length - common
48
+ removed.clear
49
+ added.clear
50
+ end
51
+
52
+ diff.to_s.each_line do |line|
53
+ if line.start_with?("+") && !line.start_with?("+++")
54
+ added << line[1..]
55
+ elsif line.start_with?("-") && !line.start_with?("---")
56
+ removed << line[1..]
57
+ else
58
+ flush.call
59
+ end
60
+ end
61
+ flush.call
62
+ { additions: additions, deletions: deletions }
63
+ end
64
+
65
+ def empty?
66
+ @additions.zero? && @deletions.zero?
67
+ end
68
+
69
+ def add_tool_result(content)
70
+ text = content.to_s
71
+ return false if text.start_with?("Error:", "Declined:")
72
+
73
+ add_diff(extract_unified_diff(text))
74
+ end
75
+
76
+ def add_diff(diff)
77
+ counts = self.class.count(diff)
78
+ return false if counts[:additions].zero? && counts[:deletions].zero?
79
+
80
+ @additions += counts[:additions]
81
+ @deletions += counts[:deletions]
82
+ true
83
+ end
84
+
85
+ private
86
+
87
+ def self.truncated_diff_stats(diff)
88
+ match = diff.to_s.match(/^\.\.\. diff truncated to \d+ bytes; full diff stats: \+(\d+)\|-(\d+)\./)
89
+ return nil unless match
90
+
91
+ { additions: match[1].to_i, deletions: match[2].to_i }
92
+ end
93
+
94
+ def self.truncated_diff?(diff)
95
+ diff.to_s.match?(/^\.\.\. diff truncated to \d+ bytes;/)
96
+ end
97
+
98
+ def self.common_line_count(left, right)
99
+ previous = Array.new(right.length + 1, 0)
100
+ left.each do |left_line|
101
+ current = Array.new(right.length + 1, 0)
102
+ right.each_with_index do |right_line, index|
103
+ current[index + 1] = if left_line == right_line
104
+ previous[index] + 1
105
+ else
106
+ [current[index], previous[index + 1]].max
107
+ end
108
+ end
109
+ previous = current
110
+ end
111
+ previous.last
112
+ end
113
+
114
+ def self.parse_record(line)
115
+ JSON.parse(line)
116
+ rescue JSON::ParserError
117
+ nil
118
+ end
119
+
120
+ def extract_unified_diff(text)
121
+ index = text.index(/^--- /)
122
+ index ? text[index..] : nil
123
+ end
124
+ end
125
+ end