kward 0.74.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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +75 -10
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +2 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +111 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +77 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/interactive_turn.rb +3 -0
  27. data/lib/kward/cli/memory_commands.rb +1 -5
  28. data/lib/kward/cli/plugins.rb +41 -1
  29. data/lib/kward/cli/prompt_interface.rb +28 -7
  30. data/lib/kward/cli/rendering.rb +8 -4
  31. data/lib/kward/cli/runtime_helpers.rb +44 -5
  32. data/lib/kward/cli/sessions.rb +13 -0
  33. data/lib/kward/cli/settings.rb +226 -152
  34. data/lib/kward/cli/slash_commands.rb +81 -42
  35. data/lib/kward/cli/tabs.rb +26 -6
  36. data/lib/kward/cli.rb +26 -3
  37. data/lib/kward/cli_transcript_formatter.rb +21 -18
  38. data/lib/kward/compaction/token_estimator.rb +112 -0
  39. data/lib/kward/compactor.rb +42 -116
  40. data/lib/kward/config_files.rb +120 -25
  41. data/lib/kward/deep_copy.rb +42 -0
  42. data/lib/kward/diff_view_mode.rb +36 -0
  43. data/lib/kward/events.rb +2 -0
  44. data/lib/kward/export_path.rb +2 -6
  45. data/lib/kward/frontmatter.rb +43 -0
  46. data/lib/kward/hooks/audit_log.rb +121 -0
  47. data/lib/kward/hooks/catalog.rb +110 -0
  48. data/lib/kward/hooks/command_handler.rb +66 -0
  49. data/lib/kward/hooks/config_loader.rb +104 -0
  50. data/lib/kward/hooks/decision.rb +102 -0
  51. data/lib/kward/hooks/event.rb +60 -0
  52. data/lib/kward/hooks/http_handler.rb +72 -0
  53. data/lib/kward/hooks/manager.rb +158 -0
  54. data/lib/kward/hooks/matcher.rb +75 -0
  55. data/lib/kward/hooks.rb +16 -0
  56. data/lib/kward/markdown_transcript.rb +4 -3
  57. data/lib/kward/mcp/client.rb +1 -1
  58. data/lib/kward/mcp/server_config.rb +7 -3
  59. data/lib/kward/mcp/stdio_transport.rb +7 -1
  60. data/lib/kward/memory/turn_context.rb +19 -0
  61. data/lib/kward/message_access.rb +2 -2
  62. data/lib/kward/message_text.rb +19 -0
  63. data/lib/kward/model/client.rb +23 -52
  64. data/lib/kward/model/context_usage.rb +10 -4
  65. data/lib/kward/model/copilot_models.rb +44 -0
  66. data/lib/kward/model/model_info.rb +38 -5
  67. data/lib/kward/model/payloads.rb +15 -17
  68. data/lib/kward/model/stream_parser.rb +183 -18
  69. data/lib/kward/pan/index.html.erb +1 -0
  70. data/lib/kward/pan/server.rb +53 -2
  71. data/lib/kward/path_guard.rb +13 -0
  72. data/lib/kward/plugin_registry.rb +88 -25
  73. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  74. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  75. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  76. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  77. data/lib/kward/prompt_interface/transcript_renderer.rb +1 -1
  78. data/lib/kward/prompt_interface.rb +4 -1
  79. data/lib/kward/prompts/commands.rb +3 -3
  80. data/lib/kward/prompts.rb +7 -7
  81. data/lib/kward/rpc/mcp_status.rb +50 -0
  82. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  83. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  84. data/lib/kward/rpc/server.rb +95 -15
  85. data/lib/kward/rpc/session_manager.rb +260 -33
  86. data/lib/kward/rpc/session_metrics.rb +1 -1
  87. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  88. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  89. data/lib/kward/rpc/transcript_normalizer.rb +19 -20
  90. data/lib/kward/rpc/turn_context.rb +109 -0
  91. data/lib/kward/session_store.rb +6 -20
  92. data/lib/kward/session_tree_nodes.rb +98 -18
  93. data/lib/kward/session_tree_renderer.rb +6 -61
  94. data/lib/kward/skills/registry.rb +109 -17
  95. data/lib/kward/tools/mcp_tool.rb +4 -0
  96. data/lib/kward/tools/read_skill.rb +9 -4
  97. data/lib/kward/tools/registry.rb +266 -8
  98. data/lib/kward/tools/search/code.rb +4 -22
  99. data/lib/kward/tools/search/web.rb +28 -2
  100. data/lib/kward/update_check.rb +122 -0
  101. data/lib/kward/version.rb +1 -1
  102. data/lib/kward/workers/manager.rb +43 -3
  103. data/lib/kward/workspace.rb +3 -1
  104. data/templates/default/fulldoc/html/css/kward.css +22 -0
  105. data/templates/default/fulldoc/html/js/kward.js +2 -0
  106. data/templates/default/kward_navigation.rb +4 -2
  107. data/templates/default/layout/html/setup.rb +2 -0
  108. metadata +25 -1
