kward 0.68.0 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +34 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +32 -25
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +74 -56
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +18 -0
  11. data/doc/extensibility.md +89 -128
  12. data/doc/getting-started.md +52 -54
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -97
  16. data/doc/releasing.md +3 -1
  17. data/doc/rpc.md +1 -1
  18. data/doc/usage.md +125 -144
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/lib/kward/agent.rb +1 -1
  22. data/lib/kward/cli/commands.rb +10 -3
  23. data/lib/kward/cli/compaction.rb +3 -3
  24. data/lib/kward/cli/interactive_turn.rb +3 -1
  25. data/lib/kward/cli/memory_commands.rb +16 -16
  26. data/lib/kward/cli/plugins.rb +3 -3
  27. data/lib/kward/cli/prompt_interface.rb +15 -13
  28. data/lib/kward/cli/rendering.rb +35 -46
  29. data/lib/kward/cli/runtime_helpers.rb +13 -2
  30. data/lib/kward/cli/sessions.rb +21 -21
  31. data/lib/kward/cli/settings.rb +49 -43
  32. data/lib/kward/cli/slash_commands.rb +6 -4
  33. data/lib/kward/cli/stats.rb +2 -2
  34. data/lib/kward/cli/sysprompt.rb +57 -0
  35. data/lib/kward/cli/tool_summaries.rb +5 -1
  36. data/lib/kward/cli.rb +14 -2
  37. data/lib/kward/cli_transcript_formatter.rb +36 -5
  38. data/lib/kward/compactor.rb +2 -2
  39. data/lib/kward/config_files.rb +45 -10
  40. data/lib/kward/conversation.rb +41 -9
  41. data/lib/kward/memory/manager.rb +131 -14
  42. data/lib/kward/message_access.rb +6 -0
  43. data/lib/kward/model/context_usage.rb +11 -10
  44. data/lib/kward/model/model_info.rb +18 -1
  45. data/lib/kward/model/payloads.rb +89 -10
  46. data/lib/kward/model/stream_parser.rb +258 -25
  47. data/lib/kward/prompt_interface/question_prompt.rb +1 -1
  48. data/lib/kward/prompt_interface/transcript_renderer.rb +20 -11
  49. data/lib/kward/prompts.rb +61 -7
  50. data/lib/kward/rpc/server.rb +7 -2
  51. data/lib/kward/rpc/session_manager.rb +18 -2
  52. data/lib/kward/rpc/session_metrics.rb +2 -2
  53. data/lib/kward/rpc/transcript_normalizer.rb +47 -0
  54. data/lib/kward/session_store.rb +40 -1
  55. data/lib/kward/starter_pack_installer.rb +2 -2
  56. data/lib/kward/tools/fetch_content.rb +41 -0
  57. data/lib/kward/tools/fetch_raw.rb +40 -0
  58. data/lib/kward/tools/registry.rb +9 -2
  59. data/lib/kward/tools/search/web.rb +3 -3
  60. data/lib/kward/tools/search/web_fetch.rb +202 -0
  61. data/lib/kward/tools/tool_call.rb +2 -0
  62. data/lib/kward/version.rb +1 -1
  63. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  64. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  65. data/templates/default/fulldoc/html/js/kward.js +296 -0
  66. data/templates/default/fulldoc/html/setup.rb +8 -0
  67. data/templates/default/layout/html/breadcrumb.erb +11 -0
  68. data/templates/default/layout/html/layout.erb +141 -0
  69. data/templates/default/layout/html/setup.rb +139 -0
  70. metadata +14 -1
@@ -191,6 +191,7 @@ module Kward
191
191
  when "Edit" then "edit_file"
192
192
  when "Bash" then "run_shell_command"
193
193
  when "WebSearch" then "web_search"
194
+ when "WebFetch" then "fetch_content"
194
195
  when "AskUserQuestion" then "ask_user_question"
195
196
  else name.to_s
196
197
  end
@@ -218,7 +219,18 @@ module Kward
218
219
  end
219
220
 
220
221
  def codex_sse_state
221
- { content: +"", reasoning_summary: +"", tool_calls: [], final_output: [], usage: nil }
222
+ {
223
+ content: +"",
224
+ raw_content: +"",
225
+ emitted_message_keys: [],
226
+ reasoning_summary: +"",
227
+ tool_calls: [],
228
+ response_item_keys: [],
229
+ items_by_id: {},
230
+ active_item_id: nil,
231
+ current_text_content_part: nil,
232
+ usage: nil
233
+ }
222
234
  end
