kward 0.73.1 → 0.75.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +70 -4
  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/mcp.md +72 -0
  11. data/doc/memory.md +1 -1
  12. data/doc/personas.md +2 -2
  13. data/doc/plugins.md +1 -1
  14. data/doc/releasing.md +7 -15
  15. data/doc/rpc.md +108 -15
  16. data/doc/session-management.md +9 -9
  17. data/doc/skills.md +199 -0
  18. data/doc/usage.md +14 -7
  19. data/lib/kward/agent.rb +72 -22
  20. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  21. data/lib/kward/auth/oauth_helpers.rb +105 -0
  22. data/lib/kward/auth/openai_oauth.rb +4 -86
  23. data/lib/kward/cli/commands.rb +12 -2
  24. data/lib/kward/cli/compaction.rb +7 -0
  25. data/lib/kward/cli/doctor.rb +18 -10
  26. data/lib/kward/cli/git.rb +30 -3
  27. data/lib/kward/cli/hook_commands.rb +258 -0
  28. data/lib/kward/cli/interactive_turn.rb +9 -3
  29. data/lib/kward/cli/memory_commands.rb +1 -5
  30. data/lib/kward/cli/plugins.rb +41 -1
  31. data/lib/kward/cli/prompt_interface.rb +32 -3
  32. data/lib/kward/cli/rendering.rb +1 -1
  33. data/lib/kward/cli/runtime_helpers.rb +44 -5
  34. data/lib/kward/cli/sessions.rb +13 -0
  35. data/lib/kward/cli/settings.rb +226 -152
  36. data/lib/kward/cli/slash_commands.rb +81 -42
  37. data/lib/kward/cli/tabs.rb +32 -15
  38. data/lib/kward/cli.rb +64 -4
  39. data/lib/kward/cli_transcript_formatter.rb +0 -7
  40. data/lib/kward/compaction/token_estimator.rb +112 -0
  41. data/lib/kward/compactor.rb +42 -116
  42. data/lib/kward/config_files.rb +152 -27
  43. data/lib/kward/deep_copy.rb +42 -0
  44. data/lib/kward/diff_view_mode.rb +36 -0
  45. data/lib/kward/events.rb +1 -0
  46. data/lib/kward/export_path.rb +2 -6
  47. data/lib/kward/frontmatter.rb +43 -0
  48. data/lib/kward/hooks/audit_log.rb +121 -0
  49. data/lib/kward/hooks/catalog.rb +110 -0
  50. data/lib/kward/hooks/command_handler.rb +66 -0
  51. data/lib/kward/hooks/config_loader.rb +104 -0
  52. data/lib/kward/hooks/decision.rb +102 -0
  53. data/lib/kward/hooks/event.rb +60 -0
  54. data/lib/kward/hooks/http_handler.rb +72 -0
  55. data/lib/kward/hooks/manager.rb +158 -0
  56. data/lib/kward/hooks/matcher.rb +75 -0
  57. data/lib/kward/hooks.rb +16 -0
  58. data/lib/kward/markdown_transcript.rb +4 -3
  59. data/lib/kward/mcp/client.rb +56 -0
  60. data/lib/kward/mcp/server_config.rb +59 -0
  61. data/lib/kward/mcp/stdio_transport.rb +112 -0
  62. data/lib/kward/memory/turn_context.rb +19 -0
  63. data/lib/kward/message_access.rb +2 -2
  64. data/lib/kward/message_text.rb +19 -0
  65. data/lib/kward/model/client.rb +13 -45
  66. data/lib/kward/model/context_usage.rb +10 -4
  67. data/lib/kward/model/copilot_models.rb +44 -0
  68. data/lib/kward/model/model_info.rb +24 -0
  69. data/lib/kward/model/payloads.rb +15 -17
  70. data/lib/kward/model/stream_parser.rb +178 -13
  71. data/lib/kward/pan/server.rb +51 -2
  72. data/lib/kward/path_guard.rb +13 -0
  73. data/lib/kward/plugin_registry.rb +86 -25
  74. data/lib/kward/prompt_interface/editor/controller.rb +124 -4
  75. data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
  76. data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
  77. data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
  78. data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
  79. data/lib/kward/prompt_interface/editor/search.rb +70 -13
  80. data/lib/kward/prompt_interface/editor/state.rb +72 -13
  81. data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
  82. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  83. data/lib/kward/prompt_interface.rb +4 -1
  84. data/lib/kward/prompts/commands.rb +3 -3
  85. data/lib/kward/prompts.rb +7 -7
  86. data/lib/kward/rpc/mcp_status.rb +50 -0
  87. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  88. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  89. data/lib/kward/rpc/server.rb +94 -13
  90. data/lib/kward/rpc/session_manager.rb +258 -33
  91. data/lib/kward/rpc/session_metrics.rb +1 -1
  92. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  93. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  94. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  95. data/lib/kward/rpc/turn_context.rb +109 -0
  96. data/lib/kward/session_store.rb +6 -20
  97. data/lib/kward/session_tree_nodes.rb +98 -18
  98. data/lib/kward/session_tree_renderer.rb +6 -61
  99. data/lib/kward/skills/registry.rb +109 -17
  100. data/lib/kward/tools/mcp_tool.rb +122 -0
  101. data/lib/kward/tools/read_skill.rb +9 -4
  102. data/lib/kward/tools/registry.rb +297 -9
  103. data/lib/kward/tools/search/code.rb +4 -22
  104. data/lib/kward/tools/search/web.rb +28 -2
  105. data/lib/kward/update_check.rb +117 -0
  106. data/lib/kward/version.rb +1 -1
  107. data/lib/kward/workers/manager.rb +43 -3
  108. data/lib/kward/workspace.rb +3 -1
  109. data/templates/default/fulldoc/html/css/kward.css +22 -0
  110. data/templates/default/fulldoc/html/js/kward.js +2 -0
  111. data/templates/default/kward_navigation.rb +4 -2
  112. data/templates/default/layout/html/setup.rb +2 -0
  113. metadata +30 -1
