kward 0.75.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 063b8f53ff1a13adc3171af1201b12dcbbd014b59edf37c61f963614ceb79ea5
4
- data.tar.gz: db3f8715d8e648326a3d1dbf746636092dca709addadc6e629acec11016d5e13
3
+ metadata.gz: bd9108129c3abd1aebeb3d1adb3e9011fbaf4e96c60a4f2ff55505497ff4fd0c
4
+ data.tar.gz: 14177ca73389b1e2df9239706f20b879d22322ec89e72f31a81aa573915b0b91
5
5
  SHA512:
6
- metadata.gz: c483b653b15e10415ec322dfea7f0e3a23df7e416260eaaf91d66cb711348573f4ecc642db07b55786f0f01389f5421549293a5c2ead483947152d5702909572
7
- data.tar.gz: faa9827cdc03b0d48fdee98f4ecf40b7157ed8989800236226adc138d8ecabe52855a67d472c85174d0a3365527ed4aab9ec63ded872a61ba9e85c62b08a4c8b
6
+ metadata.gz: 787448090be2990a36e9d6d5c1911ce3bc5f6031f7c946a6d5d3edde3b78a43742227df6257d64c04e15def86a579e91366db602187200f1c08d241ae705d3b4
7
+ data.tar.gz: 9a18e14824b3f38d2d1e030adf706629a4e797cd49a8aa1c23786fb53d174e4e81a3d1462c6e7a15de4efa1df517f0aaed7eba89fab1d94f162ba066fff59743
data/CHANGELOG.md CHANGED
@@ -4,6 +4,18 @@ All notable changes to Kward will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.76.0] - 2026-07-10
8
+
9
+ ### Added
10
+
11
+ - Added GPT-5.6 Sol, Terra, and Luna to the OpenAI/Codex model choices and made GPT-5.6 Sol the default OpenAI/Codex model.
12
+ - Added Claude Sonnet 5 and Fable 5 to the Anthropic model choices and made Claude Sonnet 5 the default Anthropic model.
13
+
14
+ ### Fixed
15
+
16
+ - Fixed Codex reasoning summaries so separate summary messages render as separate blocks with one empty line between transcript blocks.
17
+ - Fixed interactive update notices so stale checks refresh before the startup banner and new configs expose the `updates.check` setting.
18
+
7
19
  ## [0.75.0] - 2026-07-09
8
20
 
9
21
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- kward (0.75.0)
4
+ kward (0.76.0)
5
5
  base64
6
6
  nokogiri
7
7
  tiktoken_ruby
@@ -146,7 +146,7 @@ CHECKSUMS
146
146
  html-proofer (5.2.1) sha256=fdd958a7cbf9c3255fb96fe7cfc4e611f64e2706e469488a3326309ad007d2fd
147
147
  io-event (1.16.2) sha256=9f9cb0a96ea5c3850a672606c65f27bc96d7621399ef6196acbfe2be0cd1279c
148
148
  json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
149
- kward (0.75.0)
149
+ kward (0.76.0)
150
150
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
151
151
  metrics (0.15.0) sha256=61ded5bac95118e995b1bc9ed4a5f19bc9814928a312a85b200abbdac9039072
152
152
  minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
data/doc/configuration.md CHANGED
@@ -9,7 +9,7 @@ Small examples:
9
9
  ```json
10
10
  {
11
11
  "provider": "openrouter",
12
- "openrouter_model": "openai/gpt-5.5"
12
+ "openrouter_model": "openai/gpt-5.6-sol"
13
13
  }
14
14
  ```
15
15
 
@@ -36,6 +36,16 @@ See [MCP servers](mcp.md) for connecting local Model Context Protocol servers su
36
36
 
37
37
  By default, Kward loads user-level skills but skips project-level skills from the workspace. Set `skills.trust_project` to `true`, or use `/settings` → `Tools & Search` → `Trust project skills`, when you trust the repository's `.kward/skills` or `.agents/skills` directories. See [Skills](skills.md) for skill locations and examples.
38
38
 
39
+ ```json
40
+ {
41
+ "updates": {
42
+ "check": true
43
+ }
44
+ }
45
+ ```
46
+
47
+ Kward checks RubyGems for newer `kward` versions on the interactive startup screen. Results are cached under `~/.kward/cache/update_check.json` so startup does not hit RubyGems every time. Set `updates.check` to `false`, or set `KWARD_DISABLE_UPDATE_CHECK=1`, to disable the check.
48
+
39
49
  ```json
40
50
  {
41
51
  "memory": {
@@ -131,10 +141,10 @@ Model settings:
131
141
 
132
142
  ```json