223
235
 
224
236
  def process_codex_sse_block(block, state, on_reasoning_delta: nil, on_assistant_delta: nil, usage_normalizer: nil, request_error_class: nil)
@@ -227,30 +239,33 @@ module Kward
227
239
 
228
240
  event = JSON.parse(data)
229
241
  case event["type"]
230
- when "response.output_text.delta"
231
- delta = event["delta"].to_s
232
- state[:content] << delta
233
- on_assistant_delta&.call(delta)
242
+ when "response.output_item.added"
243
+ codex_output_item_added(state, event["item"])
244
+ when "response.content_part.added"
245
+ codex_content_part_added(state, event["part"])
246
+ when "response.output_text.delta", "response.refusal.delta"
247
+ codex_output_text_delta(state, event["delta"], on_assistant_delta: on_assistant_delta)
248
+ when "response.reasoning_summary_part.added"
249
+ codex_reasoning_summary_part_added(state, event["part"])
234
250
  when "response.reasoning_summary_text.delta"
235
- delta = event["delta"].to_s
236
- state[:reasoning_summary] << delta
237
- on_reasoning_delta&.call(delta)
251
+ codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
252
+ when "response.reasoning_summary_part.done"
253
+ codex_reasoning_part_done(state, on_reasoning_delta: on_reasoning_delta)
254
+ when "response.reasoning_text.delta"
255
+ codex_reasoning_delta(state, event["delta"], on_reasoning_delta: on_reasoning_delta)
256
+ when "response.function_call_arguments.delta", "response.custom_tool_call_input.delta"
257
+ codex_tool_arguments_delta(state, event["delta"])
258
+ when "response.function_call_arguments.done"
259
+ codex_tool_arguments_done(state, event["arguments"])
238
260
  when "response.output_item.done"
