kward 0.75.0 → 0.77.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +8 -1
  3. data/CHANGELOG.md +48 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +12 -4
  6. data/doc/agent-tools.md +0 -3
  7. data/doc/api.md +4 -0
  8. data/doc/composer.md +219 -0
  9. data/doc/configuration.md +87 -27
  10. data/doc/editor.md +18 -2
  11. data/doc/files.md +1 -1
  12. data/doc/getting-started.md +4 -0
  13. data/doc/git.md +2 -2
  14. data/doc/lifecycle-hooks.md +2 -14
  15. data/doc/pan.md +167 -0
  16. data/doc/plugins.md +1 -0
  17. data/doc/rpc.md +3 -22
  18. data/doc/security.md +180 -0
  19. data/doc/usage.md +15 -32
  20. data/lib/kward/agent.rb +5 -0
  21. data/lib/kward/ansi.rb +2 -2
  22. data/lib/kward/cancellation.rb +2 -2
  23. data/lib/kward/cli/commands.rb +1 -3
  24. data/lib/kward/cli/interactive_turn.rb +3 -17
  25. data/lib/kward/cli/plugins.rb +2 -7
  26. data/lib/kward/cli/prompt_interface.rb +10 -17
  27. data/lib/kward/cli/rendering.rb +7 -3
  28. data/lib/kward/cli/runtime_helpers.rb +0 -38
  29. data/lib/kward/cli/sessions.rb +14 -5
  30. data/lib/kward/cli/settings.rb +22 -0
  31. data/lib/kward/cli/slash_commands.rb +1 -561
  32. data/lib/kward/cli/tabs.rb +14 -2
  33. data/lib/kward/cli.rb +1 -17
  34. data/lib/kward/cli_transcript_formatter.rb +21 -11
  35. data/lib/kward/compactor.rb +3 -3
  36. data/lib/kward/config_files.rb +21 -0
  37. data/lib/kward/ekwsh.rb +0 -4
  38. data/lib/kward/events.rb +1 -0
  39. data/lib/kward/hooks/catalog.rb +0 -5
  40. data/lib/kward/image_attachments.rb +1 -1
  41. data/lib/kward/model/client.rb +12 -9
  42. data/lib/kward/model/model_info.rb +14 -5
  43. data/lib/kward/model/stream_parser.rb +12 -12
  44. data/lib/kward/pan/index.html.erb +460 -64
  45. data/lib/kward/pan/kward_logo.png +0 -0
  46. data/lib/kward/pan/server.rb +250 -23
  47. data/lib/kward/plugin_registry.rb +20 -0
  48. data/lib/kward/prompt_interface/editor/auto_indent.rb +31 -23
  49. data/lib/kward/prompt_interface/editor/modes/vibe.rb +72 -3
  50. data/lib/kward/prompt_interface/layout.rb +0 -4
  51. data/lib/kward/prompt_interface/project_browser.rb +40 -1
  52. data/lib/kward/prompt_interface/runtime_state.rb +4 -0
  53. data/lib/kward/prompt_interface/selection_prompt.rb +0 -1
  54. data/lib/kward/prompt_interface/transcript_renderer.rb +1 -13
  55. data/lib/kward/prompt_interface.rb +18 -1
  56. data/lib/kward/prompts/commands.rb +1 -3
  57. data/lib/kward/prompts.rb +20 -0
  58. data/lib/kward/rpc/server.rb +14 -42
  59. data/lib/kward/rpc/session_manager.rb +77 -25
  60. data/lib/kward/rpc/tool_event_normalizer.rb +2 -0
  61. data/lib/kward/rpc/tool_metadata.rb +23 -0
  62. data/lib/kward/rpc/transcript_normalizer.rb +14 -10
  63. data/lib/kward/session_store.rb +55 -16
  64. data/lib/kward/skills/registry.rb +21 -1
  65. data/lib/kward/tools/base.rb +14 -0
  66. data/lib/kward/tools/registry.rb +11 -16
  67. data/lib/kward/tools/search/code.rb +1 -1
  68. data/lib/kward/tools/search/web.rb +1 -1
  69. data/lib/kward/tools/tool_call.rb +0 -4
  70. data/lib/kward/transcript_export.rb +1 -1
  71. data/lib/kward/update_check.rb +12 -7
  72. data/lib/kward/version.rb +1 -1
  73. data/templates/default/kward_navigation.rb +5 -1
  74. data/templates/default/layout/html/layout.erb +0 -2
  75. metadata +5 -12
  76. data/lib/kward/workers/git_guard.rb +0 -93
  77. data/lib/kward/workers/job.rb +0 -99
  78. data/lib/kward/workers/live_view.rb +0 -49
  79. data/lib/kward/workers/manager.rb +0 -328
  80. data/lib/kward/workers/queue_runner.rb +0 -166
  81. data/lib/kward/workers/queue_store.rb +0 -112
  82. data/lib/kward/workers/store.rb +0 -72
  83. data/lib/kward/workers/tool_policy.rb +0 -23
  84. data/lib/kward/workers/worker.rb +0 -82
  85. data/lib/kward/workers/write_lock.rb +0 -38
  86. data/lib/kward/workers.rb +0 -10