133
143
  {
134
- "model": "gpt-5.5",
135
- "openai_model": "gpt-5.5",
136
- "openrouter_model": "openai/gpt-5.5",
137
- "anthropic_model": "claude-sonnet-4-5",
144
+ "model": "gpt-5.6-sol",
145
+ "openai_model": "gpt-5.6-sol",
146
+ "openrouter_model": "openai/gpt-5.6-sol",
147
+ "anthropic_model": "claude-sonnet-5",
138
148
  "copilot_model": "gpt-5-mini",
139
149
  "reasoning_effort": "medium",
140
150
  "openai_reasoning_effort": "medium",
@@ -151,12 +161,14 @@ Set `codex_show_raw_reasoning` to `true` to display raw Codex `reasoning_text` w
151
161
 
152
162
  Defaults:
153
163
 
154
- - OpenAI/Codex: `gpt-5.5`
155
- - OpenRouter: `openai/gpt-5.5`
156
- - Anthropic: `claude-sonnet-4-6`
164
+ - OpenAI/Codex: `gpt-5.6-sol`
165
+ - OpenRouter: `openai/gpt-5.6-sol`
166
+ - Anthropic: `claude-sonnet-5`
157
167
  - Copilot: `gpt-5-mini`
158
168
  - Reasoning effort: `medium`
159
169
 
170
+ The Anthropic model choices include `claude-fable-5`, but Fable availability depends on the logged-in account and organization. Selecting it without access returns an Anthropic provider error.
171
+
160
172
  The interactive `/model` picker reads cached OpenRouter models when available. Run `kward openrouter refresh` to fetch text-capable models available to the configured OpenRouter API key and cache them under `~/.kward/cache/openrouter_models.json`. Run `kward openrouter list` to inspect the cached model ids.
161
173
 
162
174
  ## Environment overrides
data/doc/plugins.md CHANGED
@@ -199,6 +199,7 @@ Event payloads are read-only copies. Handler errors are caught and printed as wa
199
199
  Common event types include:
200
200
 
201
201
  - `reasoning_delta`
202
+ - `reasoning_boundary`
202
203
  - `assistant_delta`
203
204
  - `assistant_message`
204
205
  - `model_retry`
data/doc/rpc.md CHANGED
@@ -357,6 +357,7 @@ Known event types:
357
357
  - `steeringApplied`
358
358
  - `turnStarted`
359
359
  - `reasoningDelta`
360
+ - `reasoningBoundary`
360
361
  - `assistantDelta`
361
362
  - `assistantMessage`
362
363
  - `modelRetry`
@@ -368,6 +369,8 @@ Known event types:
368
369
  - `error`
369
370
  - `turnFinished`
370
371
 
372
+ `reasoningBoundary` has an empty payload and marks the end of one reasoning summary message, allowing clients to render the next `reasoningDelta` as a separate block.
373
+
371
374
  Lifecycle payloads include `status` for `turnQueued`, `turnStarted`, and `turnFinished`. Exactly one terminal `turnFinished` is emitted per turn with `status` set to `completed`, `failed`, or `canceled`; failed turns include a sanitized `{ "message": "...", "code": "...", "fatal": false }` error payload.
372
375
 
373
376
  `modelRetry` is emitted before Kward retries a transient model request failure. Its payload includes `provider`, `model`, `attempt`, `maxAttempts`, `delaySeconds`, and `error`.
data/lib/kward/agent.rb CHANGED
@@ -215,6 +215,10 @@ module Kward
215
215
  on_reasoning_delta&.call(delta)
216
216
  yield Events::ReasoningDelta.new(delta: delta) if block_given?
217
217
  end
218
+ reasoning_boundary = lambda do
219
+ cancellation&.raise_if_cancelled!
220
+ yield Events::ReasoningBoundary.new if block_given?
221
+ end
218
222
  assistant_delta = lambda do |delta|
219
223
  cancellation&.raise_if_cancelled!
220
224
  yield Events::AssistantDelta.new(delta: delta) if block_given?
@@ -244,6 +248,7 @@ module Kward
244
248
  {
245
249
  tools: request[:tools] || request["tools"],
246
250
  on_reasoning_delta: reasoning_delta,
251
+ on_reasoning_boundary: reasoning_boundary,
247
252
  on_assistant_delta: assistant_delta,
248
253
  on_retry: retry_callback,
249
254
  cancellation: cancellation,
@@ -116,6 +116,9 @@ module Kward
116
116
  when Events::ReasoningDelta
117
117
  stream_state[:streamed] = true
118
118
  append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
119
+ when Events::ReasoningBoundary
120
+ stream_state[:streamed] = true
121
+ finish_interactive_markdown_deltas(markdown_chunks, stream_state)
119
122
  when Events::AssistantDelta
120
123
  stream_state[:streamed] = true
121
124
  append_markdown_delta(markdown_chunks, "Assistant", event.delta)
@@ -70,13 +70,12 @@ module Kward
70
70
  def print_visual_banner
71
71
  return unless @prompt.respond_to?(:print_visual_banner)
72
72
 
73
- @prompt.print_visual_banner(startup_info_screen)
74
- refresh_startup_update_check if prompt_interface?
73
+ @prompt.print_visual_banner(startup_info_screen(refresh_update_check: prompt_interface?))
75
74
  end
76
75
 
77
- def startup_info_screen
76
+ def startup_info_screen(refresh_update_check: false)
78
77
  [
79
- startup_status_line,
78
+ startup_status_line(refresh_update_check: refresh_update_check),
80
79
  *startup_update_notice_lines,
81
80
  "",
82
81
  startup_info_line("Workspace", startup_workspace_label),
@@ -119,8 +118,8 @@ module Kward
119
118
  filenames.empty? ? "none" : filenames.join(", ")
120
119
  end
121
120
 
122
- def startup_status_line
123
- color = startup_update_notice ? :yellow : :green
121
+ def startup_status_line(refresh_update_check: false)
122
+ color = startup_update_notice(refresh: refresh_update_check) ? :yellow : :green
124
123
  "#{ANSI.colorize("●", color, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
125
124
  end
126
125
 
@@ -134,18 +133,11 @@ module Kward
134
133
  ]
135
134
  end
136
135
 
137
- def startup_update_notice
138
- return @startup_update_notice if defined?(@startup_update_notice)
136
+ def startup_update_notice(refresh: false)
137
+ return @startup_update_notice if defined?(@startup_update_notice) && !refresh
139
138
 
140
- @startup_update_check = UpdateCheck.new(current_version: Kward::VERSION)
141
- @startup_update_notice = @startup_update_check.notice
142
- end
143
-
144
- def refresh_startup_update_check
145
- checker = @startup_update_check || UpdateCheck.new(current_version: Kward::VERSION)
146
- return unless checker.enabled?
147
-
148
- Thread.new { checker.refresh_if_stale }
139
+ @startup_update_check ||= UpdateCheck.new(current_version: Kward::VERSION)
140
+ @startup_update_notice = @startup_update_check.notice(refresh: refresh)
149
141
  end
150
142
 
151
143
  def startup_info_line(label, value)
@@ -38,8 +38,9 @@ module Kward
38
38
  end
39
39
 
40
40
  def render_reasoning(message)
41
- reasoning = CLITranscriptFormatter.reasoning(message)
42
- render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
41
+ CLITranscriptFormatter.reasoning_blocks(message).each do |reasoning|
42
+ render_transcript_block("Reasoning", reasoning)
43
+ end
43
44
  end
44
45
 
45
46
  def render_assistant_message(message)
@@ -66,7 +67,7 @@ module Kward
66
67
  def render_transcript_block(label, content)
67
68
  return if content.to_s.empty?
68
69
 
69
- rendered = render_markdown_transcript(content)
70
+ rendered = render_markdown_transcript(content).sub(/\n+\z/, "")
70
71
  if prompt_interface?
71
72
  print_block_delta(label, rendered)
72
73
  finish_stream_block
@@ -84,6 +85,9 @@ module Kward
84
85
  when Events::ReasoningDelta
85
86
  append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
86
87
  :streamed
88
+ when Events::ReasoningBoundary
89
+ flush_markdown_deltas(markdown_chunks)
90
+ :streamed
87
91
  when Events::AssistantDelta
88
92
  append_markdown_delta(markdown_chunks, "Assistant", event.delta)
89
93
  :assistant_streamed
@@ -10,25 +10,31 @@ module Kward
10
10
  module_function
11
11
 
12
12
  def reasoning(message)
13
- direct = MessageAccess.value(message, :reasoning_summary)
14
- return direct.to_s unless direct.to_s.empty?
13
+ reasoning_blocks(message).join("\n")
14
+ end
15
+
16
+ def reasoning_blocks(message)
17
+ response_blocks = response_items(message).flat_map do |item|
18
+ next [] unless MessageAccess.value(item, :type) == "reasoning"
19
+
20
+ summary = response_item_text_parts(MessageAccess.value(item, :summary))
21
+ summary.empty? ? response_item_text_parts(MessageAccess.value(item, :content)) : summary
22
+ end
23
+ return response_blocks unless response_blocks.empty?
15
24
 
16
25
  content = MessageAccess.content(message)
17
26
  if content.is_a?(Array)
18
- text = content.filter_map do |part|
27
+ blocks = content.filter_map do |part|
19
28
  type = MessageAccess.value(part, :type)
20
29
  next unless ["thinking", "reasoning"].include?(type)
21
30
 
22
31
  MessageAccess.value(part, :thinking) || MessageAccess.value(part, :reasoning) || MessageAccess.value(part, :text)
23
- end.join("\n")
24
- return text unless text.empty?
32
+ end.map(&:to_s).reject(&:empty?)
33
+ return blocks unless blocks.empty?
25
34
  end
26
35
 
27
- response_items(message).filter_map do |item|
28
- next unless MessageAccess.value(item, :type) == "reasoning"
29
-
30
- response_item_text(MessageAccess.value(item, :summary)).empty? ? response_item_text(MessageAccess.value(item, :content)) : response_item_text(MessageAccess.value(item, :summary))
31
- end.join("\n")
36
+ direct = MessageAccess.value(message, :reasoning_summary).to_s
37
+ direct.empty? ? [] : [direct]
32
38
  end
33
39
 
34
40
  def content_text(content)
@@ -113,11 +119,15 @@ module Kward
113
119
  end
114
120
 
115
121
  def response_item_text(parts)
122
+ response_item_text_parts(parts).join
123
+ end
124
+
125
+ def response_item_text_parts(parts)
116
126
  Array(parts).filter_map do |part|
117
127
  next unless part.is_a?(Hash)
118
128
 
119
129
  MessageAccess.value(part, :text) || MessageAccess.value(part, :refusal)
120
- end.join
130
+ end.map(&:to_s).reject(&:empty?)
121
131
  end
122
132
 
123
133
  def image_part_reference(part)
@@ -129,6 +129,9 @@ module Kward
129
129
  "provider" => "auto",
130
130
  "allow_model_providers" => false
131
131
  },
132
+ "updates" => {
133
+ "check" => true
134
+ },
132
135
  "sessions" => {
133
136
  "auto_resume" => false
134
137
  },
data/lib/kward/events.rb CHANGED
@@ -3,6 +3,7 @@ module Kward
3
3
  # Frontend-neutral event objects emitted during agent turns.
4
4
  module Events
5
5
  ReasoningDelta = Struct.new(:delta, keyword_init: true)
6
+ ReasoningBoundary = Data.define
6
7
  AssistantDelta = Struct.new(:delta, keyword_init: true)
7
8
  AssistantMessage = Struct.new(:message, keyword_init: true)
8
9
  Retry = Struct.new(:provider, :model, :attempt, :max_attempts, :delay_seconds, :error, :request_bytes, keyword_init: true)
@@ -94,7 +94,7 @@ module Kward
94
94
  @openrouter_models = nil
95
95
  end
96
96
 
97
- def chat(messages, tools: [], on_reasoning_delta: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
97
+ def chat(messages, tools: [], on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, on_retry: nil, cancellation: nil, steering: nil, max_tokens: nil, provider: nil, model: nil, reasoning: nil)
98
98
  cancellation&.raise_if_cancelled!
99
99
  requested_provider = provider
100
100
  url, token, resolved_provider, account_id = credentials(provider: requested_provider)
@@ -126,6 +126,7 @@ module Kward
126
126
  request_body: request_body,
127
127
  current_model: current_model,
128
128
  on_reasoning_delta: on_reasoning_delta,
129
+ on_reasoning_boundary: on_reasoning_boundary,
129
130
  on_assistant_delta: on_assistant_delta,
130
131
  cancellation: cancellation,
131
132
  max_tokens: max_tokens
@@ -257,7 +258,7 @@ module Kward
257
258
  }
258
259
  end
259
260
 
260
- def chat_provider_request(provider:, url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:, max_tokens:)
261
+ def chat_provider_request(provider:, url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_reasoning_boundary:, on_assistant_delta:, cancellation:, max_tokens:)
261
262
  case provider
262
263
  when "Codex"
263
264
  chat_codex_provider(
@@ -269,6 +270,7 @@ module Kward
269
270
  request_body: request_body,
270
271
  current_model: current_model,
271
272
  on_reasoning_delta: on_reasoning_delta,
273
+ on_reasoning_boundary: on_reasoning_boundary,
272
274
  on_assistant_delta: on_assistant_delta,
273
275
  cancellation: cancellation,
274
276
  max_tokens: max_tokens
@@ -306,7 +308,7 @@ module Kward
306
308
  end
307
309
  end
308
310
 
309
- def chat_codex_provider(url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_assistant_delta:, cancellation:, max_tokens:)
311
+ def chat_codex_provider(url:, token:, account_id:, messages:, tools:, request_body:, current_model:, on_reasoning_delta:, on_reasoning_boundary:, on_assistant_delta:, cancellation:, max_tokens:)
310
312
  message = codex_chat(
311
313
  url,
312
314
  token,
@@ -315,6 +317,7 @@ module Kward
315
317
  tools,
316
318
  request_body: request_body,
317
319
  on_reasoning_delta: on_reasoning_delta,
320
+ on_reasoning_boundary: on_reasoning_boundary,
318
321
  on_assistant_delta: on_assistant_delta,
319
322
  cancellation: cancellation,
320
323
  max_tokens: max_tokens
@@ -649,7 +652,7 @@ module Kward
649
652
  role.to_s == "user" ? "user" : "agent"
650
653
  end
651
654
 
652
- def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
655
+ def codex_chat(url, token, account_id, messages, tools, request_body: nil, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, max_tokens: nil)
653
656
  request = Net::HTTP::Post.new(url)
654
657
  request["Authorization"] = "Bearer #{token}"
655
658
  request["ChatGPT-Account-Id"] = account_id if account_id
@@ -669,7 +672,7 @@ module Kward
669
672
  raise RequestError.new(provider: "Codex", code: response.code, body: redact(body, token))
670
673
  end
671
674
 
672
- message = parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
675
+ message = parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, cancellation: cancellation)
673
676
  end
674
677
  end
675
678
 
@@ -681,12 +684,12 @@ module Kward
681
684
  raise e
682
685
  end
683
686
 
684
- def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil)
685
- ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
687
+ def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil)
688
+ ModelStreamParser.parse_codex_sse(body, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
686
689
  end
687
690
 
688
- def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
689
- ModelStreamParser.parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_assistant_delta: on_assistant_delta, cancellation: cancellation, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
691
+ def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil)
692
+ ModelStreamParser.parse_codex_sse_stream(response, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, cancellation: cancellation, show_raw_reasoning: codex_show_raw_reasoning?, usage_normalizer: method(:normalized_usage), request_error_class: RequestError)
690
693
  end
