kward 0.67.1 → 0.69.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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -1,6 +1,15 @@
1
1
  require "json"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Parses streaming provider responses into Kward assistant messages.
6
+ #
7
+ # `ModelStreamParser` is intentionally provider-shape focused and side-effect
8
+ # light: it accumulates assistant text, reasoning summaries, tool calls, and
9
+ # normalized usage from SSE events. Network IO, retries, credentials, and
10
+ # telemetry stay in `Client`; frontend rendering stays in CLI/RPC event
11
+ # handlers. Keep parser methods deterministic and easy to unit test with raw
12
+ # response bodies.
4
13
  module ModelStreamParser
5
14
  module_function
6
15
 
@@ -46,6 +55,11 @@ module Kward
46
55
  raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
47
56
  end
48
57
 
58
+ # Incrementally parses a Codex/Responses SSE HTTP response body.
59
+ #
60
+ # Deltas are yielded as soon as complete SSE blocks arrive so interactive
61
+ # frontends can render streamed assistant and reasoning text without waiting
62
+ # for the provider to close the response.
49
63
  def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
50
64
  state = codex_sse_state
51
65
  buffer = +""
@@ -67,6 +81,122 @@ module Kward
67
81
  raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
68
82
  end
69
83
 
84
+ def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
85
+ state = anthropic_sse_state
86
+ buffer = +""
87
+ response.read_body do |chunk|
88
+ cancellation&.raise_if_cancelled!
89
+ buffer << chunk
90
+ while (index = buffer.index(/\r?\n\r?\n/))
91
+ delimiter = Regexp.last_match[0]
92
+ block = buffer[0...index]
93
+ buffer = buffer[(index + delimiter.length)..] || +""
94
+ process_anthropic_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
95
+ end
96
+ end
97
+ cancellation&.raise_if_cancelled!
98
+ process_anthropic_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
99
+ anthropic_sse_message(state)
100
+ rescue JSON::ParserError => e
101
+ raise "Anthropic returned invalid SSE JSON: #{e.message}"
102
+ end
103
+
104
+ def anthropic_sse_state
105
+ { content: +"", reasoning_summary: +"", blocks: {}, tool_calls: [], usage: nil }
106
+ end
107
+
108
+ def process_anthropic_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
109
+ event_name = nil
110
+ data = block.lines.filter_map do |line|
111
+ event_name = line.delete_prefix("event:").strip if line.start_with?("event:")
112
+ line.start_with?("data:") ? line.delete_prefix("data:").strip : nil
113
+ end.join("\n")
114
+ return if data.empty? || data == "[DONE]"
115
+ raise anthropic_sse_error(data, request_error_class: request_error_class) if event_name == "error"
116
+
117
+ event = JSON.parse(data)
118
+ case event["type"]
119
+ when "message_start"
120
+ state[:usage] ||= usage_normalizer&.call(event.dig("message", "usage"))
121
+ when "content_block_start"
122
+ block_data = event["content_block"] || {}
123
+ state[:blocks][event["index"].to_i] = block_data.merge("partial_json" => "")
124
+ if block_data["type"] == "text"
125
+ text = block_data["text"].to_s
126
+ state[:content] << text
127
+ on_assistant_delta&.call(text) unless text.empty?
128
+ elsif block_data["type"] == "thinking"
129
+ text = block_data["thinking"].to_s
130
+ state[:reasoning_summary] << text
131
+ on_reasoning_delta&.call(text) unless text.empty?
132
+ end
133
+ when "content_block_delta"
134
+ current = state[:blocks][event["index"].to_i] ||= { "partial_json" => "" }
135
+ delta = event["delta"] || {}
136
+ case delta["type"]
137
+ when "text_delta"
138
+ text = delta["text"].to_s
139
+ state[:content] << text
140
+ on_assistant_delta&.call(text)
141
+ when "thinking_delta"
142
+ text = delta["thinking"].to_s
143
+ state[:reasoning_summary] << text
144
+ on_reasoning_delta&.call(text)
145
+ when "input_json_delta"
146
+ current["partial_json"] = current["partial_json"].to_s + delta["partial_json"].to_s
147
+ end
148
+ when "content_block_stop"
149
+ block_data = state[:blocks][event["index"].to_i]
150
+ tool_call = anthropic_tool_call(block_data)
151
+ state[:tool_calls] << tool_call if tool_call
152
+ when "message_delta"
153
+ state[:usage] = usage_normalizer&.call(event["usage"]) || state[:usage]
154
+ end
155
+ end
156
+
157
+ def anthropic_sse_error(data, request_error_class: nil)
158
+ if request_error_class
159
+ request_error_class.new(provider: "Anthropic", code: 400, body: data)
160
+ else
161
+ data
162
+ end
163
+ end
164
+
165
+ def anthropic_tool_call(block_data)
166
+ return nil unless block_data.is_a?(Hash) && block_data["type"] == "tool_use"
167
+
168
+ name = from_claude_code_tool_name(block_data["name"])
169
+ arguments = block_data["partial_json"].to_s
170
+ arguments = JSON.dump(block_data["input"] || {}) if arguments.empty?
171
+ arguments = "{}" if arguments.empty?
172
+ {
173
+ "id" => block_data["id"] || "call_#{name}",
174
+ "type" => "function",
175
+ "function" => { "name" => name, "arguments" => arguments }
176
+ }
177
+ end
178
+
179
+ def anthropic_sse_message(state)
180
+ message = { "role" => "assistant", "content" => state[:content] }
181
+ message["reasoning_summary"] = state[:reasoning_summary] unless state[:reasoning_summary].empty?
182
+ message["tool_calls"] = state[:tool_calls] unless state[:tool_calls].empty?
183
+ message["usage"] = state[:usage] if state[:usage]
184
+ message
185
+ end
186
+
187
+ def from_claude_code_tool_name(name)
188
+ case name.to_s
189
+ when "Read" then "read_file"
190
+ when "Write" then "write_file"
191
+ when "Edit" then "edit_file"
192
+ when "Bash" then "run_shell_command"
193
+ when "WebSearch" then "web_search"
194
+ when "WebFetch" then "fetch_content"
195
+ when "AskUserQuestion" then "ask_user_question"
196
+ else name.to_s
197
+ end
198
+ end
199
+
70
200
  def merge_streaming_tool_call(tool_calls, delta)
