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.
- checksums.yaml +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- 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
|