691
694
 
692
695
  def parse_anthropic_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil)
@@ -4,13 +4,15 @@ require_relative "../config_files"
4
4
  module Kward
5
5
  # Static and configured model metadata helpers.
6
6
  module ModelInfo
7
- DEFAULT_OPENAI_MODEL = "gpt-5.5"
7
+ DEFAULT_OPENAI_MODEL = "gpt-5.6-sol"
8
8
  DEFAULT_COPILOT_MODEL = "gpt-5-mini"
9
- DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6"
9
+ DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5"
10
10
  DEFAULT_REASONING_EFFORT = "medium"
11
- OPENAI_MODEL_CHOICES = %w[gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
11
+ OPENAI_MODEL_CHOICES = %w[gpt-5.6-sol gpt-5.6-terra gpt-5.6-luna gpt-5.5 gpt-5.4 gpt-5.4-mini gpt-5.3-codex-spark].freeze
12
12
  ANTHROPIC_MODEL_CHOICES = %w[
13
+ claude-fable-5
13
14
  claude-opus-4-8
15
+ claude-sonnet-5
14
16
  claude-sonnet-4-6
15
17
  claude-haiku-4-5
16
18
  claude-opus-4-7
@@ -46,6 +48,10 @@ module Kward
46
48
  ["none", "None"],
47
49
  *REASONING_EFFORT_CHOICES
48
50
  ].freeze
51
+ OPENAI_MAX_REASONING_EFFORT_CHOICES = [
52
+ *OPENAI_REASONING_EFFORT_CHOICES,
53
+ ["max", "Max"]
54
+ ].freeze
49
55
  ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES = [
50
56
  ["low", "Low"],
51
57
  ["medium", "Medium"],
@@ -70,6 +76,7 @@ module Kward
70
76
  ].freeze
71
77
 
72
78
  CODEX_CONTEXT_WINDOWS = [
79
+ [/\Agpt-5\.6/, 1_050_000],
73
80
  [/\Agpt-5\.5/, 400_000],
74
81
  [/\Agpt-5\.4-mini/, 400_000],
75
82
  [/\Agpt-5\.4/, 1_050_000],
@@ -87,6 +94,7 @@ module Kward
87
94
  [/\Agpt-3\.5-turbo/, 16_385]
88
95
  ].freeze
89
96
  OPENAI_CONTEXT_WINDOWS = [
97
+ [/\Agpt-5\.6/, 1_050_000],
90
98
  [/\Agpt-5\.5/, 1_050_000],
91
99
  [/\Agpt-5\.4-mini/, 400_000],
92
100
  [/\Agpt-5\.4/, 1_050_000],
@@ -104,7 +112,7 @@ module Kward
104
112
  [/\Agpt-3\.5-turbo/, 16_385]
105
113
  ].freeze
106
114
  ANTHROPIC_CONTEXT_WINDOWS = [
107
- [/\Aclaude-(?:fable|mythos)-5(?:\z|-)/, 1_000_000],
115
+ [/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)/, 1_000_000],
108
116
  [/\Aclaude-opus-4-(?:6|7|8)(?:\z|-)/, 1_000_000],
109
117
  [/\Aclaude-sonnet-4-6(?:\z|-)/, 1_000_000],
110
118
  [/\Aclaude-(?:haiku|opus|sonnet)-4-5(?:\z|-)/, 200_000],
@@ -382,13 +390,14 @@ module Kward
382
390
  def openai_reasoning_effort_choices(id)
383
391
  text = id.to_s.delete_prefix("openai/")
384
392
  return REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.[23]-codex/)