71
201
  index = (delta["index"] || tool_calls.length).to_i
72
202
  tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => "", "arguments" => "" } }
@@ -89,7 +219,18 @@ module Kward
89
219
  end
90
220
 
91
221
  def codex_sse_state
92
- { content: +"", reasoning_summary: +"", tool_calls: [], final_output: [], usage: nil }
222
+ {
223
+ content: +"",
224
+ raw_content: +"",
225
+ emitted_message_keys: [],
226
+ reasoning_summary: +"",
227
+ tool_calls: [],
228
+ response_item_keys: [],
229
+ items_by_id: {},
230
+ active_item_id: nil,
231
+ current_text_content_part: nil,
232
+ usage: nil
233
+ }
93
234
  end
94
235
 
95
236
  def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
@@ -98,30 +239,33 @@ module Kward
98
239
 
99
240
  event = JSON.parse(data)
100
241
  case event["type"]
101
- when "response.output_text.delta"
102
- delta = event["delta"].to_s
103
- state[:content] << delta
104
- on_assistant_delta&.call(delta)
242
+ when "response.output_item.added"
243
+ codex_output_item_added(state, event["item"])
244
+ when "response.content_part.added"
245
+ codex_content_part_added(state, event["part"])
246
+ when "response.output_text.delta", "response.refusal.delta"
247
+ codex_output_text_delta(state, event["delta"], on_assistant_delta: on_assistant_delta)
248
+ when "response.reasoning_summary_part.added"
249
+ codex_reasoning_summary_part_added(state, event["part"])
105
250
  when "response.reasoning_summary_text.delta"