@@ -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
 
@@ -412,6 +421,30 @@ module Kward
412
421
  }.compact
413
422
  end
414
423
 
424
+ def normalize_available(models, current_provider:, current_model:, current_reasoning_effort: nil)
425
+ Array(models).map do |model|
426
+ normalize(
427
+ model,
428
+ current_provider: current_provider,
429
+ current_model: current_model,
430
+ current_reasoning_effort: current_reasoning_effort
431
+ )
432
+ end
433
+ end
434
+
435
+ def current_payload(provider:, model:, reasoning_effort: nil, context_window: nil)
436
+ normalize(
437
+ {
438
+ provider: provider,
439
+ id: model,
440
+ model: model,
441
+ reasoningEffort: reasoning_effort,
442
+ contextWindow: context_window,
443
+ current: true
444
+ }
445
+ )
446
+ end
447
+
415
448
  def boolean_value(value, default: false)
416
449
  return default if value.nil?
417
450
  return value if value == true || value == false
@@ -188,14 +188,12 @@ module Kward
188
188
  text = plain_content(content).to_s
189
189
  blocks << { type: "text", text: text } unless text.empty?
190
190
  MessageAccess.tool_calls(message).each do |tool_call|
191
- function = tool_call[:function] || tool_call["function"] || {}
192
- name = function[:name] || function["name"]
193
- arguments = function[:arguments] || function["arguments"] || "{}"
191
+ name = ToolCall.name(tool_call)
194
192
  blocks << {
195
193
  type: "tool_use",
196
- id: normalize_anthropic_tool_call_id(tool_call[:id] || tool_call["id"] || "call_#{name}"),
194
+ id: normalize_anthropic_tool_call_id(ToolCall.id(tool_call) || "call_#{name}"),
197
195
  name: claude_code_tool_name(name),
198
- input: parse_tool_arguments(arguments)
196
+ input: parse_tool_arguments(ToolCall.raw_arguments(tool_call) || "{}")
199
197
  }
200
198
  end
201
199
  output << { role: "assistant", content: blocks } unless blocks.empty?
@@ -260,11 +258,11 @@ module Kward
260
258
  end
261
259
 
262
260
  def anthropic_tool_schema(tool)