393
+ return OPENAI_MAX_REASONING_EFFORT_CHOICES if text.match?(/\Agpt-5\.6(?:\z|-)/)
385
394
 
386
395
  OPENAI_REASONING_EFFORT_CHOICES
387
396
  end
388
397
 
389
398
  def anthropic_reasoning_effort_choices(id)
390
399
  text = normalize_anthropic_model(id)
391
- return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
400
+ return ANTHROPIC_HIGH_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:fable|mythos|sonnet)-5(?:\z|-)|\Aclaude-opus-4-(?:7|8)(?:\z|-)/)
392
401
  return ANTHROPIC_STANDARD_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-(?:opus-4-6|sonnet-4-6)(?:\z|-)/)
393
402
  return ANTHROPIC_OPUS_4_5_REASONING_EFFORT_CHOICES if text.match?(/\Aclaude-opus-4-5(?:\z|-)/)
394
403
 
@@ -45,10 +45,10 @@ module Kward
45
45
  raise "Copilot returned invalid SSE JSON: #{e.message}"
46
46
  end
47
47
 
48
- def parse_codex_sse(body, on_reasoning_delta: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
48
+ def parse_codex_sse(body, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
49
49
  state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
50
50
  body.split(/\r?\n\r?\n/).each do |block|
51
- process_codex_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)
51
+ process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
52
52
  end
53
53
  flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
54
54
  codex_sse_message(state)
@@ -61,7 +61,7 @@ module Kward
61
61
  # Deltas are yielded as soon as complete SSE blocks arrive so interactive
62
62
  # frontends can render streamed assistant and reasoning text without waiting
63
63
  # for the provider to close the response.
64
- def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
64
+ def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, cancellation: nil, show_raw_reasoning: false, usage_normalizer: nil, request_error_class: nil)
65
65
  state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