@@ -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)
@@ -37,7 +37,7 @@ module Kward
37
37
 
38
38
  Cut = Struct.new(:first_kept_index, :messages_to_summarize, :turn_prefix_messages, :split_turn, :preserved_messages, :preserved_start_index, keyword_init: true)
39
39
 
40
- # Interactive settings menu actions mixed into the CLI frontend.
40
+ # Configuration for manual and automatic conversation compaction.
41
41
  class Settings
42
42
  DEFAULT_ENABLED = true
43
43
  DEFAULT_RESERVE_TOKENS = 16_384
@@ -731,7 +731,7 @@ module Kward
731
731
  SummarizationFailed = Compaction::SummarizationFailed
732
732
 
733
733
  AUTO_COMPACTION_GUARD_RATIO = 0.10
734
- AUTO_COMPACTION_EXTRA_GUARD_CAP = 12_000
734
+ AUTO_COMPACTION_EXTRA_GUARD_FLOOR = 12_000
735
735
 
736
736
  # Creates an object for conversation compaction.
737
737
  def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil)
@@ -805,7 +805,7 @@ module Kward
805
805
  def self.auto_compaction_reserve_tokens(context_window:, configured_reserve_tokens:)
806
806
  context_window_i = context_window.to_i
807
807
  dynamic_guard = (context_window_i * AUTO_COMPACTION_GUARD_RATIO).to_i
808
- [configured_reserve_tokens.to_i, dynamic_guard, AUTO_COMPACTION_EXTRA_GUARD_CAP].max
808
+ [configured_reserve_tokens.to_i, dynamic_guard, AUTO_COMPACTION_EXTRA_GUARD_FLOOR].max
809
809
  end
810
810
 
811
811
  def compaction_messages(custom_instructions = nil)
@@ -24,7 +24,9 @@ module Kward
24
24
  # Keep path decisions here. Higher-level code should ask `ConfigFiles` for
25
25
  # config, prompt, skill, plugin, cache, memory, and session locations instead of
26
26
  # reconstructing `~/.kward` paths independently.
27
+ # @api public
27
28
  module ConfigFiles
29
+ # Raised when a user configuration file cannot be parsed.
28
30
  class ConfigError < StandardError
29
31
  attr_reader :path, :format, :detail
30
32
 
@@ -39,6 +41,7 @@ module Kward
39
41
  MAX_SKILL_FILE_BYTES = 100_000
40
42
  MAX_PROMPT_FILE_BYTES = 32 * 1024
41
43
  DEFAULT_OVERLAY_SETTINGS = { "alignment" => "center", "width" => "maximum" }.freeze