263
- function = tool[:function] || tool["function"] || {}
264
- schema = function[:parameters] || function["parameters"] || {}
261
+ function = ToolCall.function(tool)
262
+ schema = ToolCall.value(function, :parameters) || {}
265
263
  {
266
- name: claude_code_tool_name(function[:name] || function["name"]),
267
- description: function[:description] || function["description"] || "",
264
+ name: claude_code_tool_name(ToolCall.value(function, :name)),
265
+ description: ToolCall.value(function, :description) || "",
268
266
  input_schema: {
269
267
  type: "object",
270
268
  properties: schema[:properties] || schema["properties"] || {},
@@ -296,12 +294,12 @@ module Kward
296
294
  content = plain_content(content)
297
295
  input << codex_message("assistant", content.to_s) unless content.to_s.empty?
298
296
  MessageAccess.tool_calls(message).each do |tool_call|
299
- function = tool_call[:function] || tool_call["function"] || {}
297
+ name = ToolCall.name(tool_call)
300
298
  input << {
301
299
  type: "function_call",
302
- call_id: tool_call[:id] || tool_call["id"] || function[:name] || function["name"] || "tool-call",
303
- name: function[:name] || function["name"],
304
- arguments: function[:arguments] || function["arguments"] || "{}"
300
+ call_id: ToolCall.id(tool_call) || name || "tool-call",
301
+ name: name,
302
+ arguments: ToolCall.raw_arguments(tool_call) || "{}"
305
303
  }
306
304
  end
307
305
  else
@@ -420,12 +418,12 @@ module Kward
420
418
  end
421
419
 
422
420
  def codex_tool_schema(tool)
423
- function = tool[:function] || tool["function"] || {}
421
+ function = ToolCall.function(tool)
424
422
  {
425
423
  type: "function",
426
- name: function[:name] || function["name"],
427
- description: function[:description] || function["description"] || "",
428
- parameters: function[:parameters] || function["parameters"] || {},
424
+ name: ToolCall.value(function, :name),
425
+ description: ToolCall.value(function, :description) || "",
426
+ parameters: ToolCall.value(function, :parameters) || {},
429
427
  strict: false
430
428
  }
431
429
  end
@@ -45,11 +45,12 @@ 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, usage_normalizer: nil, request_error_class: nil)
49
- state = codex_sse_state
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
+ 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
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
53
54
  codex_sse_message(state)
54
55
  rescue JSON::ParserError => e
55
56
  raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
@@ -60,8 +61,8 @@ module Kward
60
61
  # Deltas are yielded as soon as complete SSE blocks arrive so interactive
61
62
  # frontends can render streamed assistant and reasoning text without waiting
62
63
  # for the provider to close the response.
63
- def parse_codex_sse_stream(response, on_reasoning_delta: nil, on_assistant_delta: nil, cancellation: nil, usage_normalizer: nil, request_error_class: nil)
64
- state = codex_sse_state
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
+ state = codex_sse_state(show_raw_reasoning: show_raw_reasoning)
65
66
  buffer = +""
66
67
 
67
68
  response.read_body do |chunk|
@@ -71,11 +72,12 @@ module Kward
71
72
  delimiter = Regexp.last_match[0]
72
73
  block = buffer[0...index]
73
74
  buffer = buffer[(index + delimiter.length)..] || +""
74
- 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)
75
76
  end
76
77
  end
77
78
  cancellation&.raise_if_cancelled!
78
- 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
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
79
81
  codex_sse_message(state)
80
82
  rescue JSON::ParserError => e
81
83
  raise "Codex OAuth returned invalid SSE JSON: #{e.message}"
@@ -218,22 +220,26 @@ module Kward
218
220
  end
219
221
  end
220
222
 
221
- def codex_sse_state
223
+ def codex_sse_state(show_raw_reasoning: false)
222
224
  {
223
225
  content: +"",
224
226
  raw_content: +"",
225
227
  emitted_message_keys: [],
226
228
  reasoning_summary: +"",
229
+ raw_reasoning_visible: +"",
230
+ pending_reasoning_delta: +"",
231
+ show_raw_reasoning: show_raw_reasoning,
227
232
  tool_calls: [],
228
233
  response_item_keys: [],
229
234
  items_by_id: {},
230
235
  active_item_id: nil,
231
236
  current_text_content_part: nil,
237
+ current_reasoning_content_part: nil,
232
238
  usage: nil
233
239
  }
234
240
  end
235
241
 
236
- 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)
237
243
  data = block.lines.filter_map { |line| line.start_with?("data:") ? line.delete_prefix("data:").strip : nil }.join("\n")
238
244
  return if data.empty? || data == "[DONE]"
239
245
 
@@ -250,9 +256,11 @@ module Kward
250
256
  when "response.reasoning_summary_text.delta"
251
257
  codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
252
258
  when "response.reasoning_summary_part.done"
253
- 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)
254
260
  when "response.reasoning_text.delta"
255
- codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
261
+ codex_raw_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
262
+ when "response.reasoning_text.done"
263
+ codex_raw_reasoning_done(state, event["text"], on_reasoning_delta: on_reasoning_delta)
256
264
  when "response.function_call_arguments.delta", "response.custom_tool_call_input.delta"
257
265
  codex_tool_arguments_delta(state, event["delta"])
258
266
  when "response.function_call_arguments.done"
@@ -299,6 +307,7 @@ module Kward
299
307
  remember_codex_item(state, item)
300
308
  state[:active_item_id] = codex_item_key(item)
301
309
  state[:current_text_content_part] = nil
310
+ state[:current_reasoning_content_part] = nil
302
311
  end
303
312
 
304
313
  def codex_content_part_added(state, part)
@@ -333,12 +342,71 @@ module Kward
333
342
 
334
343
  item["summary"] ||= []
335
344
  item["summary"] << deep_dup_hash(part)
345
+ state[:current_reasoning_content_part] = nil
336
346
  end
337
347
 
338
348
  def codex_reasoning_delta(state, delta, on_reasoning_delta: nil)