66
66
  buffer = +""
67
67
 
@@ -72,11 +72,11 @@ module Kward
72
72
  delimiter = Regexp.last_match[0]
73
73
  block = buffer[0...index]
74
74
  buffer = buffer[(index + delimiter.length)..] || +""
75
- process_codex_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)
75
+ process_codex_sse_block(block, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class)
76
76
  end
77
77
  end
78
78
  cancellation&.raise_if_cancelled!
79
- process_codex_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?
79
+ process_codex_sse_block(buffer, state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary, on_assistant_delta: on_assistant_delta, usage_normalizer: usage_normalizer, request_error_class: request_error_class) unless buffer.empty?
80
80
  flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
81
81
  codex_sse_message(state)
82
82
  rescue JSON::ParserError => e
@@ -239,7 +239,7 @@ module Kward
239
239
  }
240
240
  end
241
241
 
242
- def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
242
+ def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_reasoning_boundary: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
243
243
  data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
244
244
  return if data.empty? || data == "[DONE]"
245
245
 
@@ -256,7 +256,7 @@ module Kward
256
256
  when "response.reasoning_summary_text.delta"
257
257
  codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
258
258
  when "response.reasoning_summary_part.done"
259
- codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
259
+ codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta, on_reasoning_boundary: on_reasoning_boundary)
260
260
  when "response.reasoning_text.delta"