44
+ PROJECT_BROWSER_ICON_THEMES = %w[off nerd-font].freeze
42
45
  DEFAULT_PERSONAS = {
43
46
  "characters" => [
44
47
  {
@@ -87,10 +90,12 @@ module Kward
87
90
  File.expand_path(ENV["KWARD_CONFIG_PATH"] || File.join(config_dir, "config.json"))
88
91
  end
89
92
 
93
+ # @return [String] directory containing reusable local caches
90
94
  def cache_dir
91
95
  File.join(config_dir, "cache")
92
96
  end
93
97
 
98
+ # @return [String] embedded-shell YAML config path
94
99
  def ekwsh_config_path
95
100
  File.join(config_dir, "ekwsh.yml")
96
101
  end
@@ -103,6 +108,9 @@ module Kward
103
108
  File.join(config_dir, "trusted_workspace_hooks.json")
104
109
  end
105
110
 
111
+ # Returns a fresh default config suitable for first-run persistence.
112
+ #
113
+ # @return [Hash] deep-mutable config defaults
106
114
  def default_config
107
115
  {
108
116
  "personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
@@ -124,11 +132,17 @@ module Kward
124
132
  "diff_view" => "auto"
125
133
  },
126
134
  "overlay" => DEFAULT_OVERLAY_SETTINGS.dup,
135
+ "project_browser" => {
136
+ "icons" => "off"
137
+ },
127
138
  "web_search" => {
128
139
  "enabled" => true,
129
140
  "provider" => "auto",
130
141
  "allow_model_providers" => false
131
142
  },
143
+ "updates" => {
144
+ "check" => true
145
+ },
132
146
  "sessions" => {
133
147
  "auto_resume" => false
134
148
  },
@@ -406,6 +420,13 @@ module Kward
406
420
  settings
407
421
  end
408
422
 
423
+ # Returns the project browser icon theme, or off when unset or invalid.
424
+ def project_browser_icon_theme(config = read_config)
425
+ browser = config["project_browser"].is_a?(Hash) ? config["project_browser"] : {}
426
+ theme = browser["icons"].to_s
427
+ PROJECT_BROWSER_ICON_THEMES.include?(theme) ? theme : "off"
428
+ end
429
+
409
430
  # Returns whether the composer should show busy-state keyboard help.
410
431
  def composer_busy_help?(config = read_config)
411
432
  composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
data/lib/kward/ekwsh.rb CHANGED
@@ -551,9 +551,5 @@ module Kward
551
551
  ANSI.sanitize_transcript(text)
552
552
  end
553
553
 
554
- def clean_output(value)
555
- text = clean_chunk(value)
556
- text.end_with?("\n") || text.empty? ? text : "#{text}\n"
557
- end
558
554
  end
559
555
  end
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)
@@ -45,11 +45,6 @@ module Kward
45
45
  EventDefinition.new(name: "git_stage_after", failure_policy: "warn"),
46
46
  EventDefinition.new(name: "git_commit_before", failure_policy: "deny"),
47
47
  EventDefinition.new(name: "git_commit_after", failure_policy: "warn"),
48
- EventDefinition.new(name: "worker_job_create", failure_policy: "warn"),
49
- EventDefinition.new(name: "worker_job_start_before", failure_policy: "warn"),
50
- EventDefinition.new(name: "worker_job_start_after", failure_policy: "warn"),
51
- EventDefinition.new(name: "worker_job_ready_for_review", failure_policy: "warn"),
52
- EventDefinition.new(name: "worker_job_failed", failure_policy: "warn")
53
48
  ].each_with_object({}) { |definition, result| result[definition.name] = definition }.freeze
54
49
 
55
50
  module_function
@@ -1,5 +1,5 @@
1
1
  require "base64"
2
- require "cgi"
2
+ require "cgi/escape"
3
3
  require "shellwords"
4
4
  require "tmpdir"
5
5
  require "uri"
@@ -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)