349
+ text = visible_codex_reasoning_delta(state, delta)
350
+ return if text.empty?
351
+
352
+ append_codex_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta)
353
+ end
354
+
355
+ def codex_raw_reasoning_delta(state, delta, on_reasoning_delta: nil)
339
356
  text = delta.to_s
340
357
  return if text.empty?
341
358
 
359
+ item = active_codex_item(state)
360
+ if item&.fetch("type", nil) == "reasoning" && state[:show_raw_reasoning]
361
+ item["content"] ||= [{ "type" => "reasoning_text", "text" => +"" }]
362
+ part = state[:current_reasoning_content_part] || item["content"].last
363
+ part = item["content"].last unless part.is_a?(Hash)
364
+ part["type"] ||= "reasoning_text"
365
+ part["text"] = part["text"].to_s + text
366
+ state[:current_reasoning_content_part] = part
367
+ end
368
+ return unless state[:show_raw_reasoning]
369
+
370
+ visible = visible_codex_reasoning_delta(state, text)
371
+ append_codex_raw_reasoning_text(state, visible, on_reasoning_delta: on_reasoning_delta) unless visible.empty?
372
+ end
373
+
374
+ def codex_raw_reasoning_done(state, text, on_reasoning_delta: nil)
375
+ item = active_codex_item(state)
376
+ full_text = text.to_s
377
+ if item&.fetch("type", nil) == "reasoning" && state[:show_raw_reasoning]
378
+ item["content"] ||= [{ "type" => "reasoning_text", "text" => +"" }]
379
+ part = state[:current_reasoning_content_part] || item["content"].last
380
+ part = item["content"].last unless part.is_a?(Hash)
381
+ part["type"] ||= "reasoning_text"
382
+ part["text"] = full_text
383
+ state[:current_reasoning_content_part] = part
384
+ end
385
+ return unless state[:show_raw_reasoning]
386
+
387
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
388
+ visible = sanitize_codex_reasoning_text(full_text)
389
+ emitted = state[:raw_reasoning_visible]
390
+ text = visible.start_with?(emitted) ? visible[emitted.length..].to_s : (emitted.empty? ? visible : +"")
391
+ append_codex_raw_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta) unless text.empty?
392
+ end
393
+
394
+ def codex_reasoning_part_done(state, on_reasoning_delta: nil, on_reasoning_boundary: nil)
395
+ item = active_codex_item(state)
396
+ return unless item&.fetch("type", nil) == "reasoning"
397
+ return if item["summary"].to_a.empty?
398
+
399
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
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
+ end
405
+
406
+ def append_codex_reasoning_text(state, text, on_reasoning_delta: nil)
407
+ text = compact_codex_reasoning_delta_spacing(state[:reasoning_summary], text)
408
+ return if text.empty?
409
+
342
410
  item = active_codex_item(state)
343
411
  if item&.fetch("type", nil) == "reasoning"
344
412
  item["summary"] ||= []
@@ -349,17 +417,76 @@ module Kward
349
417
  on_reasoning_delta&.call(text)
350
418
  end
351
419
 
352
- def codex_reasoning_part_done(state, on_reasoning_delta: nil)
353
- item = active_codex_item(state)
354
- return unless item&.fetch("type", nil) == "reasoning"
355
- return if item["summary"].to_a.empty?
420
+ def append_codex_raw_reasoning_text(state, text, on_reasoning_delta: nil)
421
+ text = compact_codex_reasoning_delta_spacing(state[:reasoning_summary], text)
422
+ return if text.empty?
356
423
 
357
- text = "\n\n"
358
- item["summary"].last["text"] = item["summary"].last["text"].to_s + text
424
+ state[:raw_reasoning_visible] << text
359
425
  state[:reasoning_summary] << text
360
426
  on_reasoning_delta&.call(text)
361
427
  end
362
428
 