261
261
  codex_raw_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
262
262
  when "response.reasoning_text.done"
@@ -391,16 +391,16 @@ module Kward
391
391
  append_codex_raw_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta) unless text.empty?
392
392
  end
393
393
 
394
- def codex_reasoning_part_done(state, on_reasoning_delta: nil)
394
+ def codex_reasoning_part_done(state, on_reasoning_delta: nil, on_reasoning_boundary: nil)
395
395
  item = active_codex_item(state)
396
396
  return unless item&.fetch("type", nil) == "reasoning"
397
397
  return if item["summary"].to_a.empty?
398
398
 
399
399
  flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
400
- text = "\n\n"
401
- item["summary"].last["text"] = item["summary"].last["text"].to_s + text
402
- state[:reasoning_summary] << text
403
- on_reasoning_delta&.call(text)
400
+ item["summary"].last["text"] = "#{item["summary"].last["text"].to_s.rstrip}\n\n"
401
+ state[:reasoning_summary].rstrip!
402
+ state[:reasoning_summary] << "\n\n"
403
+ on_reasoning_boundary&.call
404
404
  end
405
405
 
406
406
  def append_codex_reasoning_text(state, text, on_reasoning_delta: nil)
@@ -100,6 +100,7 @@
100
100
  currentReasoning.textContent += JSON.parse(event.data).delta;
