kward 0.67.0 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +125 -31
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -1
@@ -1,13 +1,25 @@
1
1
  require_relative "../config_files"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Static and configured model metadata helpers.
4
6
  module ModelInfo
5
7
  DEFAULT_OPENAI_MODEL = "gpt-5.5"
6
8
  DEFAULT_OPENROUTER_MODEL = "openai/gpt-5.5"
7
9
  DEFAULT_COPILOT_MODEL = "gpt-5-mini"
10
+ DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"
8
11
  DEFAULT_REASONING_EFFORT = "medium"
9
12
  OPENAI_MODEL_CHOICES = %w[gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
10
13
  OPENROUTER_MODEL_CHOICES = OPENAI_MODEL_CHOICES.map { |model| "openai/#{model}" }.freeze
14
+ ANTHROPIC_MODEL_CHOICES = %w[
15
+ claude-opus-4-8
16
+ claude-sonnet-4-6
17
+ claude-haiku-4-5
18
+ claude-opus-4-7
19
+ claude-opus-4-6
20
+ claude-opus-4-5
21
+ claude-sonnet-4-5
22
+ ].freeze
11
23
  COPILOT_MODEL_CHOICES = %w[
12
24
  gpt-5-mini
13
25
  gpt-5.3-codex
@@ -32,13 +44,38 @@ module Kward
32
44
  ["high", "High"],
33
45
  ["xhigh", "Extra High"]
34
46
  ].freeze
47
+ OPENAI_REASONING_EFFORT_CHOICES = [
48
+ ["none", "None"],
49
+ *REASONING_EFFORT_CHOICES
50
+ ].freeze
51
+ ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES = [
52
+ ["low", "Low"],
53
+ ["medium", "Medium"],
54
+ ["high", "High"],
55
+ ["xhigh", "Extra High"],
56
+ ["max", "Max"]
57
+ ].freeze
58
+ ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES = [
59
+ ["low", "Low"],
60
+ ["medium", "Medium"],
61
+ ["high", "High"],
62
+ ["max", "Max"]
63
+ ].freeze
64
+ ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES = [
65
+ ["low", "Low"],
66
+ ["medium", "Medium"],
67
+ ["high", "High"]
68
+ ].freeze
35
69
 
36
70
  IMAGE_UNSUPPORTED_MODELS = [
37
71
  /(?:\A|\/)gpt-5\.3-codex-spark\z/
38
72
  ].freeze
39
73
 
40
74
  OPENAI_CONTEXT_WINDOWS = [
41
- [/\Agpt-5\.5/, 400_000],
75
+ [/\Agpt-5\.5/, 1_050_000],
76
+ [/\Agpt-5\.4-mini/, 400_000],
77
+ [/\Agpt-5\.4/, 1_050_000],
78
+ [/\Agpt-5-mini/, 400_000],
42
79
  [/\Agpt-5-codex/, 400_000],
43
80
  [/\Agpt-5\.3-codex-spark/, 128_000],
44
81
  [/\Agpt-5\.3-codex/, 400_000],
@@ -51,6 +88,16 @@ module Kward
51
88
  [/\Agpt-4/, 128_000],
52
89
  [/\Agpt-3\.5-turbo/, 16_385]
53
90
  ].freeze
91
+ ANTHROPIC_CONTEXT_WINDOWS = [
92
+ [/\Aclaude-(?:fable|mythos)-5(?:\z|-)/, 1_000_000],
93
+ [/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
94
+ [/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
95
+ [/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
96
+ [/\Aclaude-(?:haiku|opus|sonnet)-4(?:\z|-)/, 200_000]
97
+ ].freeze
98
+ GEMINI_CONTEXT_WINDOWS = [
99
+ [/\Agemini-(?:2\.5-pro|3(?:\.1)?-pro|3(?:\.5)?-flash)/, 1_048_576]
100
+ ].freeze
54
101
 
55
102
  module_function
56
103
 
@@ -62,11 +109,28 @@ module Kward
62
109
  env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model") || DEFAULT_OPENROUTER_MODEL
63
110
  when "Copilot"
64
111
  normalize_copilot_model(env["COPILOT_MODEL"] || ConfigFiles.config_value(config, "copilot_model", "model") || DEFAULT_COPILOT_MODEL)
112
+ when "Anthropic"
113
+ normalize_anthropic_model(env["ANTHROPIC_MODEL"] || ConfigFiles.config_value(config, "anthropic_model", "model") || DEFAULT_ANTHROPIC_MODEL)
65
114
  else
66
115
  env["OPENAI_MODEL"] || ConfigFiles.config_value(config, "openai_model", "model") || DEFAULT_OPENAI_MODEL
67
116
  end
68
117
  end
69
118
 
119
+ def normalize_anthropic_model(id)
120
+ text = id.to_s.strip
121
+ return DEFAULT_ANTHROPIC_MODEL if text.empty?
122
+ text = text.delete_prefix("anthropic/").delete_prefix("claude/")
123
+ {
124
+ "claude-sonnet-4.6" => "claude-sonnet-4-6",
125
+ "claude-sonnet-4.5" => "claude-sonnet-4-5",
126
+ "claude-opus-4.8" => "claude-opus-4-8",
127
+ "claude-opus-4.7" => "claude-opus-4-7",
128
+ "claude-opus-4.6" => "claude-opus-4-6",
129
+ "claude-opus-4.5" => "claude-opus-4-5",
130
+ "claude-haiku-4.5" => "claude-haiku-4-5"
131
+ }.fetch(text, text)
132
+ end
133
+
70
134
  def normalize_copilot_model(id)
71
135
  text = id.to_s.strip
72
136
  return DEFAULT_COPILOT_MODEL if text.empty?
@@ -92,6 +156,8 @@ module Kward
92
156
  env["OPENROUTER_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openrouter_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
93
157
  when "Copilot"
94
158
  env["COPILOT_REASONING_EFFORT"] || ConfigFiles.config_value(config, "copilot_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
159
+ when "Anthropic"
160
+ env["ANTHROPIC_REASONING_EFFORT"] || ConfigFiles.config_value(config, "anthropic_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
95
161
  else
96
162
  env["OPENAI_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openai_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
97
163
  end
@@ -101,6 +167,7 @@ module Kward
101
167
  case provider.to_s.downcase
102
168
  when "openrouter" then "OpenRouter"
103
169
  when "copilot" then "Copilot"
170
+ when "anthropic", "claude" then "Anthropic"
104
171
  when "codex", "openai" then "Codex"
105
172
  else provider.to_s
106
173
  end
@@ -110,6 +177,7 @@ module Kward
110
177
  case provider.to_s.downcase
111
178
  when "openrouter" then "openrouter"
112
179
  when "copilot" then "copilot"
180
+ when "anthropic", "claude" then "anthropic"
113
181
  else "codex"
114
182
  end
115
183
  end
@@ -118,6 +186,7 @@ module Kward
118
186
  case provider.to_s.downcase
119
187
  when "openrouter" then "openrouter_model"
120
188
  when "copilot" then "copilot_model"
189
+ when "anthropic", "claude" then "anthropic_model"
121
190
  else "openai_model"
122
191
  end
123
192
  end
@@ -126,6 +195,7 @@ module Kward
126
195
  case provider.to_s.downcase
127
196
  when "openrouter" then "openrouter_reasoning_effort"
128
197
  when "copilot" then "copilot_reasoning_effort"
198
+ when "anthropic", "claude" then "anthropic_reasoning_effort"
129
199
  else "openai_reasoning_effort"
130
200
  end
131
201
  end
@@ -142,18 +212,87 @@ module Kward
142
212
  end
143
213
 
144
214
  def context_window(provider, id)
145
- return nil unless provider == "Codex"
215
+ case provider
216
+ when "Codex"
217
+ pattern_context_window(OPENAI_CONTEXT_WINDOWS, id)
218
+ when "OpenRouter"
219
+ openrouter_context_window(id)
220
+ when "Copilot"
221
+ copilot_context_window(id)
222
+ when "Anthropic"
223
+ anthropic_context_window(id)
224
+ end
225
+ end
146
226
 
147
- match = OPENAI_CONTEXT_WINDOWS.find { |pattern, _window| id.to_s.match?(pattern) }
227
+ def pattern_context_window(patterns, id)
228
+ match = patterns.find { |pattern, _window| id.to_s.match?(pattern) }
148
229
  match&.last
149
230
  end
150
231
 
232
+ def openrouter_context_window(id)
233
+ text = id.to_s
234
+ return pattern_context_window(OPENAI_CONTEXT_WINDOWS, text.delete_prefix("openai/")) if text.start_with?("openai/")
235
+ return anthropic_context_window(text.delete_prefix("anthropic/")) if text.start_with?("anthropic/")
236
+ return pattern_context_window(GEMINI_CONTEXT_WINDOWS, text.delete_prefix("google/")) if text.start_with?("google/")
237
+
238
+ nil
239
+ end
240
+
241
+ def copilot_context_window(id)
242
+ text = id.to_s
243
+ pattern_context_window(OPENAI_CONTEXT_WINDOWS, text) ||
244
+ anthropic_context_window(normalize_anthropic_model(text)) ||
245
+ pattern_context_window(GEMINI_CONTEXT_WINDOWS, text)
246
+ end
247
+
248
+ def anthropic_context_window(id)
249
+ pattern_context_window(ANTHROPIC_CONTEXT_WINDOWS, normalize_anthropic_model(id))
250
+ end
251
+
151
252
  def supports_images?(_provider, id)
152
253
  IMAGE_UNSUPPORTED_MODELS.none? { |pattern| id.to_s.match?(pattern) }
153
254
  end
154
255
 
155
256
  def reasoning_supported?(provider, id)
156
- provider == "Codex" || provider == "OpenRouter" || (provider == "Copilot" && id.to_s.match?(/\Agpt-5(?:\.|-|\z)/))
257
+ case provider
258
+ when "Codex", "OpenRouter"
259
+ true
260
+ when "Anthropic"
261
+ !reasoning_effort_choices(provider, id).empty?
262
+ when "Copilot"
263
+ id.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
264
+ else
265
+ false
266
+ end
267
+ end
268
+
269
+ def reasoning_effort_choices(provider, id)
270
+ case provider
271
+ when "Codex", "OpenRouter"
272
+ openai_reasoning_effort_choices(id)
273
+ when "Anthropic"
274
+ anthropic_reasoning_effort_choices(id)
275
+ when "Copilot"
276
+ id.to_s.match?(/\Agpt-5(?:\.|-|\z)/) ? openai_reasoning_effort_choices(id) : []
277
+ else
278
+ []
279
+ end
280
+ end
281
+
282
+ def openai_reasoning_effort_choices(id)
283
+ text = id.to_s.delete_prefix("openai/")
284
+ return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)
285
+
286
+ OPENAI_REASONING_EFFORT_CHOICES
287
+ end
288
+
289
+ def anthropic_reasoning_effort_choices(id)
290
+ text = normalize_anthropic_model(id)
291
+ return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
292
+ return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
293
+ return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)
294
+
295
+ []
157
296
  end
158
297
 
159
298
  def normalize(model, current_provider: nil, current_model: nil, current_reasoning_effort: nil)
@@ -1,14 +1,28 @@
1
+ require "json"
1
2
  require_relative "../image_attachments"
3
+ require_relative "../message_access"
2
4
  require_relative "model_info"
3
5
 
6
+ # Namespace for the Kward CLI agent runtime.
4
7
  module Kward
8
+ # Converts Kward conversation/tool data into provider-specific request shapes.
9
+ #
10
+ # This module is mixed into `Client` because it needs provider configuration
11
+ # helpers such as `model_for` and `reasoning_effort`. Keep pure transcript and
12
+ # schema transformations here; keep network transport, credentials, retries,
13
+ # and telemetry in `Client`.
14
+ #
15
+ # Kward stores one internal transcript shape, then projects it into either
16
+ # Chat Completions-style messages or Responses/Codex input items. Preserve both
17
+ # symbol and string key handling through `MessageAccess` so restored sessions,
18
+ # tests, and RPC-normalized messages keep working.
5
19
  module ModelPayloads
6
20
  private
7
21
 
8
22
  def request_payload(provider, messages, tools, max_tokens: nil, model: nil, reasoning: nil)
9
23
  parts = build_context_parts(provider, messages, tools, model: model)
10
24
  payload = { model: parts[:model], messages: parts[:messages], tools: parts[:tools] }
11
- payload[:reasoning] = { effort: reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
25
+ payload[:reasoning] = { effort: reasoning || reasoning_effort("OpenRouter") } if provider == "OpenRouter" && reasoning != false
12
26
  payload[:max_tokens] = max_tokens.to_i if max_tokens.to_i.positive?
13
27
  payload
14
28
  end
@@ -22,18 +36,18 @@ module Kward
22
36
 
23
37
  def messages_include_images?(messages)
24
38
  messages.any? do |message|
25
- content = message[:content] || message["content"]
39
+ content = MessageAccess.content(message)
26
40
  content.is_a?(Array) && content.any? { |part| (part[:type] || part["type"]).to_s == "image" }
27
41
  end
28
42
  end
29
43
 
30
44
  def chat_messages(messages)
31
45
  messages.map do |message|
32
- role = message[:role] || message["role"]
33
- content = message[:content] || message["content"]
46
+ role = MessageAccess.role(message)
47
+ content = MessageAccess.content(message)
34
48
  case role.to_s
35
49
  when "compactionSummary"
36
- { role: "assistant", content: message[:summary] || message["summary"] || content.to_s }
50
+ { role: "assistant", content: MessageAccess.summary(message) || content.to_s }
37
51
  when "assistant"
38
52
  api_message(message, role: "assistant", content: content.is_a?(Array) ? plain_content(content) : content, keys: ["tool_calls", :tool_calls, "name", :name])
39
53
  when "toolResult"
@@ -51,7 +65,7 @@ module Kward
51
65
  def api_message(message, role:, content:, keys: [])
52
66
  result = { role: role, content: content }
53
67
  keys.each_slice(2) do |string_key, symbol_key|
54
- value = message[string_key] || message[symbol_key]
68
+ value = MessageAccess.value(message, string_key) || MessageAccess.value(message, symbol_key)
55
69
  next if value.nil?
56
70
 
57
71
  target_key = case string_key.to_s
@@ -75,6 +89,27 @@ module Kward
75
89
  end
76
90
  end
77
91
 
92
+ def anthropic_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
93
+ parts = build_context_parts("Anthropic", messages, tools, model: model)
94
+ system = [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }]
95
+ system << { type: "text", text: parts[:system] } unless parts[:system].to_s.empty?
96
+ payload = {
97
+ model: parts[:model],
98
+ system: system,
99
+ messages: parts[:messages],
100
+ max_tokens: max_tokens.to_i.positive? ? max_tokens.to_i : 16_384,
101
+ stream: true
102
+ }
103
+ payload[:tools] = parts[:tools] unless parts[:tools].empty?
104
+ if reasoning != false
105
+ payload[:thinking] = { type: "adaptive", display: "summarized" }
106
+ payload[:output_config] = { effort: reasoning || reasoning_effort("Anthropic") }
107
+ else
108
+ payload[:thinking] = { type: "disabled" }
109
+ end
110
+ payload
111
+ end
112
+
78
113
  def codex_payload(messages, tools, max_tokens: nil, model: nil, reasoning: nil)
79
114
  parts = build_context_parts("Codex", messages, tools, model: model)
80
115
  payload = {
@@ -88,12 +123,27 @@ module Kward
88
123
  store: false,
89
124
  include: []
90
125
  }
91
- payload[:reasoning] = { effort: reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
126
+ payload[:reasoning] = { effort: reasoning || reasoning_effort("Codex"), summary: "auto" } unless reasoning == false
92
127
  payload
93
128
  end
94
129
 
130
+ # Builds provider-neutral context parts before final JSON serialization.
131
+ #
132
+ # Codex and Copilot Responses use `instructions` plus typed `input` items;
133
+ # OpenRouter and Copilot chat use OpenAI-compatible `messages`. Callers should
134
+ # prefer this method when showing context usage or debugging provider payloads
135
+ # so every frontend sees the same conversion rules.
95
136
  def build_context_parts(provider, messages, tools, model: nil)
96
- if provider == "CopilotResponses"
137
+ if provider == "Anthropic"
138
+ system, anthropic_messages = anthropic_messages(messages)
139
+ {
140
+ provider: provider,
141
+ model: model_for(provider, override_model: model),
142
+ system: system,
143
+ messages: anthropic_messages,
144
+ tools: tools.map { |tool| anthropic_tool_schema(tool) }
145
+ }
146
+ elsif provider == "CopilotResponses"
97
147
  instructions, input = codex_messages(messages)
98
148
  {
99
149
  provider: provider,
@@ -121,26 +171,129 @@ module Kward
121
171
  end
122
172
  end
123
173
 
174
+ def anthropic_messages(messages)
175
+ system = []
176
+ output = []
177
+ messages.each do |message|
178
+ role = MessageAccess.role(message)
179
+ content = MessageAccess.content(message) || ""
180
+ case role.to_s
181
+ when "system"
182
+ system << plain_content(content).to_s
183
+ when "assistant"
184
+ blocks = []
185
+ text = plain_content(content).to_s
186
+ blocks << { type: "text", text: text } unless text.empty?
187
+ MessageAccess.tool_calls(message).each do |tool_call|
188
+ function = tool_call[:function] || tool_call["function"] || {}
189
+ name = function[:name] || function["name"]
190
+ arguments = function[:arguments] || function["arguments"] || "{}"
191
+ blocks << {
192
+ type: "tool_use",
193
+ id: normalize_anthropic_tool_call_id(tool_call[:id] || tool_call["id"] || "call_#{name}"),
194
+ name: claude_code_tool_name(name),
195
+ input: parse_tool_arguments(arguments)
196
+ }
197
+ end
198
+ output << { role: "assistant", content: blocks } unless blocks.empty?
199
+ when "tool", "toolResult"
200
+ output << {
201
+ role: "user",
202
+ content: [{
203
+ type: "tool_result",
204
+ tool_use_id: normalize_anthropic_tool_call_id(MessageAccess.tool_call_id(message) || MessageAccess.tool_name(message) || "tool-call"),
205
+ content: plain_content(content).to_s
206
+ }]
207
+ }
208
+ when "compactionSummary"
209
+ summary = MessageAccess.summary(message) || content
210
+ output << { role: "assistant", content: [{ type: "text", text: summary.to_s }] } unless summary.to_s.empty?
211
+ else
212
+ output << { role: "user", content: anthropic_user_content(content) }
213
+ end
214
+ end
215
+ [system.join("\n\n"), output]
216
+ end
217
+
218
+ def anthropic_user_content(content)
219
+ return content.to_s unless content.is_a?(Array)
220
+
221
+ content.filter_map do |part|
222
+ type = part[:type] || part["type"]
223
+ if type == "text"
224
+ { type: "text", text: part[:text] || part["text"] || "" }
225
+ elsif type == "image"
226
+ mime_type, data = ImageAttachments.data_url(part).split(",", 2)
227
+ media_type = mime_type.to_s[/data:([^;]+)/, 1] || "image/png"
228
+ { type: "image", source: { type: "base64", media_type: media_type, data: data.to_s } }
229
+ end
230
+ end
231
+ end
232
+
233
+ CLAUDE_CODE_TOOL_NAMES = %w[Read Write Edit Bash Grep Glob AskUserQuestion EnterPlanMode ExitPlanMode KillShell NotebookEdit Skill Task TaskOutput TodoWrite WebFetch WebSearch].freeze
234
+
235
+ def claude_code_tool_name(name)
236
+ mapped = {
237
+ "read_file" => "Read",
238
+ "write_file" => "Write",
239
+ "edit_file" => "Edit",
240
+ "run_shell_command" => "Bash",
241
+ "web_search" => "WebSearch",
242
+ "ask_user_question" => "AskUserQuestion"
243
+ }[name.to_s]
244
+ return mapped if mapped
245
+
246
+ lookup = CLAUDE_CODE_TOOL_NAMES.find { |tool_name| tool_name.downcase == name.to_s.downcase }
247
+ lookup || name.to_s
248
+ end
249
+
250
+ def normalize_anthropic_tool_call_id(id)
251
+ id.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")[0, 64]
252
+ end
253
+
254
+ def parse_tool_arguments(arguments)
255
+ return arguments if arguments.is_a?(Hash)
256
+
257
+ JSON.parse(arguments.to_s.empty? ? "{}" : arguments.to_s)
258
+ rescue JSON::ParserError
259
+ {}
260
+ end
261
+
262
+ def anthropic_tool_schema(tool)
263
+ function = tool[:function] || tool["function"] || {}
264
+ schema = function[:parameters] || function["parameters"] || {}
265
+ {
266
+ name: claude_code_tool_name(function[:name] || function["name"]),
267
+ description: function[:description] || function["description"] || "",
268
+ input_schema: {
269
+ type: "object",
270
+ properties: schema[:properties] || schema["properties"] || {},
271
+ required: schema[:required] || schema["required"] || []
272
+ },
273
+ eager_input_streaming: true
274
+ }
275
+ end
276
+
124
277
  def codex_messages(messages)
125
278
  instructions = []
126
279
  input = []
127
280
 
128
281
  messages.each do |message|
129
- role = message[:role] || message["role"]
130
- content = message[:content] || message["content"] || ""
282
+ role = MessageAccess.role(message)
283
+ content = MessageAccess.content(message) || ""
131
284
  case role.to_s
132
285
  when "system"
133
286
  instructions << plain_content(content).to_s
134
287
  when "tool", "toolResult"
135
288
  input << {
136
289
  type: "function_call_output",
137
- call_id: message[:tool_call_id] || message["tool_call_id"] || message[:toolCallId] || message["toolCallId"] || message[:name] || message["name"] || message[:toolName] || message["toolName"] || "tool-call",
290
+ call_id: MessageAccess.tool_call_id(message) || MessageAccess.tool_name(message) || "tool-call",
138
291
  output: plain_content(content).to_s
139
292
  }
140
293
  when "assistant"
141
294
  content = plain_content(content)
142
295
  input << codex_message("assistant", content.to_s) unless content.to_s.empty?
143
- (message[:tool_calls] || message["tool_calls"] || []).each do |tool_call|
296
+ MessageAccess.tool_calls(message).each do |tool_call|
144
297
  function = tool_call[:function] || tool_call["function"] || {}
145
298
  input << {
146
299
  type: "function_call",
@@ -150,7 +303,7 @@ module Kward
150
303
  }
151
304
  end
152
305
  when "compactionSummary"
153
- summary = message[:summary] || message["summary"] || content
306
+ summary = MessageAccess.summary(message) || content
154
307
  input << codex_message("assistant", summary.to_s) unless summary.to_s.empty?
155
308
  else
156
309
  input << codex_user_message(content)
@@ -1,4 +1,6 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Formats retry status messages for model-provider requests.
2
4
  module RetryMessage
3
5
  module_function
4
6
 
@@ -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,121 @@ 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 "AskUserQuestion" then "ask_user_question"
195
+ else name.to_s
196
+ end
197
+ end
198
+
70
199
  def merge_streaming_tool_call(tool_calls, delta)
71
200
  index = (delta["index"] || tool_calls.length).to_i
72
201
  tool_calls[index] ||= { "id" => nil, "type" => "function", "function" => { "name" => "", "arguments" => "" } }
@@ -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