429
+ def visible_codex_reasoning_delta(state, delta)
430
+ state[:pending_reasoning_delta] << delta.to_s
431
+ sanitized = sanitize_codex_reasoning_text(state[:pending_reasoning_delta], keep_incomplete_comment: true)
432
+ text, pending_prefix = split_trailing_comment_prefix(sanitized[:text])
433
+ state[:pending_reasoning_delta].replace(pending_prefix + sanitized[:pending])
434
+ text
435
+ end
436
+
437
+ def flush_codex_reasoning_delta(state, on_reasoning_delta: nil)
438
+ pending = state[:pending_reasoning_delta].to_s
439
+ state[:pending_reasoning_delta].clear
440
+ text = sanitize_codex_reasoning_text(pending)
441
+ append_codex_raw_reasoning_text(state, text, on_reasoning_delta: on_reasoning_delta) unless text.empty?
442
+ end
443
+
444
+ def split_trailing_comment_prefix(text)
445
+ string = text.to_s
446
+ prefix = ["<!-", "<!", "<"].find { |candidate| string.end_with?(candidate) }
447
+ return [string, +""] unless prefix
448
+
449
+ [string[0...-prefix.length], prefix.dup]
450
+ end
451
+
452
+ def sanitize_codex_reasoning_text(text, keep_incomplete_comment: false)
453
+ string = text.to_s
454
+ visible = +""
455
+ index = 0
456
+
457
+ while (start_index = string.index("<!--", index))
458
+ end_index = string.index("-->", start_index + 4)
459
+ if end_index
460
+ visible << string[index...start_index]
461
+ index = end_index + 3
462
+ elsif keep_incomplete_comment
463
+ visible << string[index...start_index]
464
+ return { text: visible, pending: string[start_index..] }
465
+ else
466
+ visible << string[index..]
467
+ index = string.length
468
+ end
469
+ end
470
+
471
+ visible << string[index..].to_s
472
+ visible = compact_blank_codex_reasoning_lines(visible)
473
+ keep_incomplete_comment ? { text: visible, pending: +"" } : visible
474
+ end
475
+
476
+ def compact_blank_codex_reasoning_lines(text)
477
+ text.to_s.gsub(/(?:[ \t]*\n){3,}/, "\n\n")
478
+ end
479
+
480
+ def compact_codex_reasoning_delta_spacing(existing, text)
481
+ text = compact_blank_codex_reasoning_lines(text)
482
+ trailing_newlines = existing.to_s[/\n+\z/].to_s.length
483
+ leading_newlines = text[/\A\n+/].to_s.length
484
+ excess_newlines = trailing_newlines + leading_newlines - 2
485
+ return text unless excess_newlines.positive? && leading_newlines.positive?
486
+
487
+ text[[excess_newlines, leading_newlines].min..].to_s
488
+ end
489
+
363
490
  def codex_tool_arguments_delta(state, delta)
364
491
  item = active_codex_item(state)
365
492
  return unless item && ["function_call", "custom_tool_call"].include?(item["type"])
@@ -379,10 +506,40 @@ module Kward
379
506
  return unless item.is_a?(Hash)
380
507
 
381
508
  item = merge_codex_item(active_or_known_codex_item(state, item), item)
509
+ item = codex_visible_reasoning_item(state, item)
382
510
  remember_codex_item(state, item)