101
101
  transcript.scrollTop = transcript.scrollHeight;
102
102
  });
103
+ events.addEventListener('reasoning_boundary', () => { currentReasoning = null; });
103
104
  events.addEventListener('assistant_delta', event => {
104
105
  if (!currentAssistant) currentAssistant = entry('assistant', 'Assistant', '');
105
106
  currentAssistant.textContent += JSON.parse(event.data).delta;
@@ -166,6 +166,8 @@ module Kward
166
166
  case event
167
167
  when Events::ReasoningDelta
168
168
  broadcast("reasoning_delta", { delta: event.delta.to_s })
169
+ when Events::ReasoningBoundary
170
+ broadcast("reasoning_boundary", {})
169
171
  when Events::AssistantDelta
170
172
  broadcast("assistant_delta", { delta: event.delta.to_s })
171
173
  when Events::Retry
@@ -454,6 +454,8 @@ module Kward
454
454
  case event.class.name
455
455
  when "Kward::Events::ReasoningDelta"
456
456
  transcript_event("reasoning_delta", delta: event.delta)
457
+ when "Kward::Events::ReasoningBoundary"
458
+ transcript_event("reasoning_boundary")
457
459
  when "Kward::Events::AssistantDelta"
458
460
  transcript_event("assistant_delta", delta: event.delta)
459
461
  when "Kward::Events::AssistantMessage"
@@ -15,7 +15,7 @@ module Kward
15
15
  @stream_state.start_block(label)
16
16
  end
17
17
  write_transcript_text_locked(delta) unless delta.empty?
18
- write_transcript_text_locked("\n") if finish && @stream_state.block
18
+ write_transcript_text_locked("\n") if finish && @stream_state.block && !@transcript_buffer.end_with?("\n")
19
19
  @stream_state.finish_block if finish
20
20
  restore_composer_cursor_locked unless @restoring_transcript
21
21
  end
@@ -426,7 +426,7 @@ module Kward
426
426
  events: {
427
427
  notification: TURN_EVENT_NOTIFICATION,
428
428
  assistantText: "assistantDelta",
429
- reasoning: { start: false, delta: true, end: false },
429
+ reasoning: { start: false, delta: true, boundary: true, end: false },
430
430
  modelRetry: { supported: true, event: "modelRetry" },
431
431
  steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
432
432
  tools: { call: true, update: true, result: true, normalizedMetadata: true, diffs: true, changedFiles: true, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
@@ -1256,6 +1256,8 @@ module Kward
1256
1256
  case event
1257
1257
  when Events::ReasoningDelta
1258
1258
  emit_turn_event(turn, "reasoningDelta", { delta: event.delta })
1259
+ when Events::ReasoningBoundary
1260
+ emit_turn_event(turn, "reasoningBoundary", {})
1259
1261
  when Events::AssistantDelta
1260
1262
  emit_turn_event(turn, "assistantDelta", { delta: event.delta })
1261
1263
  when Events::AssistantMessage
@@ -157,18 +157,19 @@ module Kward
157
157
  end
158
158
 
159
159
  def response_item_content(message)
160
- response_items(message).filter_map do |item|
161
- next unless item.is_a?(Hash)
160
+ response_items(message).flat_map do |item|
161
+ next [] unless item.is_a?(Hash)
162
162
 
163
163
  case ToolCall.value(item, :type).to_s
164
164
  when "reasoning"
165
- thinking = reasoning_item_text(item)
166
- { type: "thinking", thinking: thinking } unless thinking.empty?
165
+ reasoning_item_blocks(item).map { |thinking| { type: "thinking", thinking: thinking } }
167
166
  when "message"
168
- next if ToolCall.value(item, :phase).to_s == "commentary"
167
+ next [] if ToolCall.value(item, :phase).to_s == "commentary"
169
168
 
170
169
  text = response_message_item_text(item)
171
- { type: "text", text: text } unless text.empty?
170
+ text.empty? ? [] : [{ type: "text", text: text }]
171
+ else
172
+ []
172
173
  end
173
174
  end
174
175
  end
@@ -177,10 +178,9 @@ module Kward
177
178
  MessageAccess.response_items(message)
178
179
  end
179
180
 
180
- def reasoning_item_text(item)
181
- summary = ToolCall.value(item, :summary)
182
- content = ToolCall.value(item, :content)
183
- response_text_parts(summary).empty? ? response_text_parts(content).join("\n\n") : response_text_parts(summary).join("\n\n")
181
+ def reasoning_item_blocks(item)
182
+ summary = response_text_parts(ToolCall.value(item, :summary))
183
+ summary.empty? ? response_text_parts(ToolCall.value(item, :content)) : summary
184
184
  end
185
185
 
186
186
  def response_message_item_text(item)
@@ -29,15 +29,11 @@ module Kward
29
29
  true
30
30
  end
31
31
 
32
- def notice
32
+ def notice(refresh: false)
33
33
  return nil unless enabled?
34
34
 
35
- latest_version = cache["latest_version"].to_s
36
- return nil if latest_version.empty?
37
- return nil if dismissed_version == latest_version
38
- return nil unless newer_than_current?(latest_version)
39
-
40
- Notice.new(latest_version: latest_version)
35
+ refresh_if_stale if refresh
36
+ notice_from_cache
41
37
  end
42
38
 
43
39
  def refresh_if_stale
@@ -69,6 +65,15 @@ module Kward
69
65
  cache["dismissed_version"].to_s
70
66
  end
71
67
 
68
+ def notice_from_cache
69
+ latest_version = cache["latest_version"].to_s
70
+ return nil if latest_version.empty?
71
+ return nil if dismissed_version == latest_version
72
+ return nil unless newer_than_current?(latest_version)
73
+
74
+ Notice.new(latest_version: latest_version)
75
+ end
76
+
72
77
  def stale?
73
78
  checked_at = Time.iso8601(cache["checked_at"].to_s)
74
79
  (@now - checked_at) >= CHECK_INTERVAL_SECONDS
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.75.0"
4
+ VERSION = "0.76.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kward
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.75.0
4
+ version: 0.76.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kai Wood