239
- item = event["item"]
240
- state[:final_output] << item if item.is_a?(Hash)
241
- tool_call = codex_tool_call(item)
242
- state[:tool_calls] << tool_call if tool_call
261
+ codex_output_item_done(state, event["item"], on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
243
262
  when "response.completed"
244
263
  response = event["response"]
245
264
  state[:usage] ||= usage_normalizer&.call(response["usage"]) if response.is_a?(Hash)
246
265
  state[:usage] ||= usage_normalizer&.call(event["usage"])
247
- if state[:content].empty? && response.is_a?(Hash) && response["output"].is_a?(Array)
248
- state[:final_output] = response["output"]
249
- text = text_from_codex_items(state[:final_output])
250
- state[:content] << text
251
- on_assistant_delta&.call(text) unless text.empty?
252
- if state[:reasoning_summary].empty?
253
- state[:reasoning_summary] << reasoning_summary_from_codex_items(state[:final_output])
266
+ if response.is_a?(Hash) && response["output"].is_a?(Array) && state[:response_item_keys].empty?
267
+ response["output"].each do |item|
268
+ codex_output_item_done(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
254
269
  end
255
270
  end
256
271
  when "response.failed", "response.incomplete"
@@ -275,21 +290,239 @@ module Kward
275
290
  end
276
291
  end
277
292
 
278
- def codex_sse_message(state)
279
- if state[:tool_calls].empty?
280
- state[:final_output].each do |item|
281
- tool_call = codex_tool_call(item)
282
- state[:tool_calls] << tool_call if tool_call
293
+ def codex_output_item_added(state, item)
294
+ return unless item.is_a?(Hash)
295
+
296
+ item = deep_dup_hash(item)
297
+ item["content"] = [] if item["type"] == "message" && !item["content"].is_a?(Array)
298
+ item["summary"] = [] if item["type"] == "reasoning" && !item["summary"].is_a?(Array)
299
+ remember_codex_item(state, item)
300
+ state[:active_item_id] = codex_item_key(item)
301
+ state[:current_text_content_part] = nil
302
+ end
303
+
304
+ def codex_content_part_added(state, part)
305
+ item = active_codex_item(state)
306
+ return unless item&.fetch("type", nil) == "message" && part.is_a?(Hash)
307
+ return unless ["output_text", "text", "refusal"].include?(part["type"])
308
+
309
+ item["content"] ||= []
310
+ item["content"] << deep_dup_hash(part)
311
+ state[:current_text_content_part] = item["content"].last
312
+ end
313
+
314
+ def codex_output_text_delta(state, delta, on_assistant_delta: nil)
315
+ text = delta.to_s
316
+ return if text.empty?
317
+
318
+ item = active_codex_item(state)
319
+ if item&.fetch("type", nil) == "message"
320
+ item["content"] ||= [{ "type" => "output_text", "text" => +"" }]
321
+ part = state[:current_text_content_part] || item["content"].last
322
+ part = item["content"].last unless part.is_a?(Hash)
323
+ part["type"] ||= "output_text"
324
+ text_key = part["type"] == "refusal" ? "refusal" : "text"
325
+ part[text_key] = part[text_key].to_s + text
326
+ end
327
+ state[:raw_content] << text
328
+ end
329
+
330
+ def codex_reasoning_summary_part_added(state, part)
331
+ item = active_codex_item(state)
332
+ return unless item&.fetch("type", nil) == "reasoning" && part.is_a?(Hash)
333
+
334
+ item["summary"] ||= []
335
+ item["summary"] << deep_dup_hash(part)
336
+ end
337
+
338
+ def codex_reasoning_delta(state, delta, on_reasoning_delta: nil)
339
+ text = delta.to_s
340
+ return if text.empty?
341
+
342
+ 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
347
+ end
348
+ state[:reasoning_summary] << text
349
+ on_reasoning_delta&.call(text)
350
+ end
351
+
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?
356
+
357
+ text = "\n\n"
358
+ item["summary"].last["text"] = item["summary"].last["text"].to_s + text
359
+ state[:reasoning_summary] << text
360
+ on_reasoning_delta&.call(text)
361
+ end
362
+
363
+ def codex_tool_arguments_delta(state, delta)
364
+ item = active_codex_item(state)
365
+ return unless item && ["function_call", "custom_tool_call"].include?(item["type"])
366
+
367
+ key = item["type"] == "custom_tool_call" ? "input" : "arguments"
368
+ item[key] = item[key].to_s + delta.to_s
369
+ end
370
+
371
+ def codex_tool_arguments_done(state, arguments)
372
+ item = active_codex_item(state)
373
+ return unless item&.fetch("type", nil) == "function_call"
374
+
375
+ item["arguments"] = arguments.to_s
376
+ end
377
+
378
+ def codex_output_item_done(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
379
+ return unless item.is_a?(Hash)
380
+
381
+ item = merge_codex_item(active_or_known_codex_item(state, item), item)
382
+ remember_codex_item(state, item)
383
+ collect_codex_item_output(state, item, on_assistant_delta: on_assistant_delta, on_reasoning_delta: on_reasoning_delta)
384
+ state[:active_item_id] = nil if state[:active_item_id] == codex_item_key(item)
385
+ state[:current_text_content_part] = nil
386
+ end
387
+
388
+ def collect_codex_item_output(state, item, on_assistant_delta: nil, on_reasoning_delta: nil)
389
+ case item["type"]
390
+ when "message"
391
+ text = text_from_codex_items([item])
392
+ return if text.empty? || !codex_streamable_message_item?(item)
393
+
394
+ state[:content] << text
395
+ key = codex_item_key(item)
396
+ unless state[:emitted_message_keys].include?(key)
397
+ on_assistant_delta&.call(text)
398
+ state[:emitted_message_keys] << key
283
399
  end
400
+ when "reasoning"
401
+ text = reasoning_summary_from_codex_items([item])
402
+ if state[:reasoning_summary].empty? && !text.empty?
403
+ state[:reasoning_summary] << text
404
+ on_reasoning_delta&.call(text)
405
+ end
406
+ when "function_call", "custom_tool_call"
407
+ tool_call = codex_tool_call(item)
408
+ state[:tool_calls] << tool_call if tool_call && !state[:tool_calls].any? { |call| call["id"] == tool_call["id"] }
284
409
  end
410
+ end
285
411
 
286
- message = { "role" => "assistant", "content" => state[:content] }
412
+ def active_or_known_codex_item(state, item)
413
+ key = codex_item_key(item)
414
+ known = state[:items_by_id][key]
415
+ return known if known
416
+
417
+ active = active_codex_item(state)
418
+ return active if active && (!codex_item_has_stable_key?(item) || codex_item_key(active) == key)
419
+
420
+ nil
421
+ end
422
+
423
+ def codex_item_has_stable_key?(item)
424
+ item.key?("id") || item.key?("call_id")
425
+ end
426
+
427
+ def active_codex_item(state)
428
+ key = state[:active_item_id]
429
+ key ? state[:items_by_id][key] : nil
430
+ end
431
+
432
+ def remember_codex_item(state, item)
433
+ key = codex_item_key(item)
434
+ if state[:items_by_id].key?(key)
435
+ stored = state[:items_by_id][key]
436
+ stored.replace(merge_codex_item(stored, item))
437
+ else
438
+ state[:items_by_id][key] = item
439
+ state[:response_item_keys] << key
440
+ end
441
+ end
442
+
443
+ def codex_item_key(item)
444
+ item["id"] || item["call_id"] || "item_#{item.object_id}"
445
+ end
446
+
447
+ def merge_codex_item(existing, update)
448
+ return deep_dup_hash(update) unless existing.is_a?(Hash)
449
+
450
+ merged = deep_dup_hash(existing)
451
+ update.each do |key, value|
452
+ next if value.nil?
453
+
454
+ merged[key] = if key == "content" && merged[key].is_a?(Array) && value.is_a?(Array)
455
+ value.empty? ? merged[key] : value
456
+ elsif key == "summary" && merged[key].is_a?(Array) && value.is_a?(Array)
457
+ value.empty? ? merged[key] : value
458
+ elsif key == "arguments" && value.to_s.empty? && !merged[key].to_s.empty?
459
+ merged[key]
460
+ else
461
+ deep_dup(value)
462
+ end
463
+ end
464
+ merged
465
+ end
466
+
467
+ def deep_dup_hash(hash)
468
+ deep_dup(hash)
469
+ end
470
+
471
+ def deep_dup(value)
472
+ case value
473
+ when Hash
474
+ value.each_with_object({}) { |(key, entry), result| result[key] = deep_dup(entry) }
475
+ when Array
476
+ value.map { |entry| deep_dup(entry) }
477
+ when String
478
+ value.dup
479
+ else
480
+ value
481
+ end
482
+ end
483
+
484
+ def codex_sse_message(state)
485
+ message = { "role" => "assistant", "content" => codex_visible_content(state) }
287
486
  message["reasoning_summary"] = state[:reasoning_summary] unless state[:reasoning_summary].empty?
288
487
  message["tool_calls"] = state[:tool_calls] unless state[:tool_calls].empty?
488
+ response_items = codex_response_items(state)
489
+ message["response_items"] = response_items unless response_items.empty?
289
490
  message["usage"] = state[:usage] if state[:usage]
290
491
  message
291
492
  end
292
493
 
494
+ def codex_visible_content(state)
495
+ return state[:content] unless state[:content].empty?
496
+
497
+ response_items = codex_response_items(state)
498
+ visible_text = text_from_codex_items(visible_codex_message_items(response_items, tool_calls: state[:tool_calls]))
499
+ return visible_text unless visible_text.empty?
500
+ return "" unless state[:tool_calls].empty?
501
+
502
+ state[:raw_content]
503
+ end
504
+
505
+ def visible_codex_message_items(items, tool_calls: [])
506
+ messages = Array(items).select { |item| item.is_a?(Hash) && item["type"] == "message" }
507
+ final_messages = messages.select { |item| codex_message_phase(item) == "final_answer" }
508
+ return final_messages unless final_messages.empty?
509
+ return [] unless tool_calls.empty?
510
+
511
+ messages.reject { |item| codex_message_phase(item) == "commentary" }
512
+ end
513
+
514
+ def codex_streamable_message_item?(item)
515
+ codex_message_phase(item) == "final_answer"
516
+ end
517
+
518
+ def codex_message_phase(item)
519
+ item["phase"].to_s
520
+ end
521
+
522
+ def codex_response_items(state)
523
+ state[:response_item_keys].filter_map { |key| state[:items_by_id][key] }
524
+ end
525
+
293
526
  def codex_tool_call(item)
294
527
  return nil unless item.is_a?(Hash) && ["function_call", "custom_tool_call"].include?(item["type"])
295
528
 
@@ -320,7 +320,7 @@ module Kward
320
320
  end
321
321
 
322
322
  def display_question_input(value)
323
- value.to_s.gsub(/\s+/, " ").strip
323
+ value.to_s.gsub(/\s+/, " ")
324
324
  end
325
325
 
326
326
  end
@@ -11,7 +11,7 @@ module Kward
11
11
  prepare_transcript_output_locked unless @restoring_transcript
12
12
  if label && @stream_state.block != label
13
13
  ensure_transcript_block_separator_locked
14
- write_transcript_text_locked("#{colored("#{transcript_label(label)}>", label_color(label), :bold)}\n")
14
+ write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))}\n")
15
15
  @stream_state.start_block(label)
16
16
  end
17
17
  write_transcript_text_locked(delta) unless delta.empty?
@@ -119,21 +119,30 @@ module Kward
119
119
  end
120
120
 
121
121
  def transcript_label(label)
122
- label == "Assistant" ? @assistant_label : label
122
+ case label
123
+ when "Assistant"
124
+ @assistant_label
125
+ when "Tool failed"
126
+ "Tool"
127
+ else
128
+ label
129
+ end
123
130
  end
124
131
 
125
- def label_color(label)
132
+ def label_styles(label)
126
133
  case label
127
- when "Reasoning"
128
- :yellow
134
+ when "Reasoning", "Compaction summary"
135
+ [:gray, :bold]
129
136
  when "Assistant", "Kward"
130
- :green
131
- when "Tool"
132
- :magenta
133
- when "Tool output"
134
- :cyan
137
+ [:green, :bold]
138
+ when "Tool", "Tool output"
139
+ [:cyan, :bold]
140
+ when "Tool failed"
141
+ [:red, :bold]
142
+ when "Retry"
143
+ [:yellow, :bold]
135
144
  else
136
- :blue
145
+ [:gray, :bold]
137
146
  end
138
147
  end
139
148
 
data/lib/kward/prompts.rb CHANGED
@@ -14,18 +14,27 @@ module Kward
14
14
  end
15
15
 
16
16
  def prompt_parts(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
17
- parts = [base_prompt, config_agents_prompt]
18
- parts << memory_context unless memory_context.to_s.empty?
19
- parts << persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now) if include_workspace_personality
20
- parts << plugin_context unless plugin_context.to_s.empty? || !include_workspace_personality
21
- parts << skills_prompt
22
- parts << workspace_agents_prompt(workspace_root)
23
- parts
17
+ prompt_sections(workspace_root: workspace_root, include_workspace_personality: include_workspace_personality, model: model, reasoning_effort: reasoning_effort, now: now, memory_context: memory_context, plugin_context: plugin_context).map { |section| section[:content] }
18
+ end
19
+
20
+ def prompt_sections(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
21
+ sections = [prompt_section("Built-in system prompt", base_prompt)]
22
+ sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
23
+ sections << prompt_section("Memory context", memory_context) unless memory_context.to_s.empty?
24
+ if include_workspace_personality
25
+ sections << prompt_section("Persona", persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now))
26
+ sections << prompt_section("Plugin context", plugin_context) unless plugin_context.to_s.empty?
27
+ end
28
+ sections << prompt_section("Configured skills", skills_prompt, source: ConfigFiles.skills.empty? ? nil : File.join(ConfigFiles.config_dir, "skills"))
29
+ sections << prompt_section(workspace_agents_context_label(workspace_root), workspace_agents_context(workspace_root), source: ConfigFiles.workspace_agents_file?(workspace_root) ? ConfigFiles.workspace_agents_path(workspace_root) : nil)
30
+ sections.compact
24
31
  end
25
32
 
26
33
  def base_prompt
27
34
  <<~PROMPT.strip
28
35
  You are Kward, a concise practical CLI coding agent. You are allowed to use the tools. Help users understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
36
+
37
+ For web research, use web_search to discover sources, then fetch_content for important human-readable pages before relying on them. Use fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources when practical, and cite or mention the URLs you relied on.
29
38
  PROMPT
30
39
  end
31
40
 
@@ -33,14 +42,59 @@ module Kward
33
42
  ConfigFiles.agents_prompt
34
43
  end
35
44
 
45
+ def config_agents_prompt_label
46
+ return "Config principles" if File.exist?(ConfigFiles.config_principles_path)
47
+ return "Config AGENTS.md alias" if File.exist?(ConfigFiles.config_agents_path)
48
+
49
+ "Config principles"
50
+ end
51
+
52
+ def config_agents_prompt_source
53
+ return ConfigFiles.config_principles_path if File.exist?(ConfigFiles.config_principles_path)
54
+ return ConfigFiles.config_agents_path if File.exist?(ConfigFiles.config_agents_path)
55
+
56
+ nil
57
+ end
58
+
36
59
  def persona_prompt(workspace_root = Dir.pwd, model: nil, reasoning_effort: nil, now: Time.now)
37
60
  ConfigFiles.persona_prompt(workspace_root, model: model, reasoning_effort: reasoning_effort, now: now)
38
61
  end
39
62
 
63
+ def workspace_agents_context(workspace_root = Dir.pwd)
64
+ if ConfigFiles.enforce_workspace_agents_file?
65
+ ConfigFiles.workspace_agents_prompt(workspace_root)
66
+ else
67
+ workspace_agents_hint(workspace_root)
68
+ end
69
+ end
70
+
40
71
  def workspace_agents_prompt(workspace_root = Dir.pwd)
41
72
  ConfigFiles.workspace_agents_prompt(workspace_root)
42
73
  end
43
74
 
75
+ def workspace_agents_hint(workspace_root = Dir.pwd)
76
+ return nil unless ConfigFiles.workspace_agents_file?(workspace_root)
77
+
78
+ path = ConfigFiles.workspace_agents_path(workspace_root)
79
+ <<~PROMPT.strip
80
+ Workspace guidance is available in AGENTS.md at the workspace root: #{path}
81
+ For tasks involving this repository, read it before analyzing or modifying project files, and follow it when it does not conflict with higher-priority instructions or the user's request.
82
+ PROMPT
83
+ end
84
+
85
+ def workspace_agents_context_label(workspace_root = Dir.pwd)
86
+ return "Workspace AGENTS.md" unless ConfigFiles.workspace_agents_file?(workspace_root)
87
+ return "Workspace AGENTS.md" if ConfigFiles.enforce_workspace_agents_file?
88
+
89
+ "Workspace AGENTS.md hint"
90
+ end
91
+
92
+ def prompt_section(label, content, source: nil)
93
+ return nil if content.to_s.empty?
94
+
95
+ { label: label, content: content, source: source }
96
+ end
97
+
44
98
  def skills_prompt
45
99
  skills = ConfigFiles.skills
46
100
  return nil if skills.empty?
@@ -560,8 +560,13 @@ module Kward
560
560
  def startup_resources(params)
561
561
  @session_manager.runtime_state(session_id: params.fetch("sessionId"))
562
562
  sections = []
563
- agents_path = File.join(ConfigFiles.config_dir, "AGENTS.md")
564
- sections << { name: "Context", items: ["AGENTS.md"] } if File.exist?(agents_path)
563
+ context_items = []
564
+ if File.exist?(ConfigFiles.config_principles_path)
565
+ context_items << "PRINCIPLES.md"
566
+ elsif File.exist?(ConfigFiles.config_agents_path)
567
+ context_items << "AGENTS.md"
568
+ end
569
+ sections << { name: "Context", items: context_items } unless context_items.empty?
565
570
  skills = ConfigFiles.skills.map(&:name)
566
571
  prompts = ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).map { |template| "/#{template.command}" }