@@ -80,6 +80,10 @@ module Kward
80
80
 
81
81
  # Structured context usage result returned to frontends.
82
82
  class TiktokenTokenCounter
83
+ def initialize
84
+ @encodings = {}
85
+ end
86
+
83
87
  def count(text, model:)
84
88
  text = text.to_s
85
89
  tokenizer = encoding(model)
@@ -93,11 +97,13 @@ module Kward
93
97
  private
94
98
 
95
99
  def encoding(model)
96
- require "tiktoken_ruby"
100
+ @encodings[model.to_s] ||= begin
101
+ require "tiktoken_ruby"
97
102
 
98
- Tiktoken.encoding_for_model(model.to_s) || Tiktoken.get_encoding(encoding_name_for_model(model))
99
- rescue StandardError
100
- Tiktoken.get_encoding(encoding_name_for_model(model)) if defined?(Tiktoken)
103
+ Tiktoken.encoding_for_model(model.to_s) || Tiktoken.get_encoding(encoding_name_for_model(model))
104
+ rescue StandardError
105
+ Tiktoken.get_encoding(encoding_name_for_model(model)) if defined?(Tiktoken)
106
+ end
101
107
  end
102
108
 
103
109
  def rough_count(text)
@@ -0,0 +1,44 @@
1
+ require "json"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Parses and filters GitHub Copilot model catalog responses.
6
+ module CopilotModels
7
+ module_function
8
+
9
+ def parse(body)
10
+ catalog_entries(body).filter_map { |entry| model_id(entry) }.uniq
11
+ rescue JSON::ParserError
12
+ []
13
+ end
14
+
15
+ def supported?(model)
16
+ text = model.to_s
17
+ text.match?(/\Agpt-5(?:\.|-|\z)/) || text.match?(/\A(?:gemini-|gpt-4\.1|oswe-)/)
18
+ end
19
+
20
+ def supported_choices(choices)
21
+ choices.select { |model| supported?(model) }.uniq
22
+ end
23
+
24
+ def resolved_chat_model(configured_model, choices)
25
+ return configured_model if choices.empty? || choices.include?(configured_model)
26
+
27
+ choices.find { |model| supported?(model) } || raise("No Copilot models supported by Kward are available for this account. Kward currently supports Copilot GPT-5 Responses and Gemini/GPT-4.1 chat models.")
28
+ end
29
+
30
+ def catalog_entries(body)
31
+ data = JSON.parse(body.to_s)
32
+ entries = data.is_a?(Hash) ? data["data"] || data["models"] || data["items"] || [] : data
33
+ Array(entries)
34
+ end
35
+
36
+ def model_id(entry)
37
+ return entry.to_s.strip unless entry.is_a?(Hash)
38
+ return nil if entry.key?("model_picker_enabled") && entry["model_picker_enabled"] == false
39
+
40
+ id = entry["id"] || entry["model"] || entry["name"]
41
+ id.to_s.strip unless id.to_s.strip.empty?
42
+ end
43
+ end
44
+ end
@@ -412,6 +412,30 @@ module Kward
412
412
  }.compact