383
511
  collect_codex_item_output(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
384
512
  state[:active_item_id] = nil if state[:active_item_id] == codex_item_key(item)
385
513
  state[:current_text_content_part] = nil
514
+ state[:current_reasoning_content_part] = nil
515
+ end
516
+
517
+ def codex_visible_reasoning_item(state, item)
518
+ return item unless item.is_a?(Hash) && item["type"] == "reasoning"
519
+
520
+ visible = deep_dup_hash(item)
521
+ sanitize_codex_reasoning_summary_item(visible)
522
+ visible.delete("content") unless state[:show_raw_reasoning]
523
+ return visible unless state[:show_raw_reasoning]
524
+ return visible if visible["summary"].is_a?(Array) && !visible["summary"].empty?
525
+
526
+ text = sanitize_codex_reasoning_text(raw_reasoning_from_codex_items([visible]))
527
+ visible["summary"] = [{ "type" => "summary_text", "text" => text }] unless text.empty?
528
+ visible
529
+ end
530
+
531
+ def sanitize_codex_reasoning_summary_item(item)
532
+ return unless item["summary"].is_a?(Array)
533
+
534
+ pending = +""
535
+ item["summary"].each do |part|
536
+ next unless part.is_a?(Hash) && part.key?("text")
537
+
538
+ pending << part["text"].to_s
539
+ sanitized = sanitize_codex_reasoning_text(pending, keep_incomplete_comment: true)
540
+ part["text"] = sanitized[:text]
541
+ pending.replace(sanitized[:pending])
542
+ end
386
543
  end
387
544
 
388
545
  def collect_codex_item_output(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
@@ -398,7 +555,7 @@ module Kward
398
555
  state[:emitted_message_keys] << key
399
556
  end
400
557
  when "reasoning"
401
- text = reasoning_summary_from_codex_items([item])
558
+ text = sanitize_codex_reasoning_text(reasoning_summary_from_codex_items([item]))
402
559
  if state[:reasoning_summary].empty? && !text.empty?
403
560
  state[:reasoning_summary] << text
404
561
  on_reasoning_delta&.call(text)
@@ -563,5 +720,13 @@ module Kward
563
720
  end
564
721
  end.join
565
722
  end
723
+
724
+ def raw_reasoning_from_codex_items(items)
725
+ items.flat_map do |item|
726
+ next [] unless item.is_a?(Hash) && item["type"] == "reasoning" && item["content"].is_a?(Array)
727
+
728
+ item["content"].filter_map { |part| part["text"] if part.is_a?(Hash) && part["type"] == "reasoning_text" }
729
+ end.join
730
+ end
566
731
  end
567
732
  end
@@ -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;
@@ -9,6 +9,8 @@ require_relative "../agent"
9
9
  require_relative "../config_files"
10
10
  require_relative "../events"
11
11
  require_relative "../model/retry_message"
12
+ require_relative "../plugin_registry"
13
+ require_relative "../prompts/commands"
12
14
  require_relative "../rpc/transcript_normalizer"
13
15
  require_relative "../session_store"
14
16
  require_relative "../tools/tool_call"
@@ -42,10 +44,14 @@ module Kward
42
44
  reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
43
45
  )
44
46
  @session.attach(@conversation)
47
+ hook_context = lifecycle_hook_context
48
+ hook_manager = lifecycle_hook_manager
45
49
  @agent = Agent.new(
46
50
  client: @client,
47
- tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false),
48
- conversation: @conversation
51
+ tool_registry: ToolRegistry.new(workspace: @workspace, ask_user_question_enabled: false, hook_manager: hook_manager, hook_context: hook_context),
52
+ conversation: @conversation,
53
+ hook_manager: hook_manager,
54
+ hook_context: hook_context
49
55
  )
50
56
  @prompt_queue = Queue.new
51
57
  @subscribers = []
@@ -97,6 +103,49 @@ module Kward
97
103
 
98
104
  private
99
105
 
106
+ def lifecycle_hook_manager
107
+ manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(@workspace.root)).manager
108
+ manager.on_result = method(:broadcast_lifecycle_hook_event)
109
+ plugin_registry.hook_handlers.each do |hook|
110
+ manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
111
+ hook.handler.call(event, context)
112
+ end
113
+ end
114
+ manager
115
+ end
116
+
117
+ def broadcast_lifecycle_hook_event(event, result)
118
+ broadcast("hook_event", {
119
+ event: {
120
+ id: event.id,
121
+ name: event.name,
122
+ phase: event.phase,
123
+ timestamp: event.timestamp.iso8601,
124
+ payloadKeys: event.payload.keys.map(&:to_s).sort
125
+ },
126
+ result: {
127
+ decision: result.decision.decision,
128
+ warnings: result.warnings,
129
+ messages: result.messages,
130
+ decisionCount: result.decisions.length
131
+ }
132
+ })
133
+ end
134
+
135
+ def lifecycle_hook_context
136
+ PluginRegistry::Context.new(
137
+ conversation: @conversation,
138
+ args: "",
139
+ session: @session,
140
+ workspace_root: @workspace.root.to_s,
141
+ say_callback: lambda { |message| broadcast("hook_message", { message: message.to_s }) }
142
+ )
143
+ end
144
+
145
+ def plugin_registry
146
+ @plugin_registry ||= PluginRegistry.load(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES)
147
+ end
148
+
100
149
  def start_worker
101
150
  return if @worker_started
102
151
 
@@ -117,6 +166,8 @@ module Kward
117
166
  case event
118
167
  when Events::ReasoningDelta
119
168
  broadcast("reasoning_delta", { delta: event.delta.to_s })
169
+ when Events::ReasoningBoundary
170
+ broadcast("reasoning_boundary", {})
120
171
  when Events::AssistantDelta
121
172
  broadcast("assistant_delta", { delta: event.delta.to_s })
122
173
  when Events::Retry
@@ -0,0 +1,13 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Shared path containment predicate for local filesystem guardrails.
4
+ module PathGuard
5
+ module_function
6
+
7
+ def inside?(path, root)
8
+ path = path.to_s
9
+ root = root.to_s
10
+ path == root || path.start_with?(root + File::SEPARATOR)
11
+ end
12
+ end
13
+ end