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,143 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Kward Pan Mode</title>
7
+ <style>
8
+ :root { color-scheme: light dark; font-family: system-ui, sans-serif; }
9
+ body { margin: 0; padding: 1rem; max-width: 56rem; margin-inline: auto; }
10
+ header { margin-bottom: 1rem; }
11
+ h1 { font-size: 1.35rem; margin: 0 0 .25rem; }
12
+ .meta { color: #666; font-size: .85rem; overflow-wrap: anywhere; }
13
+ #transcript { border: 1px solid #9995; border-radius: .5rem; min-height: 45vh; max-height: 65vh; overflow-y: auto; padding: .75rem; background: #7771; }
14
+ .entry { margin: 0 0 .75rem; white-space: pre-wrap; overflow-wrap: anywhere; }
15
+ .label { font-weight: 700; margin-bottom: .15rem; }
16
+ .user .label { color: #2f6fca; }
17
+ .assistant .label { color: #14833b; }
18
+ .tool .label { color: #9b4ab8; }
19
+ .reasoning .label { color: #9a6b00; }
20
+ form { position: sticky; bottom: 0; background: Canvas; padding-top: .75rem; margin-top: .75rem; }
21
+ textarea { box-sizing: border-box; width: 100%; min-height: 7rem; font: inherit; padding: .65rem; border-radius: .5rem; border: 1px solid #999; }
22
+ .controls { display: flex; gap: .5rem; align-items: center; margin-top: .5rem; }
23
+ button { font: inherit; padding: .6rem .9rem; border-radius: .45rem; border: 1px solid #777; }
24
+ #status { color: #666; font-size: .9rem; }
25
+ @media (max-width: 600px) {
26
+ body { padding: .65rem; }
27
+ #transcript { max-height: 58vh; }
28
+ .controls { align-items: stretch; flex-direction: column; }
29
+ button { width: 100%; }
30
+ }
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <header>
35
+ <h1>Kward Pan Mode</h1>
36
+ <div class="meta">Workspace: <%= ERB::Util.html_escape(@workspace_root) %></div>
37
+ <div class="meta">Session: <%= ERB::Util.html_escape(@session_path) %></div>
38
+ </header>
39
+
40
+ <main id="transcript" aria-live="polite"></main>
41
+
42
+ <form id="prompt-form">
43
+ <label for="prompt">Prompt</label>
44
+ <textarea id="prompt" name="prompt" autocomplete="off" placeholder="Give Kward an order..."></textarea>
45
+ <div class="controls">
46
+ <button type="submit">Send</button>
47
+ <span id="status">Connecting…</span>
48
+ </div>
49
+ </form>
50
+
51
+ <script>
52
+ const transcript = document.getElementById('transcript');
53
+ const form = document.getElementById('prompt-form');
54
+ const promptBox = document.getElementById('prompt');
55
+ const statusEl = document.getElementById('status');
56
+ let currentAssistant = null;
57
+ let currentReasoning = null;
58
+
59
+ function entry(role, label, text) {
60
+ const node = document.createElement('section');
61
+ node.className = `entry ${role}`;
62
+ const labelNode = document.createElement('div');
63
+ labelNode.className = 'label';
64
+ labelNode.textContent = label;
65
+ const textNode = document.createElement('div');
66
+ textNode.className = 'text';
67
+ textNode.textContent = text || '';
68
+ node.append(labelNode, textNode);
69
+ transcript.appendChild(node);
70
+ transcript.scrollTop = transcript.scrollHeight;
71
+ return textNode;
72
+ }
73
+
74
+ function setStatus(payload) {
75
+ const queued = payload && Number(payload.queued || 0);
76
+ const active = payload && payload.active;
77
+ statusEl.textContent = active ? `Working${queued ? ` — ${queued} queued` : ''}` : (queued ? `${queued} queued` : 'Ready');
78
+ }
79
+
80
+ function resetStreamBlocks() {
81
+ currentAssistant = null;
82
+ currentReasoning = null;
83
+ }
84
+
85
+ fetch('/transcript')
86
+ .then(response => response.json())
87
+ .then(data => {
88
+ (data.transcript || []).forEach(item => entry(item.role || 'system', item.label || item.role || 'Message', item.text || ''));
89
+ })
90
+ .catch(error => entry('tool', 'Error', error.message));
91
+
92
+ const events = new EventSource('/events');
93
+ events.addEventListener('ready', event => setStatus(JSON.parse(event.data)));
94
+ events.addEventListener('queue', event => setStatus(JSON.parse(event.data)));
95
+ events.addEventListener('turn_started', event => { resetStreamBlocks(); setStatus(JSON.parse(event.data)); });
96
+ events.addEventListener('turn_finished', event => { resetStreamBlocks(); setStatus(JSON.parse(event.data)); });
97
+ events.addEventListener('user', event => entry('user', 'You', JSON.parse(event.data).text));
98
+ events.addEventListener('reasoning_delta', event => {
99
+ if (!currentReasoning) currentReasoning = entry('reasoning', 'Reasoning', '');
100
+ currentReasoning.textContent += JSON.parse(event.data).delta;
101
+ transcript.scrollTop = transcript.scrollHeight;
102
+ });
103
+ events.addEventListener('assistant_delta', event => {
104
+ if (!currentAssistant) currentAssistant = entry('assistant', 'Assistant', '');
105
+ currentAssistant.textContent += JSON.parse(event.data).delta;
106
+ transcript.scrollTop = transcript.scrollHeight;
107
+ });
108
+ events.addEventListener('tool_call', event => {
109
+ const data = JSON.parse(event.data);
110
+ entry('tool', 'Tool', `${data.name} ${JSON.stringify(data.args || {})}`);
111
+ });
112
+ events.addEventListener('tool_result', event => {
113
+ const data = JSON.parse(event.data);
114
+ entry('tool', 'Tool output', `${data.name}: ${data.content}`);
115
+ });
116
+ events.addEventListener('answer', event => {
117
+ if (!currentAssistant) entry('assistant', 'Assistant', JSON.parse(event.data).content);
118
+ });
119
+ events.addEventListener('retry', event => entry('tool', 'Retry', JSON.parse(event.data).message));
120
+ events.addEventListener('error', event => entry('tool', 'Error', JSON.parse(event.data).message));
121
+ events.onerror = () => { statusEl.textContent = 'Disconnected'; };
122
+
123
+ form.addEventListener('submit', async event => {
124
+ event.preventDefault();
125
+ const prompt = promptBox.value;
126
+ if (!prompt.trim()) return;
127
+ promptBox.value = '';
128
+ try {
129
+ const response = await fetch('/turn', {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ prompt })
133
+ });
134
+ const data = await response.json();
135
+ if (!data.ok) entry('tool', 'Error', data.error || 'Prompt was not accepted.');
136
+ setStatus(data);
137
+ } catch (error) {
138
+ entry('tool', 'Error', error.message);
139
+ }
140
+ });
141
+ </script>
142
+ </body>
143
+ </html>
@@ -0,0 +1,397 @@
1
+ require "base64"
2
+ require "erb"
3
+ require "json"
4
+ require "socket"
5
+ require "thread"
6
+ require "time"
7
+ require "uri"
8
+ require_relative "../agent"
9
+ require_relative "../config_files"
10
+ require_relative "../events"
11
+ require_relative "../model/retry_message"
12
+ require_relative "../rpc/transcript_normalizer"
13
+ require_relative "../session_store"
14
+ require_relative "../tools/tool_call"
15
+ require_relative "../tools/registry"
16
+ require_relative "../workspace"
17
+
18
+ module Kward
19
+ class PanServer
20
+ DEFAULT_HOST = "0.0.0.0"
21
+ DEFAULT_PORT = 8765
22
+ MAX_REQUEST_BODY_BYTES = 64 * 1024
23
+
24
+ def initialize(client:, working_directory:, config: ConfigFiles.read_config, config_dir: ConfigFiles.config_dir, output: $stderr)
25
+ @client = client
26
+ @output = output
27
+ @workspace = Workspace.new(root: working_directory)
28
+ @config = pan_config(config)
29
+ @host = @config.fetch("host", DEFAULT_HOST).to_s
30
+ @port = positive_port(@config.fetch("port", DEFAULT_PORT))
31
+ @username = @config["username"].to_s
32
+ @password = @config["password"].to_s
33
+ raise "Pan mode requires pan_mode.username and pan_mode.password in #{ConfigFiles.config_path}" if @username.empty? || @password.empty?
34
+
35
+ @session_store = SessionStore.new(config_dir: config_dir, cwd: @workspace.root.to_s)
36
+ @session = @session_store.create
37
+ @conversation = Conversation.new(
38
+ workspace_root: @workspace.root.to_s,
39
+ model: (@client.current_model if @client.respond_to?(:current_model)),
40
+ reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
41
+ )
42
+ @session.attach(@conversation)
43
+ @agent = Agent.new(
44
+ client: @client,
45
+ tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false),
46
+ conversation: @conversation
47
+ )
48
+ @prompt_queue = Queue.new
49
+ @subscribers = []
50
+ @subscribers_mutex = Mutex.new
51
+ @worker_started = false
52
+ @active = false
53
+ @state_mutex = Mutex.new
54
+ end
55
+
56
+ attr_reader :host, :port, :session, :workspace
57
+
58
+ def run
59
+ start_worker
60
+ @server = TCPServer.new(@host, @port)
61
+ actual_port = @server.addr[1]
62
+ @output.puts "Kward pan mode listening on http://#{display_host}:#{actual_port}"
63
+ @output.puts "Workspace: #{@workspace.root}"
64
+ @output.puts "Session: #{@session.path}"
65
+
66
+ loop do
67
+ socket = @server.accept
68
+ Thread.new(socket) { |client_socket| handle_client(client_socket) }
69
+ end
70
+ rescue Interrupt
71
+ @output.puts "\nPan mode stopped."
72
+ ensure
73
+ stop
74
+ end
75
+
76
+ def stop
77
+ @server&.close unless @server&.closed?
78
+ rescue IOError
79
+ nil
80
+ end
81
+
82
+ def enqueue_prompt(prompt)
83
+ text = prompt.to_s
84
+ return { ok: false, error: "Prompt is required" } if text.strip.empty?
85
+
86
+ queued_at = Time.now.utc.iso8601(3)
87
+ @prompt_queue << { prompt: text, queued_at: queued_at }
88
+ broadcast("queue", queue_payload)
89
+ { ok: true, queued: @prompt_queue.size, active: active? }
90
+ end
91
+
92
+ def transcript_items
93
+ RPC::TranscriptNormalizer.new(@conversation.messages).normalize.flat_map { |message| pan_transcript_items(message) }
94
+ end
95
+
96
+ private
97
+
98
+ def start_worker
99
+ return if @worker_started
100
+
101
+ @worker_started = true
102
+ @worker_thread = Thread.new do
103
+ loop do
104
+ item = @prompt_queue.pop
105
+ run_prompt(item[:prompt])
106
+ end
107
+ end
108
+ end
109
+
110
+ def run_prompt(prompt)
111
+ set_active(true)
112
+ broadcast("user", { text: prompt })
113
+ broadcast("turn_started", queue_payload)
114
+ @agent.ask(prompt) do |event|
115
+ case event
116
+ when Events::ReasoningDelta
117
+ broadcast("reasoning_delta", { delta: event.delta.to_s })
118
+ when Events::AssistantDelta
119
+ broadcast("assistant_delta", { delta: event.delta.to_s })
120
+ when Events::Retry
121
+ broadcast("retry", { message: retry_message(event) })
122
+ when Events::ToolCall
123
+ broadcast("tool_call", tool_call_payload(event.tool_call))
124
+ when Events::ToolResult
125
+ broadcast("tool_result", tool_result_payload(event.tool_call, event.content))
126
+ when Events::Answer
127
+ broadcast("answer", { content: event.content.to_s })
128
+ end
129
+ end
130
+ broadcast("turn_finished", queue_payload.merge(status: "completed"))
131
+ rescue StandardError => e
132
+ broadcast("error", { message: e.message })
133
+ broadcast("turn_finished", queue_payload.merge(status: "failed"))
134
+ ensure
135
+ set_active(false)
136
+ broadcast("queue", queue_payload)
137
+ end
138
+
139
+ def handle_client(socket)
140
+ request = read_request(socket)
141
+ return unless request
142
+
143
+ unless authorized?(request[:headers])
144
+ write_response(socket, 401, { "WWW-Authenticate" => 'Basic realm="Kward pan mode"' }, "Unauthorized\n")
145
+ return
146
+ end
147
+
148
+ case [request[:method], request[:path]]
149
+ when ["GET", "/"]
150
+ write_response(socket, 200, { "Content-Type" => "text/html; charset=utf-8" }, render_index)
151
+ when ["GET", "/transcript"]
152
+ write_json(socket, 200, transcript: transcript_items, session: { id: @session.id, path: @session.path }, workspace: @workspace.root.to_s)
153
+ when ["GET", "/events"]
154
+ stream_events(socket)
155
+ when ["POST", "/turn"]
156
+ handle_turn(socket, request[:body])
157
+ else
158
+ write_response(socket, 404, { "Content-Type" => "text/plain; charset=utf-8" }, "Not found\n")
159
+ end
160
+ rescue StandardError => e
161
+ write_response(socket, 500, { "Content-Type" => "text/plain; charset=utf-8" }, "Error: #{e.message}\n") rescue nil
162
+ ensure
163
+ socket.close unless socket.closed?
164
+ end
165
+
166
+ def read_request(socket)
167
+ request_line = socket.gets
168
+ return nil unless request_line
169
+
170
+ method, target, = request_line.split(" ", 3)
171
+ headers = {}
172
+ while (line = socket.gets)
173
+ line = line.chomp
174
+ break if line.empty?
175
+
176
+ name, value = line.split(":", 2)
177
+ headers[name.downcase] = value.to_s.strip if name
178
+ end
179
+
180
+ length = headers.fetch("content-length", "0").to_i
181
+ raise "Request body too large" if length > MAX_REQUEST_BODY_BYTES
182
+
183
+ body = length.positive? ? socket.read(length).to_s : ""
184
+ path = URI.parse(target.to_s).path
185
+ { method: method.to_s.upcase, path: path, headers: headers, body: body }
186
+ end
187
+
188
+ def authorized?(headers)
189
+ value = headers["authorization"].to_s
190
+ return false unless value.start_with?("Basic ")
191
+
192
+ expected = Base64.strict_encode64("#{@username}:#{@password}")
193
+ secure_compare(value.delete_prefix("Basic "), expected)
194
+ end
195
+
196
+ def secure_compare(left, right)
197
+ left = left.to_s
198
+ right = right.to_s
199
+ return false unless left.bytesize == right.bytesize
200
+
201
+ left.bytes.zip(right.bytes).reduce(0) { |memo, pair| memo | (pair[0] ^ pair[1]) }.zero?
202
+ end
203
+
204
+ def handle_turn(socket, body)
205
+ params = JSON.parse(body.empty? ? "{}" : body)
206
+ result = enqueue_prompt(params["prompt"])
207
+ status = result[:ok] ? 202 : 422
208
+ write_json(socket, status, result)
209
+ rescue JSON::ParserError
210
+ write_json(socket, 400, ok: false, error: "Invalid JSON")
211
+ end
212
+
213
+ def stream_events(socket)
214
+ subscriber = Queue.new
215
+ subscribe(subscriber)
216
+ socket.write "HTTP/1.1 200 OK\r\n"
217
+ socket.write "Content-Type: text/event-stream\r\n"
218
+ socket.write "Cache-Control: no-cache\r\n"
219
+ socket.write "Connection: keep-alive\r\n\r\n"
220
+ socket.write sse("ready", queue_payload)
221
+ socket.flush
222
+ loop do
223
+ socket.write subscriber.pop
224
+ socket.flush
225
+ end
226
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
227
+ nil
228
+ ensure
229
+ unsubscribe(subscriber)
230
+ end
231
+
232
+ def subscribe(queue)
233
+ @subscribers_mutex.synchronize { @subscribers << queue }
234
+ end
235
+
236
+ def unsubscribe(queue)
237
+ @subscribers_mutex.synchronize { @subscribers.delete(queue) }
238
+ end
239
+
240
+ def broadcast(event, payload)
241
+ message = sse(event, payload)
242
+ @subscribers_mutex.synchronize do
243
+ @subscribers.each { |subscriber| subscriber << message }
244
+ end
245
+ end
246
+
247
+ def sse(event, payload)
248
+ "event: #{event}\ndata: #{JSON.generate(payload)}\n\n"
249
+ end
250
+
251
+ def write_json(socket, status, payload)
252
+ write_response(socket, status, { "Content-Type" => "application/json; charset=utf-8" }, JSON.generate(payload))
253
+ end
254
+
255
+ def write_response(socket, status, headers, body)
256
+ body = body.to_s
257
+ socket.write "HTTP/1.1 #{status} #{reason_phrase(status)}\r\n"
258
+ headers.each { |name, value| socket.write "#{name}: #{value}\r\n" }
259
+ socket.write "Content-Length: #{body.bytesize}\r\n"
260
+ socket.write "Connection: close\r\n\r\n"
261
+ socket.write body
262
+ end
263
+
264
+ def render_index
265
+ @workspace_root = @workspace.root.to_s
266
+ @session_path = @session.path
267
+ template = File.read(File.join(__dir__, "index.html.erb"))
268
+ ERB.new(template).result(binding)
269
+ end
270
+
271
+ def pan_config(config)
272
+ values = config["pan_mode"] || config["panMode"]
273
+ values.is_a?(Hash) ? values : {}
274
+ end
275
+
276
+ def positive_port(value)
277
+ port = value.to_i
278
+ port.positive? ? port : DEFAULT_PORT
279
+ end
280
+
281
+ def display_host
282
+ @host == "0.0.0.0" ? "<lan-address>" : @host
283
+ end
284
+
285
+ def set_active(value)
286
+ @state_mutex.synchronize { @active = value }
287
+ end
288
+
289
+ def active?
290
+ @state_mutex.synchronize { @active }
291
+ end
292
+
293
+ def queue_payload
294
+ { queued: @prompt_queue.size, active: active? }
295
+ end
296
+
297
+ def retry_message(event)
298
+ RetryMessage.format(event)
299
+ end
300
+
301
+ def tool_call_payload(tool_call)
302
+ { name: tool_call_name(tool_call), args: tool_call_args(tool_call) }
303
+ end
304
+
305
+ def tool_result_payload(tool_call, content)
306
+ { name: tool_call_name(tool_call), content: content.to_s }
307
+ end
308
+
309
+ def pan_transcript_items(message)
310
+ case message[:role] || message["role"]
311
+ when "user"
312
+ [{ role: "user", label: "You", text: normalized_content_text(message[:content] || message["content"]) }]
313
+ when "assistant"
314
+ assistant_items(message[:content] || message["content"])
315
+ when "toolResult"
316
+ [{ role: "tool", label: "Tool output", text: tool_result_text(message) }]
317
+ when "compactionSummary"
318
+ [{ role: "system", label: "Compaction summary", text: message[:summary] || message["summary"] }]
319
+ else
320
+ text = normalized_content_text(message[:content] || message["content"])
321
+ text.empty? ? [] : [{ role: (message[:role] || message["role"]).to_s, label: message[:role] || message["role"] || "Message", text: text }]
322
+ end
323
+ end
324
+
325
+ def assistant_items(content)
326
+ Array(content).filter_map do |part|
327
+ next unless part.is_a?(Hash)
328
+
329
+ case part[:type] || part["type"]
330
+ when "thinking"
331
+ text = part[:thinking] || part["thinking"]
332
+ text.to_s.empty? ? nil : { role: "reasoning", label: "Reasoning", text: text }
333
+ when "text"
334
+ text = part[:text] || part["text"]
335
+ text.to_s.empty? ? nil : { role: "assistant", label: "Assistant", text: text }
336
+ when "image"
337
+ { role: "assistant", label: "Assistant", text: image_part_text(part) }
338
+ when "toolCall"
339
+ { role: "tool", label: "Tool", text: tool_call_part_text(part) }
340
+ end
341
+ end
342
+ end
343
+
344
+ def normalized_content_text(content)
345
+ Array(content).filter_map do |part|
346
+ next part.to_s unless part.is_a?(Hash)
347
+
348
+ case part[:type] || part["type"]
349
+ when "text"
350
+ part[:text] || part["text"]
351
+ when "image"
352
+ image_part_text(part)
353
+ when "thinking"
354
+ part[:thinking] || part["thinking"]
355
+ end
356
+ end.join("\n")
357
+ end
358
+
359
+ def image_part_text(part)
360
+ alt = part[:alt] || part["alt"]
361
+ media_type = part[:mimeType] || part["mimeType"] || "image"
362
+ "[#{media_type}#{alt.to_s.empty? ? "" : ": #{alt}"}]"
363
+ end
364
+
365
+ def tool_call_part_text(part)
366
+ name = part[:name] || part["name"] || "unknown_tool"
367
+ arguments = part[:arguments] || part["arguments"] || {}
368
+ "#{name} #{JSON.generate(arguments)}"
369
+ end
370
+
371
+ def tool_result_text(message)
372
+ name = message[:toolName] || message["toolName"] || "tool"
373
+ content = normalized_content_text(message[:content] || message["content"])
374
+ "#{name}: #{content}"
375
+ end
376
+
377
+ def tool_call_name(tool_call)
378
+ ToolCall.name(tool_call) || "unknown_tool"
379
+ end
380
+
381
+ def tool_call_args(tool_call)
382
+ ToolCall.arguments(tool_call)
383
+ end
384
+
385
+ def reason_phrase(status)
386
+ case status
387
+ when 200 then "OK"
388
+ when 202 then "Accepted"
389
+ when 400 then "Bad Request"
390
+ when 401 then "Unauthorized"
391
+ when 404 then "Not Found"
392
+ when 422 then "Unprocessable Content"
393
+ else "Internal Server Error"
394
+ end
395
+ end
396
+ end
397
+ end