413
413
  end
414
414
 
415
+ def normalize_available(models, current_provider:, current_model:, current_reasoning_effort: nil)
416
+ Array(models).map do |model|
417
+ normalize(
418
+ model,
419
+ current_provider: current_provider,
420
+ current_model: current_model,
421
+ current_reasoning_effort: current_reasoning_effort
422
+ )
423
+ end
424
+ end
425
+
426
+ def current_payload(provider:, model:, reasoning_effort: nil, context_window: nil)
427
+ normalize(
428
+ {
429
+ provider: provider,
430
+ id: model,
431
+ model: model,
432
+ reasoningEffort: reasoning_effort,
433
+ contextWindow: context_window,
434
+ current: true
435
+ }
436
+ )
437
+ end
438
+
415
439
  def boolean_value(value, default: false)
416
440
  return default if value.nil?
417
441
  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_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
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)
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_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|
@@ -76,6 +77,7 @@ module Kward
76
77
  end
77
78
  cancellation&.raise_if_cancelled!
78
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?
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,17 +220,21 @@ 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
@@ -252,7 +258,9 @@ module Kward
252
258
  when "response.reasoning_summary_part.done"
253
259
  codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
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,20 +342,53 @@ 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
 
342
359
  item = active_codex_item(state)
343
- if item&.fetch("type", nil) == "reasoning"
344
- item["summary"] ||= []
345
- item["summary"] << { "type" => "summary_text", "text" => +"" } if item["summary"].empty?
346
- item["summary"].last["text"] = item["summary"].last["text"].to_s + text
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
347
367
  end
348
- state[:reasoning_summary] << text
349
- on_reasoning_delta&.call(text)
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?
350
392
  end
351
393
 
352
394
  def codex_reasoning_part_done(state, on_reasoning_delta: nil)
@@ -354,12 +396,97 @@ module Kward
354
396
  return unless item&.fetch("type", nil) == "reasoning"
355
397
  return if item["summary"].to_a.empty?
356
398
 
399
+ flush_codex_reasoning_delta(state, on_reasoning_delta: on_reasoning_delta)
357
400
  text = "\n\n"
358
401
  item["summary"].last["text"] = item["summary"].last["text"].to_s + text
359
402
  state[:reasoning_summary] << text
360
403
  on_reasoning_delta&.call(text)
361
404
  end
362
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
+
410
+ item = active_codex_item(state)
411
+ if item&.fetch("type", nil) == "reasoning"
412
+ item["summary"] ||= []
413
+ item["summary"] << { "type" => "summary_text", "text" => +"" } if item["summary"].empty?
414
+ item["summary"].last["text"] = item["summary"].last["text"].to_s + text
415
+ end
416
+ state[:reasoning_summary] << text
417
+ on_reasoning_delta&.call(text)
418
+ end
419
+
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?
423
+
424
+ state[:raw_reasoning_visible] << text
425
+ state[:reasoning_summary] << text
426
+ on_reasoning_delta&.call(text)
427
+ end
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
@@ -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
 
@@ -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