106
- delta = event["delta"].to_s
107
- state[:reasoning_summary] << delta
108
- on_reasoning_delta&.call(delta)
251
+ codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
252
+ when "response.reasoning_summary_part.done"
253
+ codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
254
+ when "response.reasoning_text.delta"
255
+ codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
256
+ when "response.function_call_arguments.delta", "response.custom_tool_call_input.delta"
257
+ codex_tool_arguments_delta(state, event["delta"])
258
+ when "response.function_call_arguments.done"
259
+ codex_tool_arguments_done(state, event["arguments"])
109
260
  when "response.output_item.done"
110
- item = event["item"]
111
- state[:final_output] << item if item.is_a?(Hash)
112
- tool_call = codex_tool_call(item)
113
- state[:tool_calls] << tool_call if tool_call
261
+ codex_output_item_done(state, event["item"], on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
114
262
  when "response.completed"
115
263
  response = event["response"]
116
264
  state[:usage] ||= usage_normalizer&.call(response["usage"]) if response.is_a?(Hash)
117
265
  state[:usage] ||= usage_normalizer&.call(event["usage"])
118
- if state[:content].empty? && response.is_a?(Hash) && response["output"].is_a?(Array)
119
- state[:final_output] = response["output"]
120
- text = text_from_codex_items(state[:final_output])
121
- state[:content] << text
122
- on_assistant_delta&.call(text) unless text.empty?
123
- if state[:reasoning_summary].empty?
124
- state[:reasoning_summary] << reasoning_summary_from_codex_items(state[:final_output])
266
+ if response.is_a?(Hash) && response["output"].is_a?(Array) && state[:response_item_keys].empty?
267
+ response["output"].each do |item|
268
+ codex_output_item_done(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
125
269
  end
126
270
  end
127
271
  when "response.failed", "response.incomplete"
@@ -146,21 +290,239 @@ module Kward
146
290
  end
147
291
  end
148
292
 
149
- def codex_sse_message(state)
150
- if state[:tool_calls].empty?
151
- state[:final_output].each do |item|
152
- tool_call = codex_tool_call(item)
153
- state[:tool_calls] << tool_call if tool_call
293
+ def codex_output_item_added(state, item)
294
+ return unless item.is_a?(Hash)
295
+
296
+ item = deep_dup_hash(item)
297
+ item["content"] = [] if item["type"] == "message" && !item["content"].is_a?(Array)
298
+ item["summary"] = [] if item["type"] == "reasoning" && !item["summary"].is_a?(Array)
299
+ remember_codex_item(state, item)
300
+ state[:active_item_id] = codex_item_key(item)
301
+ state[:current_text_content_part] = nil
302
+ end
303
+
304
+ def codex_content_part_added(state, part)
305
+ item = active_codex_item(state)
306
+ return unless item&.fetch("type", nil) == "message" && part.is_a?(Hash)
307
+ return unless ["output_text", "text", "refusal"].include?(part["type"])
308
+
309
+ item["content"] ||= []
310
+ item["content"] << deep_dup_hash(part)
311
+ state[:current_text_content_part] = item["content"].last
312
+ end
313
+
314
+ def codex_output_text_delta(state, delta, on_assistant_delta: nil)
315
+ text = delta.to_s
316
+ return if text.empty?
317
+
318
+ item = active_codex_item(state)
319
+ if item&.fetch("type", nil) == "message"
320
+ item["content"] ||= [{ "type" => "output_text", "text" => +"" }]
321
+ part = state[:current_text_content_part] || item["content"].last
322
+ part = item["content"].last unless part.is_a?(Hash)
323
+ part["type"] ||= "output_text"
324
+ text_key = part["type"] == "refusal" ? "refusal" : "text"
325
+ part[text_key] = part[text_key].to_s + text
326
+ end
327
+ state[:raw_content] << text
328
+ end
329
+
330
+ def codex_reasoning_summary_part_added(state, part)
331
+ item = active_codex_item(state)
332
+ return unless item&.fetch("type", nil) == "reasoning" && part.is_a?(Hash)
333
+
334
+ item["summary"] ||= []
335
+ item["summary"] << deep_dup_hash(part)
336
+ end
337
+
338
+ def codex_reasoning_delta(state, delta, on_reasoning_delta: nil)
339
+ text = delta.to_s
340
+ return if text.empty?
341
+
342
+ item = active_codex_item(state)
343
+ if item&.fetch("type", nil) == "reasoning"
344
+ item["summary"] ||= []
345
+ item["summary"] << { "type" => "summary_text", "text" => +"" } if item["summary"].empty?
346
+ item["summary"].last["text"] = item["summary"].last["text"].to_s + text
347
+ end
348
+ state[:reasoning_summary] << text
349
+ on_reasoning_delta&.call(text)
350
+ end
351
+
352
+ def codex_reasoning_part_done(state, on_reasoning_delta: nil)
353
+ item = active_codex_item(state)
354
+ return unless item&.fetch("type", nil) == "reasoning"
355
+ return if item["summary"].to_a.empty?
356
+
357
+ text = "\n\n"
358
+ item["summary"].last["text"] = item["summary"].last["text"].to_s + text
359
+ state[:reasoning_summary] << text
360
+ on_reasoning_delta&.call(text)
361
+ end
362
+
363
+ def codex_tool_arguments_delta(state, delta)
364
+ item = active_codex_item(state)
365
+ return unless item && ["function_call", "custom_tool_call"].include?(item["type"])
366
+
367
+ key = item["type"] == "custom_tool_call" ? "input" : "arguments"
368
+ item[key] = item[key].to_s + delta.to_s
369
+ end
370
+
371
+ def codex_tool_arguments_done(state, arguments)
372
+ item = active_codex_item(state)
373
+ return unless item&.fetch("type", nil) == "function_call"
374
+
375
+ item["arguments"] = arguments.to_s
376
+ end
377
+
378
+ def codex_output_item_done(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
379
+ return unless item.is_a?(Hash)
380
+
381
+ item = merge_codex_item(active_or_known_codex_item(state, item), item)
382
+ remember_codex_item(state, item)
383
+ collect_codex_item_output(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
384
+ state[:active_item_id] = nil if state[:active_item_id] == codex_item_key(item)
385
+ state[:current_text_content_part] = nil
386
+ end
387
+
388
+ def collect_codex_item_output(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
389
+ case item["type"]
390
+ when "message"
391
+ text = text_from_codex_items([item])
392
+ return if text.empty? || !codex_streamable_message_item?(item)
393
+
394
+ state[:content] << text
395
+ key = codex_item_key(item)
396
+ unless state[:emitted_message_keys].include?(key)
397
+ on_assistant_delta&.call(text)
398
+ state[:emitted_message_keys] << key
154
399
  end
400
+ when "reasoning"
401
+ text = reasoning_summary_from_codex_items([item])
402
+ if state[:reasoning_summary].empty? && !text.empty?
403
+ state[:reasoning_summary] << text
404
+ on_reasoning_delta&.call(text)
405
+ end
406
+ when "function_call", "custom_tool_call"
407
+ tool_call = codex_tool_call(item)
408
+ state[:tool_calls] << tool_call if tool_call && !state[:tool_calls].any? { |call| call["id"] == tool_call["id"] }
155
409
  end
410
+ end
156
411
 
157
- message = { "role" => "assistant", "content" => state[:content] }
412
+ def active_or_known_codex_item(state, item)
413
+ key = codex_item_key(item)
414
+ known = state[:items_by_id][key]
415
+ return known if known
416
+
417
+ active = active_codex_item(state)
418
+ return active if active && (!codex_item_has_stable_key?(item) || codex_item_key(active) == key)
419
+
420
+ nil
421
+ end
422
+
423
+ def codex_item_has_stable_key?(item)
424
+ item.key?("id") || item.key?("call_id")
425
+ end
426
+
427
+ def active_codex_item(state)
428
+ key = state[:active_item_id]
429
+ key ? state[:items_by_id][key] : nil
430
+ end
431
+
432
+ def remember_codex_item(state, item)
433
+ key = codex_item_key(item)
434
+ if state[:items_by_id].key?(key)
435
+ stored = state[:items_by_id][key]
436
+ stored.replace(merge_codex_item(stored, item))
437
+ else
438
+ state[:items_by_id][key] = item
439
+ state[:response_item_keys] << key
440
+ end
441
+ end
442
+
443
+ def codex_item_key(item)
444
+ item["id"] || item["call_id"] || "item_#{item.object_id}"
445
+ end
446
+
447
+ def merge_codex_item(existing, update)
448
+ return deep_dup_hash(update) unless existing.is_a?(Hash)
449
+
450
+ merged = deep_dup_hash(existing)
451
+ update.each do |key, value|
452
+ next if value.nil?
453
+
454
+ merged[key] = if key == "content" && merged[key].is_a?(Array) && value.is_a?(Array)
455
+ value.empty? ? merged[key] : value
456
+ elsif key == "summary" && merged[key].is_a?(Array) && value.is_a?(Array)
457
+ value.empty? ? merged[key] : value
458
+ elsif key == "arguments" && value.to_s.empty? && !merged[key].to_s.empty?
459
+ merged[key]
460
+ else
461
+ deep_dup(value)
462
+ end
463
+ end
464
+ merged
465
+ end
466
+
467
+ def deep_dup_hash(hash)
468
+ deep_dup(hash)
469
+ end
470
+
471
+ def deep_dup(value)
472
+ case value
473
+ when Hash
474
+ value.each_with_object({}) { |(key, entry), result| result[key] = deep_dup(entry) }
475
+ when Array
476
+ value.map { |entry| deep_dup(entry) }
477
+ when String
478
+ value.dup
479
+ else
480
+ value
481
+ end
482
+ end
483
+
484
+ def codex_sse_message(state)
485
+ message = { "role" => "assistant", "content" => codex_visible_content(state) }
158
486
  message["reasoning_summary"] = state[:reasoning_summary] unless state[:reasoning_summary].empty?
159
487
  message["tool_calls"] = state[:tool_calls] unless state[:tool_calls].empty?
488
+ response_items = codex_response_items(state)
489
+ message["response_items"] = response_items unless response_items.empty?
160
490
  message["usage"] = state[:usage] if state[:usage]
161
491
  message
162
492
  end
163
493
 
494
+ def codex_visible_content(state)
495
+ return state[:content] unless state[:content].empty?
496
+
497
+ response_items = codex_response_items(state)
498
+ visible_text = text_from_codex_items(visible_codex_message_items(response_items, tool_calls: state[:tool_calls]))
499
+ return visible_text unless visible_text.empty?
500
+ return "" unless state[:tool_calls].empty?
501
+
502
+ state[:raw_content]
503
+ end
504
+
505
+ def visible_codex_message_items(items, tool_calls: [])
506
+ messages = Array(items).select { |item| item.is_a?(Hash) && item["type"] == "message" }
507
+ final_messages = messages.select { |item| codex_message_phase(item) == "final_answer" }
508
+ return final_messages unless final_messages.empty?
509
+ return [] unless tool_calls.empty?
510
+
511
+ messages.reject { |item| codex_message_phase(item) == "commentary" }
512
+ end
513
+
514
+ def codex_streamable_message_item?(item)
515
+ codex_message_phase(item) == "final_answer"
516
+ end
517
+
518
+ def codex_message_phase(item)
519
+ item["phase"].to_s
520
+ end
521
+
522
+ def codex_response_items(state)
523
+ state[:response_item_keys].filter_map { |key| state[:items_by_id][key] }
524
+ end
525
+
164
526
  def codex_tool_call(item)
165
527
  return nil unless item.is_a?(Hash) && ["function_call", "custom_tool_call"].include?(item["type"])
166
528
 
@@ -15,7 +15,9 @@ require_relative "../tools/tool_call"
15
15
  require_relative "../tools/registry"
16
16
  require_relative "../workspace"
17
17
 
18
+ # Namespace for the Kward CLI agent runtime.
18
19
  module Kward
20
+ # Minimal local HTTP server for the experimental Pan web UI.
19
21
  class PanServer
20
22
  DEFAULT_HOST = "0.0.0.0"
21
23
  DEFAULT_PORT = 8765
@@ -269,7 +271,7 @@ module Kward
269
271
  end
270
272
 
271
273
  def pan_config(config)
272
- values = config["pan_mode"] || config["panMode"]
274
+ values = config["pan_mode"]
273
275
  values.is_a?(Hash) ? values : {}
274
276
  end
275
277
 
@@ -1,11 +1,17 @@
1
1
  require_relative "config_files"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
4
5
  # Loads trusted user plugin files and provides the plugin DSL.
5
6
  #
6
7
  # Plugins live in the user plugin directory, run as local Ruby code, and can
7
8
  # register slash commands, one footer renderer, prompt context, and live
8
9
  # transcript-event observers for CLI and RPC frontends.
10
+ #
11
+ # This registry is intentionally trust-based, not a sandbox. Keep plugin loading
12
+ # restricted to `ConfigFiles.plugin_paths`, keep workspace-local code out of the
13
+ # load path, and expose immutable transcript views so plugins can observe state
14
+ # without corrupting active conversations.
9
15
  class PluginRegistry
10
16
  COMMAND_NAME_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/.freeze
11
17
 
@@ -26,6 +32,7 @@ module Kward
26
32
 
27
33
  # Read-only transcript view exposed to plugin code.
28
34
  class Transcript
35
+ # Creates an object for trusted plugin loading and dispatch.
29
36
  def initialize(conversation)
30
37
  @conversation = conversation
31
38
  end
@@ -43,6 +50,7 @@ module Kward
43
50
  class Context
44
51
  attr_reader :args, :workspace_root
45
52
 
53
+ # Creates an object for trusted plugin loading and dispatch.
46
54
  def initialize(conversation:, args: "", session: nil, workspace_root: Dir.pwd, say_callback: nil)
47
55
  @conversation = conversation
48
56
  @args = args.to_s
@@ -89,6 +97,7 @@ module Kward
89
97
 
90
98
  # DSL object yielded by `Kward.plugin` blocks.
91
99
  class DSL
100
+ # Creates an object for trusted plugin loading and dispatch.
92
101
  def initialize(registry, path)
93
102
  @registry = registry
94
103
  @path = path
@@ -128,6 +137,7 @@ module Kward
128
137
  end
129
138
  end
130
139
 
140
+ # Mutable singleton guard used while loading trusted plugin files.
131
141
  class << self
132
142
  attr_accessor :loading_registry, :loading_path
133
143
 
@@ -161,6 +171,7 @@ module Kward
161
171
  end
162
172
  end
163
173
 
174
+ # Creates an object for trusted plugin loading and dispatch.
164
175
  def initialize(reserved_commands: [])
165
176
  @reserved_commands = reserved_commands.map(&:to_s)
166
177
  @commands = {}
@@ -170,6 +181,7 @@ module Kward
170
181
  @prompt_context_renderers = []
171
182
  end
172
183
 
184
+ # @return [String, nil] plugin file currently responsible for footer output
173
185
  attr_reader :footer_path
174
186
 
175
187
  def commands
@@ -1,7 +1,9 @@
1
1
  require "fileutils"
2
2
  require "json"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Writes sensitive JSON files with private filesystem permissions.
5
7
  module PrivateFile
6
8
  module_function
7
9
 
@@ -2,8 +2,11 @@ require_relative "../ansi"
2
2
  require_relative "../resources/avatar_kward_logo"
3
3
  require_relative "../resources/pixel_logo"
4
4
 
5
+ # Namespace for the Kward CLI agent runtime.
5
6
  module Kward
7
+ # Startup banner logo and message renderer.
6
8
  class PromptInterface
9
+ # Startup banner rendering data and helpers for the prompt interface.
7
10
  class Banner
8
11
  LOGO_WIDTH = 32
9
12
  LOGO_PIXEL_HEIGHT = 32