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,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
- OPENAI_CONTEXT_WINDOWS = [
74
+ CODEX_CONTEXT_WINDOWS = [
41
75
  [/\Agpt-5\.5/, 400_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,33 @@ module Kward
51
88
  [/\Agpt-4/, 128_000],
52
89
  [/\Agpt-3\.5-turbo/, 16_385]
53
90
  ].freeze
91
+ OPENAI_CONTEXT_WINDOWS = [
92
+ [/\Agpt-5\.5/, 1_050_000],
93
+ [/\Agpt-5\.4-mini/, 400_000],
94
+ [/\Agpt-5\.4/, 1_050_000],
95
+ [/\Agpt-5-mini/, 400_000],
96
+ [/\Agpt-5-codex/, 400_000],
97
+ [/\Agpt-5\.3-codex-spark/, 128_000],
98
+ [/\Agpt-5\.3-codex/, 400_000],
99
+ [/\Agpt-5\.2-codex/, 400_000],
100
+ [/\Agpt-5/, 400_000],
101
+ [/\Agpt-4\.1/, 1_047_576],
102
+ [/\Agpt-4o/, 128_000],
103
+ [/\Ao3/, 200_000],
104
+ [/\Ao4/, 200_000],
105
+ [/\Agpt-4/, 128_000],
106
+ [/\Agpt-3\.5-turbo/, 16_385]
107
+ ].freeze
108
+ ANTHROPIC_CONTEXT_WINDOWS = [
109
+ [/\Aclaude-(?:fable|mythos)-5(?:\z|-)/, 1_000_000],
110
+ [/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
111
+ [/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
112
+ [/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
113
+ [/\Aclaude-(?:haiku|opus|sonnet)-4(?:\z|-)/, 200_000]
114
+ ].freeze
115
+ GEMINI_CONTEXT_WINDOWS = [
116
+ [/\Agemini-(?:2\.5-pro|3(?:\.1)?-pro|3(?:\.5)?-flash)/, 1_048_576]
117
+ ].freeze
54
118
 
55
119
  module_function
56
120
 
@@ -62,11 +126,28 @@ module Kward
62
126
  env["OPENROUTER_MODEL"] || ConfigFiles.config_value(config, "openrouter_model", "model") || DEFAULT_OPENROUTER_MODEL
63
127
  when "Copilot"
64
128
  normalize_copilot_model(env["COPILOT_MODEL"] || ConfigFiles.config_value(config, "copilot_model", "model") || DEFAULT_COPILOT_MODEL)
129
+ when "Anthropic"
130
+ normalize_anthropic_model(env["ANTHROPIC_MODEL"] || ConfigFiles.config_value(config, "anthropic_model", "model") || DEFAULT_ANTHROPIC_MODEL)
65
131
  else
66
132
  env["OPENAI_MODEL"] || ConfigFiles.config_value(config, "openai_model", "model") || DEFAULT_OPENAI_MODEL
67
133
  end
68
134
  end
69
135
 
136
+ def normalize_anthropic_model(id)
137
+ text = id.to_s.strip
138
+ return DEFAULT_ANTHROPIC_MODEL if text.empty?
139
+ text = text.delete_prefix("anthropic/").delete_prefix("claude/")
140
+ {
141
+ "claude-sonnet-4.6" => "claude-sonnet-4-6",
142
+ "claude-sonnet-4.5" => "claude-sonnet-4-5",
143
+ "claude-opus-4.8" => "claude-opus-4-8",
144
+ "claude-opus-4.7" => "claude-opus-4-7",
145
+ "claude-opus-4.6" => "claude-opus-4-6",
146
+ "claude-opus-4.5" => "claude-opus-4-5",
147
+ "claude-haiku-4.5" => "claude-haiku-4-5"
148
+ }.fetch(text, text)
149
+ end
150
+
70
151
  def normalize_copilot_model(id)
71
152
  text = id.to_s.strip
72
153
  return DEFAULT_COPILOT_MODEL if text.empty?
@@ -92,6 +173,8 @@ module Kward
92
173
  env["OPENROUTER_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openrouter_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
93
174
  when "Copilot"
94
175
  env["COPILOT_REASONING_EFFORT"] || ConfigFiles.config_value(config, "copilot_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
176
+ when "Anthropic"
177
+ env["ANTHROPIC_REASONING_EFFORT"] || ConfigFiles.config_value(config, "anthropic_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
95
178
  else
96
179
  env["OPENAI_REASONING_EFFORT"] || ConfigFiles.config_value(config, "openai_reasoning_effort", "reasoning_effort", "thinking_level") || DEFAULT_REASONING_EFFORT
97
180
  end
@@ -101,6 +184,7 @@ module Kward
101
184
  case provider.to_s.downcase
102
185
  when "openrouter" then "OpenRouter"
103
186
  when "copilot" then "Copilot"
187
+ when "anthropic", "claude" then "Anthropic"
104
188
  when "codex", "openai" then "Codex"
105
189
  else provider.to_s
106
190
  end
@@ -110,6 +194,7 @@ module Kward
110
194
  case provider.to_s.downcase
111
195
  when "openrouter" then "openrouter"
112
196
  when "copilot" then "copilot"
197
+ when "anthropic", "claude" then "anthropic"
113
198
  else "codex"
114
199
  end
115
200
  end
@@ -118,6 +203,7 @@ module Kward
118
203
  case provider.to_s.downcase
119
204
  when "openrouter" then "openrouter_model"
120
205
  when "copilot" then "copilot_model"
206
+ when "anthropic", "claude" then "anthropic_model"
121
207
  else "openai_model"
122
208
  end
123
209
  end
@@ -126,6 +212,7 @@ module Kward
126
212
  case provider.to_s.downcase
127
213
  when "openrouter" then "openrouter_reasoning_effort"
128
214
  when "copilot" then "copilot_reasoning_effort"
215
+ when "anthropic", "claude" then "anthropic_reasoning_effort"
129
216
  else "openai_reasoning_effort"
130
217
  end
131
218
  end
@@ -142,18 +229,87 @@ module Kward
142
229
  end
143
230
 
144
231
  def context_window(provider, id)
145
- return nil unless provider == "Codex"
232
+ case provider
233
+ when "Codex"
234
+ pattern_context_window(CODEX_CONTEXT_WINDOWS, id)
235
+ when "OpenRouter"
236
+ openrouter_context_window(id)
237
+ when "Copilot"
238
+ copilot_context_window(id)
239
+ when "Anthropic"
240
+ anthropic_context_window(id)
241
+ end
242
+ end
146
243
 
147
- match = OPENAI_CONTEXT_WINDOWS.find { |pattern, _window| id.to_s.match?(pattern) }
244
+ def pattern_context_window(patterns, id)
245
+ match = patterns.find { |pattern, _window| id.to_s.match?(pattern) }
148
246
  match&.last
149
247
  end
150
248
 
249
+ def openrouter_context_window(id)
250
+ text = id.to_s
251
+ return pattern_context_window(OPENAI_CONTEXT_WINDOWS, text.delete_prefix("openai/")) if text.start_with?("openai/")
252
+ return anthropic_context_window(text.delete_prefix("anthropic/")) if text.start_with?("anthropic/")
253
+ return pattern_context_window(GEMINI_CONTEXT_WINDOWS, text.delete_prefix("google/")) if text.start_with?("google/")
254
+
255
+ nil
256
+ end
257
+
258
+ def copilot_context_window(id)
259
+ text = id.to_s
260
+ pattern_context_window(OPENAI_CONTEXT_WINDOWS, text) ||
261
+ anthropic_context_window(normalize_anthropic_model(text)) ||
262
+ pattern_context_window(GEMINI_CONTEXT_WINDOWS, text)
263
+ end
264
+
265
+ def anthropic_context_window(id)
266
+ pattern_context_window(ANTHROPIC_CONTEXT_WINDOWS, normalize_anthropic_model(id))
267
+ end
268
+
151
269
  def supports_images?(_provider, id)
152
270
  IMAGE_UNSUPPORTED_MODELS.none? { |pattern| id.to_s.match?(pattern) }
153
271
  end
154
272
 
155
273
  def reasoning_supported?(provider, id)
156
- provider == "Codex" || provider == "OpenRouter" || (provider == "Copilot" && id.to_s.match?(/\Agpt-5(?:\.|-|\z)/))
274
+ case provider
275
+ when "Codex", "OpenRouter"
276
+ true
277
+ when "Anthropic"
278
+ !reasoning_effort_choices(provider, id).empty?
279
+ when "Copilot"
280
+ id.to_s.match?(/\Agpt-5(?:\.|-|\z)/)
281
+ else
282
+ false
283
+ end
284
+ end
285
+
286
+ def reasoning_effort_choices(provider, id)
287
+ case provider
288
+ when "Codex", "OpenRouter"
289
+ openai_reasoning_effort_choices(id)
290
+ when "Anthropic"
291
+ anthropic_reasoning_effort_choices(id)
292
+ when "Copilot"
293
+ id.to_s.match?(/\Agpt-5(?:\.|-|\z)/) ? openai_reasoning_effort_choices(id) : []
294
+ else
295
+ []
296
+ end
297
+ end
298
+
299
+ def openai_reasoning_effort_choices(id)
300
+ text = id.to_s.delete_prefix("openai/")
301
+ return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)
302
+
303
+ OPENAI_REASONING_EFFORT_CHOICES
304
+ end
305
+
306
+ def anthropic_reasoning_effort_choices(id)
307
+ text = normalize_anthropic_model(id)
308
+ return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
309
+ return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
310
+ return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)
311
+
312
+ []
157
313
  end
158
314
 
159
315
  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,36 +171,145 @@ 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
+ "fetch_content" => "WebFetch",
243
+ "ask_user_question" => "AskUserQuestion"
244
+ }[name.to_s]
245
+ return mapped if mapped
246
+
247
+ lookup = CLAUDE_CODE_TOOL_NAMES.find { |tool_name| tool_name.downcase == name.to_s.downcase }
248
+ lookup || name.to_s
249
+ end
250
+
251
+ def normalize_anthropic_tool_call_id(id)
252
+ id.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")[0, 64]
253
+ end
254
+
255
+ def parse_tool_arguments(arguments)
256
+ return arguments if arguments.is_a?(Hash)
257
+
258
+ JSON.parse(arguments.to_s.empty? ? "{}" : arguments.to_s)
259
+ rescue JSON::ParserError
260
+ {}
261
+ end
262
+
263
+ def anthropic_tool_schema(tool)
264
+ function = tool[:function] || tool["function"] || {}
265
+ schema = function[:parameters] || function["parameters"] || {}
266
+ {
267
+ name: claude_code_tool_name(function[:name] || function["name"]),
268
+ description: function[:description] || function["description"] || "",
269
+ input_schema: {
270
+ type: "object",
271
+ properties: schema[:properties] || schema["properties"] || {},
272
+ required: schema[:required] || schema["required"] || []
273
+ },
274
+ eager_input_streaming: true
275
+ }
276
+ end
277
+
124
278
  def codex_messages(messages)
125
279
  instructions = []
126
280
  input = []
127
281
 
128
282
  messages.each do |message|
129
- role = message[:role] || message["role"]
130
- content = message[:content] || message["content"] || ""
283
+ role = MessageAccess.role(message)
284
+ content = MessageAccess.content(message) || ""
131
285
  case role.to_s
132
286
  when "system"
133
287
  instructions << plain_content(content).to_s
134
288
  when "tool", "toolResult"
135
289
  input << {
136
290
  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",
291
+ call_id: MessageAccess.tool_call_id(message) || MessageAccess.tool_name(message) || "tool-call",
138
292
  output: plain_content(content).to_s
139
293
  }
140
294
  when "assistant"
141
- content = plain_content(content)
142
- input << codex_message("assistant", content.to_s) unless content.to_s.empty?
143
- (message[:tool_calls] || message["tool_calls"] || []).each do |tool_call|
144
- function = tool_call[:function] || tool_call["function"] || {}
145
- input << {
146
- type: "function_call",
147
- call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
148
- name: function[:name] || function["name"],
149
- arguments: function[:arguments] || function["arguments"] || "{}"
150
- }
295
+ response_items = codex_replay_response_items(message)
296
+ if response_items.empty?
297
+ content = plain_content(content)
298
+ input << codex_message("assistant", content.to_s) unless content.to_s.empty?
299
+ MessageAccess.tool_calls(message).each do |tool_call|
300
+ function = tool_call[:function] || tool_call["function"] || {}
301
+ input << {
302
+ type: "function_call",
303
+ call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
304
+ name: function[:name] || function["name"],
305
+ arguments: function[:arguments] || function["arguments"] || "{}"
306
+ }
307
+ end
308
+ else
309
+ input.concat(response_items)
151
310
  end
152
311
  when "compactionSummary"
153
- summary = message[:summary] || message["summary"] || content
312
+ summary = MessageAccess.summary(message) || content
154
313
  input << codex_message("assistant", summary.to_s) unless summary.to_s.empty?
155
314
  else
156
315
  input << codex_user_message(content)
@@ -179,6 +338,79 @@ module Kward
179
338
  { type: "message", role: role, content: [{ type: type, text: text }] }
180
339
  end
181
340
 
341
+ def codex_replay_response_items(message)
342
+ items = MessageAccess.response_items(message)
343
+ return [] if items.empty?
344
+
345
+ items.filter_map { |item| codex_replay_response_item(item) }
346
+ end
347
+
348
+ def codex_replay_response_item(item)
349
+ return nil unless item.is_a?(Hash)
350
+
351
+ case item[:type] || item["type"]
352
+ when "reasoning"
353
+ codex_replay_reasoning_item(item)
354
+ when "message"
355
+ codex_replay_message_item(item)
356
+ when "function_call", "custom_tool_call"
357
+ codex_replay_tool_call_item(item)
358
+ end
359
+ end
360
+
361
+ def codex_replay_reasoning_item(item)
362
+ result = { type: "reasoning" }
363
+ summary = item[:summary] || item["summary"]
364
+ content = item[:content] || item["content"]
365
+ encrypted_content = item[:encrypted_content] || item["encrypted_content"]
366
+ result[:summary] = summary if summary.is_a?(Array)
367
+ result[:content] = content if content.is_a?(Array)
368
+ result[:encrypted_content] = encrypted_content if encrypted_content
369
+ result
370
+ end
371
+
372
+ def codex_replay_message_item(item)
373
+ content = item[:content] || item["content"]
374
+ return nil unless content.is_a?(Array)
375
+
376
+ result = { type: "message", role: item[:role] || item["role"] || "assistant", content: codex_replay_message_content(content) }
377
+ phase = item[:phase] || item["phase"]
378
+ result[:phase] = phase if phase
379
+ result
380
+ end
381
+
382
+ def codex_replay_message_content(content)
383
+ content.filter_map do |part|
384
+ next unless part.is_a?(Hash)
385
+
386
+ type = part[:type] || part["type"]
387
+ next unless ["output_text", "text", "refusal"].include?(type)
388
+
389
+ replay_part = { type: type }
390
+ text = part[:text] || part["text"]
391
+ refusal = part[:refusal] || part["refusal"]
392
+ replay_part[:text] = text.to_s if text || type != "refusal"
393
+ replay_part[:refusal] = refusal.to_s if refusal
394
+ annotations = part[:annotations] || part["annotations"]
395
+ replay_part[:annotations] = annotations if annotations.is_a?(Array)
396
+ replay_part
397
+ end
398
+ end
399
+
400
+ def codex_replay_tool_call_item(item)
401
+ type = item[:type] || item["type"]
402
+ result = { type: type }
403
+ call_id = item[:call_id] || item["call_id"]
404
+ name = item[:name] || item["name"]
405
+ arguments = item[:arguments] || item["arguments"]
406
+ input = item[:input] || item["input"]
407
+ result[:call_id] = call_id if call_id
408
+ result[:name] = name if name
409
+ result[:arguments] = arguments if arguments
410
+ result[:input] = input if input
411
+ result
412
+ end
413
+
182
414
  def plain_content(content)
183
415
  return content unless content.is_a?(Array)
184
416
 
@@ -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