567
572
  plugins = @session_manager.plugin_commands.map { |command| "/#{command.name}" }
@@ -576,7 +576,13 @@ module Kward
576
576
  sessions.each do |rpc_session|
577
577
  rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
578
578
  rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
579
- emit_footer_update(rpc_session)
579
+ if registry.footer_renderer
580
+ start_footer_worker(rpc_session)
581
+ emit_footer_update(rpc_session)
582
+ else
583
+ stop_footer_worker(rpc_session)
584
+ clear_footer_update(rpc_session)
585
+ end
580
586
  end
581
587
  end
582
588
 
@@ -1140,7 +1146,7 @@ module Kward
1140
1146
 
1141
1147
  def emit_footer_update(rpc_session)
1142
1148
  renderer = plugin_registry.footer_renderer
1143
- return unless renderer
1149
+ return clear_footer_update(rpc_session) unless renderer
1144
1150
 
1145
1151
  text = begin
1146
1152
  context = PluginRegistry::Context.new(
@@ -1160,6 +1166,13 @@ module Kward
1160
1166
  @server.notify("ui/footer", { sessionId: rpc_session.id, text: text })
1161
1167
  end
1162
1168
 
1169
+ def clear_footer_update(rpc_session)
1170
+ return if rpc_session.last_footer_text.to_s.empty?
1171
+
1172
+ rpc_session.last_footer_text = ""
1173
+ @server.notify("ui/footer", { sessionId: rpc_session.id, text: "" })
1174
+ end
1175
+
1163
1176
  def emit_turn_event(turn, type, payload)
1164
1177
  event = {
1165
1178
  sequence: turn.next_sequence,
@@ -1210,6 +1223,9 @@ module Kward
1210
1223
  name: info.name,
1211
1224
  firstMessage: info.first_message.to_s,
1212
1225
  messageCount: info.message_count.to_i,
1226
+ provider: info.provider,
1227
+ model: info.model,
1228
+ reasoningEffort: info.reasoning_effort,
1213
1229
  parentId: info.parent_id,
1214
1230
  parentPath: info.parent_path,
1215
1231
  depth: info.depth.to_i,
@@ -34,12 +34,12 @@ module Kward
34
34
 
35
35
  def context_usage(rpc_session, model, client:)
36
36
  context_parts = if client.respond_to?(:current_context_parts)
37
- client.current_context_parts(rpc_session.conversation.messages, rpc_session.tool_registry.schemas)
37
+ client.current_context_parts(rpc_session.conversation.context_messages, rpc_session.tool_registry.schemas)
38
38
  else
39
39
  {
40
40
  provider: model[:provider],
41
41
  model: model[:id],
42
- messages: rpc_session.conversation.messages,
42
+ messages: rpc_session.conversation.context_messages,
43
43
  tools: rpc_session.tool_registry.schemas
44
44
  }
45
45
  end
@@ -61,6 +61,7 @@ module Kward
61
61
 
62
62
  def normalize_assistant_message(message)
63
63
  content = reasoning_first_content(normalize_content(ToolCall.value(message, :content), preserve_thinking: true))
64
+ content = response_item_content(message) if text_content_empty?(content)
64
65
  reasoning = normalize_reasoning_summary(message)
65
66
  content.unshift(reasoning) if reasoning && !thinking_content?(content)
66
67
  tool_calls(message).each do |tool_call|
@@ -154,6 +155,52 @@ module Kward
154
155
  summary.to_s.empty? ? nil : { type: "thinking", thinking: summary.to_s }
155
156
  end
156
157
 
158
+ def response_item_content(message)
159
+ response_items(message).filter_map do |item|
160
+ next unless item.is_a?(Hash)
161
+
162
+ case ToolCall.value(item, :type).to_s
163
+ when "reasoning"
164
+ thinking = reasoning_item_text(item)
165
+ { type: "thinking", thinking: thinking } unless thinking.empty?
166
+ when "message"
167
+ next if ToolCall.value(item, :phase).to_s == "commentary"
168
+
169
+ text = response_message_item_text(item)
170
+ { type: "text", text: text } unless text.empty?
171
+ end
172
+ end
173
+ end
174
+
175
+ def response_items(message)
176
+ items = ToolCall.value(message, :response_items) || ToolCall.value(message, :responseItems)
177
+ items.is_a?(Array) ? items : []
178
+ end
179
+
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")
184
+ end
185
+
186
+ def response_message_item_text(item)
187
+ response_text_parts(ToolCall.value(item, :content)).join
188
+ end
189
+
190
+ def response_text_parts(parts)
191
+ Array(parts).filter_map do |part|
192
+ next unless part.is_a?(Hash)
193
+
194
+ ToolCall.value(part, :text) || ToolCall.value(part, :refusal)
195
+ end.map(&:to_s)
196
+ end
197
+
198
+ def text_content_empty?(content)
199
+ Array(content).all? do |part|
200
+ !part.is_a?(Hash) || !["text", "thinking"].include?(ToolCall.value(part, :type).to_s) || ToolCall.value(part, :text).to_s.empty? && ToolCall.value(part, :thinking).to_s.empty?
201
+ end
202
+ end
203
+
157
204
  def thinking_content?(content)
158
205
  content.any? { |part| thinking_content_part?(part) }
159
206
  end