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
@@ -30,7 +30,7 @@ module Kward
30
30
 
31
31
  def render_resumed_last_session_transcript(conversation)
32
32
  restore_prompt_transcript do
33
- @prompt.say("\nResumed session: #{@active_session.path}\n")
33
+ runtime_output("Resumed session: #{@active_session.path}")
34
34
  render_conversation_transcript(conversation)
35
35
  end
36
36
  end
@@ -113,27 +113,27 @@ module Kward
113
113
  cleanup_replaced_session(previous_session)
114
114
  update_assistant_prompt(conversation)
115
115
  restore_prompt_transcript do
116
- @prompt.say("\nResumed session: #{@active_session.path}\n")
116
+ runtime_output("Resumed session: #{@active_session.path}")
117
117
  render_conversation_transcript(conversation)
118
118
  end
119
119
  agent = build_interactive_agent(conversation)
120
120
  @prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
121
121
  agent
122
122
  rescue StandardError => e
123
- @prompt.say("\nError: #{e.message}\n")
123
+ runtime_output("Error: #{e.message}")
124
124
  nil
125
125
  end
126
126
 
127
127
  def navigate_session_tree(session_store)
128
128
  return say_sessions_unavailable unless session_store
129
129
  unless @active_session
130
- @prompt.say("\nNo active persisted session.\n")
130
+ runtime_output("No active persisted session.")
131
131
  return nil
132
132
  end
133
133
 
134
134
  tree_items = session_tree_items(session_store)
135
135
  if tree_items.empty?
136
- @prompt.say("\nNo session tree entries found.\n")
136
+ runtime_output("No session tree entries found.")
137
137
  return nil
138
138
  end
139
139
 
@@ -148,19 +148,19 @@ module Kward
148
148
  return nil unless entry
149
149
 
150
150
  selected_text = apply_session_tree_entry(entry)
151
- @prompt.say("\nMoved session tree position to #{entry["id"]}.\n")
151
+ runtime_output("Moved session tree position to #{entry["id"]}.")
152
152
  if selected_text && !selected_text.empty?
153
153
  if @prompt.respond_to?(:prefill_input)
154
154
  @prompt.prefill_input(selected_text)
155
155
  else
156
- @prompt.say("\nSelected text for editing:\n#{selected_text}\n")
156
+ runtime_output("Selected text for editing:\n#{selected_text}")
157
157
  end
158
158
  end
159
159
  agent = reload_active_session(session_store)
160
160
  @prompt.redraw if @prompt.respond_to?(:redraw)
161
161
  agent
162
162
  rescue StandardError => e
163
- @prompt.say("\nSession tree error: #{e.message}\n")
163
+ runtime_output("Session tree error: #{e.message}")
164
164
  nil
165
165
  end
166
166
 
@@ -170,7 +170,7 @@ module Kward
170
170
  end
171
171
 
172
172
  numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
173
- @prompt.say("\nSession tree:\n#{numbered_labels.join("\n")}\n")
173
+ runtime_output((["Session tree:"] + numbered_labels).join("\n"))
174
174
  answer = @prompt.ask("Tree entry number>").to_s.strip
175
175
  answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
176
176
  end
@@ -212,13 +212,13 @@ module Kward
212
212
 
213
213
  def rename_session(argument)
214
214
  unless @active_session
215
- @prompt.say("\nNo active persisted session.\n")
215
+ runtime_output("No active persisted session.")
216
216
  return
217
217
  end
218
218
 
219
219
  @active_session.rename(argument)
220
220
  label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
221
- @prompt.say("\n#{label}\n")
221
+ runtime_output(label)
222
222
  end
223
223
 
224
224
  def clone_session(session_store, agent)
@@ -228,7 +228,7 @@ module Kward
228
228
  @active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
229
229
  reset_session_diff(@active_session.path)
230
230
  cleanup_replaced_session(previous_session)
231
- @prompt.say("\nCloned session: #{@active_session.path}\n")
231
+ runtime_output("Cloned session: #{@active_session.path}")
232
232
  render_conversation_transcript(agent.conversation)
233
233
  agent
234
234
  end
@@ -236,21 +236,21 @@ module Kward
236
236
  def copy_session_text(conversation, argument)
237
237
  target = copy_target(argument)
238
238
  unless target
239
- @prompt.say("\nUsage: /copy [last|transcript]\n")
239
+ runtime_output("Usage: /copy [last|transcript]")
240
240
  return
241
241
  end
242
242
 
243
243
  content = copy_target_content(conversation, target)
244
244
  if content.to_s.empty?
245
- @prompt.say("\nNothing to copy.\n")
245
+ runtime_output("Nothing to copy.")
246
246
  return
247
247
  end
248
248
 
249
249
  result = Clipboard.new(output: $stdout).copy(content)
250
250
  if result.success?
251
- @prompt.say("\nCopied #{copy_target_label(target)}.\n")
251
+ runtime_output("Copied #{copy_target_label(target)}.")
252
252
  else
253
- @prompt.say("\nCopy failed: #{result.message}.\n")
253
+ runtime_output("Copy failed: #{result.message}.")
254
254
  end
255
255
  end
256
256
 
@@ -291,13 +291,13 @@ module Kward
291
291
  def export_session(conversation, argument)
292
292
  path = export_path(argument)
293
293
  File.write(path, markdown_transcript(conversation))
294
- @prompt.say("\nExported session: #{path}\n")
294
+ runtime_output("Exported session: #{path}")
295
295
  rescue StandardError => e
296
- @prompt.say("\nError: #{e.message}\n")
296
+ runtime_output("Error: #{e.message}")
297
297
  end
298
298
 
299
299
  def say_sessions_unavailable
300
- @prompt.say("\nSessions are unavailable for this interactive loop.\n")
300
+ runtime_output("Sessions are unavailable for this interactive loop.")
301
301
  nil
302
302
  end
303
303
 
@@ -316,7 +316,7 @@ module Kward
316
316
  def select_session_path(session_store)
317
317
  sessions = session_store.recent(limit: nil)
318
318
  if sessions.empty?
319
- @prompt.say("\nNo saved sessions found.\n")
319
+ runtime_output("No saved sessions found.")
320
320
  return nil
321
321
  end
322
322
 
@@ -330,7 +330,7 @@ module Kward
330
330
  end
331
331
 
332
332
  numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
333
- @prompt.say("\nRecent sessions:\n#{numbered_labels.join("\n")}\n")
333
+ runtime_output((["Recent sessions:"] + numbered_labels).join("\n"))
334
334
  answer = @prompt.ask("Session number or path>").to_s.strip
335
335
  if answer.match?(/\A\d+\z/)
336
336
  sessions[answer.to_i - 1]&.path
@@ -8,7 +8,7 @@ module Kward
8
8
 
9
9
  def configure_settings(conversation = nil)
10
10
  unless settings_overlay_available?
11
- @prompt.say("\nSettings overlay is unavailable in this prompt.\n")
11
+ runtime_output("Settings overlay is unavailable in this prompt.")
12
12
  return
13
13
  end
14
14
 
@@ -22,7 +22,7 @@ module Kward
22
22
  handle_settings_category(category, conversation)
23
23
  end
24
24
  rescue StandardError => e
25
- @prompt.say("\nSettings error: #{e.message}\n")
25
+ runtime_output("Settings error: #{e.message}")
26
26
  end
27
27
 
28
28
  # Returns the category labels shown by the interactive settings overlay.
@@ -168,12 +168,12 @@ module Kward
168
168
  when /\Aenable memory/, /\Adisable memory/
169
169
  set_memory_enabled(!memory_enabled?)
170
170
  conversation&.refresh_system_message!
171
- @prompt.say("\nMemory #{memory_enabled? ? "enabled" : "disabled"}.\n")
171
+ runtime_output("Memory #{memory_enabled? ? "enabled" : "disabled"}.")
172
172
  when /\Aenable auto-summary/, /\Adisable auto-summary/
173
173
  set_memory_auto_summary_enabled(!memory_auto_summary_enabled?)
174
- @prompt.say("\nMemory auto-summary #{memory_auto_summary_enabled? ? "enabled" : "disabled"}.\n")
174
+ runtime_output("Memory auto-summary #{memory_auto_summary_enabled? ? "enabled" : "disabled"}.")
175
175
  when /\Amanage/
176
- @prompt.say("\nUse /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize.\n")
176
+ runtime_output("Use /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize.")
177
177
  end
178
178
  end
179
179
 
@@ -221,13 +221,13 @@ module Kward
221
221
  @prompt.update_overlay_settings(ConfigFiles.update_overlay_settings("width" => width))
222
222
  when /\Ashow busy help/, /\Ahide busy help/
223
223
  set_composer_busy_help(!composer_busy_help?)
224
- @prompt.say("\nBusy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.\n")
224
+ runtime_output("Busy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
225
225
  when /\Ashow startup banner/, /\Ahide startup banner/
226
226
  set_banner_enabled(!banner_enabled?)
227
- @prompt.say("\nStartup banner #{banner_enabled? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.\n")
227
+ runtime_output("Startup banner #{banner_enabled? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.")
228
228
  when /\Aenable session auto-resume/, /\Adisable session auto-resume/
229
229
  set_session_auto_resume_enabled(!session_auto_resume_enabled?)
230
- @prompt.say("\nSession auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.\n")
230
+ runtime_output("Session auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.")
231
231
  end
232
232
  end
233
233
 
@@ -272,15 +272,15 @@ module Kward
272
272
  case selected.to_s.downcase
273
273
  when /\Aenable web search/, /\Adisable web search/
274
274
  set_web_search_enabled(!web_search_enabled?)
275
- @prompt.say("\nWeb search #{web_search_enabled? ? "enabled" : "disabled"}.\n")
275
+ runtime_output("Web search #{web_search_enabled? ? "enabled" : "disabled"}.")
276
276
  when /\Aweb search provider/
277
277
  configure_web_search_provider
278
278
  when /\Aallow model-provider/, /\Adisallow model-provider/
279
279
  set_web_search_allow_model_providers(!web_search_allow_model_providers?)
280
- @prompt.say("\nModel-provider web search #{web_search_allow_model_providers? ? "enabled" : "disabled"}.\n")
280
+ runtime_output("Model-provider web search #{web_search_allow_model_providers? ? "enabled" : "disabled"}.")
281
281
  when /\Aenable workspace guardrails/, /\Adisable workspace guardrails/
282
282
  set_workspace_guardrails_enabled(!workspace_guardrails_enabled?)
283
- @prompt.say("\nWorkspace guardrails #{workspace_guardrails_enabled? ? "enabled" : "disabled"}.\n")
283
+ runtime_output("Workspace guardrails #{workspace_guardrails_enabled? ? "enabled" : "disabled"}.")
284
284
  end
285
285
  end
286
286
 
@@ -336,9 +336,9 @@ module Kward
336
336
  case selected.to_s.downcase
337
337
  when /\Aenable auto-compaction/, /\Adisable auto-compaction/
338
338
  set_compaction_enabled(!compaction_enabled?)
339
- @prompt.say("\nAuto-compaction #{compaction_enabled? ? "enabled" : "disabled"}.\n")
339
+ runtime_output("Auto-compaction #{compaction_enabled? ? "enabled" : "disabled"}.")
340
340
  else
341
- @prompt.say("\n#{auto_compaction_status_line}\n") if selected.to_s.downcase.start_with?("status")
341
+ runtime_output(auto_compaction_status_line) if selected.to_s.downcase.start_with?("status")
342
342
  end
343
343
  end
344
344
 
@@ -388,7 +388,7 @@ module Kward
388
388
  entries = ConfigFiles.crew_character_labels(personas)
389
389
  choices = entries.map { |key, label| key == personas["default"] ? "#{label} (#{key}, current)" : "#{label} (#{key})" }
390
390
  if choices.empty?
391
- @prompt.say("\nNo configured personas found. Edit #{ConfigFiles.config_path} to add personas.\n")
391
+ runtime_output("No configured personas found. Edit #{ConfigFiles.config_path} to add personas.")
392
392
  return
393
393
  end
394
394
 
@@ -406,10 +406,10 @@ module Kward
406
406
  def show_active_instructions_summary(conversation)
407
407
  label = ConfigFiles.active_persona_label(workspace_root: current_workspace_root, model: current_model_id, config: safely_read_config.to_h)
408
408
  lines = ["Active persona: #{label || "none"}"]
409
- lines << "Global AGENTS.md: #{ConfigFiles.agents_prompt ? "present" : "absent"}"
409
+ lines << "Global PRINCIPLES.md: #{ConfigFiles.agents_prompt ? "present" : "absent"}"
410
410
  lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
411
411
  lines << "Messages: #{conversation.messages.length}" if conversation&.respond_to?(:messages)
412
- @prompt.say("\n#{lines.join("\n")}\n")
412
+ runtime_output(lines.join("\n"))
413
413
  end
414
414
 
415
415
  def configure_logging_settings
@@ -418,7 +418,7 @@ module Kward
418
418
  return unless key
419
419
 
420
420
  set_logging_value(key, !logging_enabled?(key))
421
- @prompt.say("\nLogging #{key.tr("_", " ")} #{logging_enabled?(key) ? "enabled" : "disabled"}.\n")
421
+ runtime_output("Logging #{key.tr("_", " ")} #{logging_enabled?(key) ? "enabled" : "disabled"}.")
422
422
  end
423
423
 
424
424
  def logging_setting_choices
@@ -463,7 +463,7 @@ module Kward
463
463
  "Skills: #{ConfigFiles.skills.length}",
464
464
  "Prompt templates: #{ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).length}"
465
465
  ]
466
- @prompt.say("\n#{lines.join("\n")}\n")
466
+ runtime_output(lines.join("\n"))
467
467
  end
468
468
 
469
469
  def update_nested_config(section, values)
@@ -480,7 +480,7 @@ module Kward
480
480
 
481
481
  def login_interactively
482
482
  unless login_picker_available?
483
- @prompt.say("\nLogin provider picker is unavailable in this prompt.\n")
483
+ runtime_output("Login provider picker is unavailable in this prompt.")
484
484
  return
485
485
  end
486
486
 
@@ -488,20 +488,22 @@ module Kward
488
488
  provider = selected_login_provider(selected)
489
489
  return unless provider
490
490
 
491
- login(provider: provider)
492
- reload_client_config
491
+ run_busy_local_command_and_requeue(activity: "running") do
492
+ login(provider: provider)
493
+ reload_client_config
494
+ end
493
495
  rescue StandardError => e
494
- @prompt.say("\nLogin error: #{e.message}\n")
496
+ runtime_output("Login error: #{e.message}")
495
497
  end
496
498
 
497
499
  def configure_model(conversation = nil, models: nil)
498
500
  unless model_overlay_available?
499
- @prompt.say("\nModel overlay is unavailable in this prompt.\n")
501
+ runtime_output("Model overlay is unavailable in this prompt.")
500
502
  return
501
503
  end
502
504
 
503
- models ||= normalized_available_models
504
- choices = model_choices(models)
505
+ models ||= normalized_available_models(conversation)
506
+ choices = model_choices(models, conversation)
505
507
  selected = @prompt.select("Default model", choices, title: "Models", custom: true)
506
508
  return unless selected
507
509
 
@@ -513,40 +515,42 @@ module Kward
513
515
  refresh_conversation_runtime(conversation)
514
516
  @prompt.redraw if @prompt.respond_to?(:redraw)
515
517
  rescue StandardError => e
516
- @prompt.say("\nModel error: #{e.message}\n")
518
+ runtime_output("Model error: #{e.message}")
517
519
  end
518
520
 
519
521
  # Writes the openrouter catalog output for the terminal CLI flow.
520
522
  def print_openrouter_catalog
521
523
  unless @client.respond_to?(:openrouter_catalog)
522
- @prompt.say("\nOpenRouter catalog is unavailable for this client.\n")
524
+ runtime_output("OpenRouter catalog is unavailable for this client.")
523
525
  return
524
526
  end
525
527
 
526
528
  models = Array(@client.openrouter_catalog)
527
529
  if models.empty?
528
- @prompt.say("\nNo OpenRouter catalog models available.\n")
530
+ runtime_output("No OpenRouter catalog models available.")
529
531
  else
530
532
  ids = models.map { |model| model[:id] || model["id"] || model }.map(&:to_s).reject(&:empty?)
531
- @prompt.say("\nOpenRouter catalog:\n#{ids.join("\n")}\n")
533
+ runtime_output((["OpenRouter catalog:"] + ids).join("\n"))
532
534
  end
533
535
  rescue StandardError => e
534
- @prompt.say("\nOpenRouter catalog error: #{e.message}\n")
536
+ runtime_output("OpenRouter catalog error: #{e.message}")
535
537
  end
536
538
 
537
539
  def configure_reasoning(conversation = nil)
538
540
  unless model_overlay_available?
539
- @prompt.say("\nReasoning overlay is unavailable in this prompt.\n")
541
+ runtime_output("Reasoning overlay is unavailable in this prompt.")
540
542
  return
541
543
  end
542
544
 
543
- choices = ModelInfo.reasoning_effort_choices(current_model_provider, current_model_id)
545
+ provider = conversation&.provider || current_model_provider
546
+ model = conversation&.model || current_model_id
547
+ choices = ModelInfo.reasoning_effort_choices(provider, model)
544
548
  if choices.empty?
545
- @prompt.say("\nReasoning effort is unavailable for #{current_model_provider} #{current_model_id}.\n")
549
+ runtime_output("Reasoning effort is unavailable for #{provider} #{model}.")
546
550
  return
547
551
  end
548
552
 
549
- selected = @prompt.select("Reasoning effort", reasoning_choices(choices), title: "Reasoning")
553
+ selected = @prompt.select("Reasoning effort", reasoning_choices(choices, conversation), title: "Reasoning")
550
554
  return unless selected
551
555
 
552
556
  effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
@@ -557,7 +561,7 @@ module Kward
557
561
  refresh_conversation_runtime(conversation)
558
562
  @prompt.redraw if @prompt.respond_to?(:redraw)
559
563
  rescue StandardError => e
560
- @prompt.say("\nReasoning error: #{e.message}\n")
564
+ runtime_output("Reasoning error: #{e.message}")
561
565
  end
562
566
 
563
567
  def login_picker_available?
@@ -596,10 +600,10 @@ module Kward
596
600
  values.find { |value| choice.to_s.downcase.start_with?(value) }
597
601
  end
598
602
 
599
- def normalized_available_models
600
- current_provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
601
- current_model = @client.respond_to?(:current_model) ? @client.current_model : nil
602
- current_reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
603
+ def normalized_available_models(conversation = current_footer_conversation)
604
+ current_provider = conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
605
+ current_model = conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : nil)
606
+ current_reasoning = conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil)
603
607
  models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
604
608
  models.map do |model|
605
609
  ModelInfo.normalize(
@@ -611,13 +615,15 @@ module Kward
611
615
  end
612
616
  end
613
617
 
614
- def model_choices(models)
618
+ def model_choices(models, conversation = current_footer_conversation)
619
+ current_provider = conversation.provider || current_model_provider
620
+ current_model = conversation.model || current_model_id
615
621
  choices = models.map do |model|
616
622
  label = "#{model[:provider]} #{model[:id]}"
617
623
  label += " (current)" if model[:current]
618
624
  label
619
625
  end
620
- choices.empty? ? ["#{current_model_provider} #{current_model_id} (current)"] : choices.uniq
626
+ choices.empty? ? ["#{current_provider} #{current_model} (current)"] : choices.uniq
621
627
  end
622
628
 
623
629
  def selected_model(selected, models)
@@ -633,8 +639,8 @@ module Kward
633
639
  end
634
640
  end
635
641
 
636
- def reasoning_choices(choices)
637
- current = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort.to_s : ModelInfo::DEFAULT_REASONING_EFFORT
642
+ def reasoning_choices(choices, conversation = current_footer_conversation)
643
+ current = (conversation.reasoning_effort || (@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT)).to_s
638
644
  choices.map do |effort, label|
639
645
  text = label.dup
640
646
  text += " (current)" if current == effort
@@ -66,9 +66,11 @@ module Kward
66
66
  run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
67
67
  [true, nil]
68
68
  else
69
- return run_plugin_command(name, argument, agent) if plugin_command_for(name)
70
-
71
- [false, nil]
69
+ if plugin_command_for(name)
70
+ run_busy_local_command_and_requeue(activity: "running") { run_plugin_command(name, argument, agent) }
71
+ else
72
+ [false, nil]
73
+ end
72
74
  end
73
75
  end
74
76
 
@@ -86,7 +88,7 @@ module Kward
86
88
  lines << "File: #{@active_session.path}"
87
89
  end
88
90
  lines.compact!
89
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{lines.join("\n")}\n")
91
+ runtime_output(lines.join("\n"))
90
92
  end
91
93
 
92
94
  def auto_compaction_status_line
@@ -53,10 +53,10 @@ module Kward
53
53
  # Writes the stats output for the terminal CLI flow.
54
54
  def print_stats(argument)
55
55
  result = TelemetryStats.new.collect(argument)
56
- @prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{TelemetryStats.format(result)}\n")
56
+ runtime_output(TelemetryStats.format(result))
57
57
  rescue ArgumentError => e
58
58
  message = e.message == TelemetryStats::USAGE ? e.message : "#{e.message}\n#{TelemetryStats::USAGE}"
59
- @prompt.say("\n#{message}\n")
59
+ runtime_output(message)
60
60
  end
61
61
 
62
62
  end
@@ -0,0 +1,57 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # System prompt inspection command helpers.
6
+ module Sysprompt
7
+ private
8
+
9
+ def print_sysprompt(arguments)
10
+ raw = parse_sysprompt_arguments(arguments)
11
+ conversation = new_conversation
12
+ content = conversation.system_message.fetch(:content)
13
+ if raw
14
+ @prompt.say(content)
15
+ else
16
+ @prompt.say(render_markdown_transcript(render_sysprompt_sections(conversation)))
17
+ end
18
+ end
19
+
20
+ def parse_sysprompt_arguments(arguments)
21
+ raw = false
22
+ arguments.each do |argument|
23
+ case argument
24
+ when "--raw"
25
+ raw = true
26
+ else
27
+ raise ArgumentError, command_usage("sysprompt")
28
+ end
29
+ end
30
+ raw
31
+ end
32
+
33
+ def render_sysprompt_sections(conversation)
34
+ sections = Prompts.prompt_sections(
35
+ workspace_root: conversation.workspace_root,
36
+ model: conversation.model,
37
+ reasoning_effort: conversation.reasoning_effort,
38
+ memory_context: conversation.memory_context,
39
+ plugin_context: conversation.last_plugin_prompt_context
40
+ )
41
+ lines = ["# Kward System Prompt", "", "Workspace: #{conversation.workspace_root}"]
42
+ lines << "Model: #{[conversation.provider, conversation.model].compact.join(" / ")}" unless conversation.model.to_s.empty?
43
+ lines << "Reasoning effort: #{conversation.reasoning_effort}" unless conversation.reasoning_effort.to_s.empty?
44
+ lines << "Memory: not included; memory context is retrieved per user turn."
45
+ sections.each do |section|
46
+ lines << ""
47
+ lines << "## #{section.fetch(:label)}"
48
+ source = section[:source]
49
+ lines << "Source: #{source}" unless source.to_s.empty?
50
+ lines << ""
51
+ lines << section.fetch(:content)
52
+ end
53
+ lines.join("\n")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -10,7 +10,7 @@ module Kward
10
10
  name = tool_call_name(tool_call)
11
11
  args = tool_call_args(tool_call)
12
12
  text = content.to_s
13
- return error_tool_summary(name, args, text) if text.start_with?("Error:", "Declined:")
13
+ return error_tool_summary(name, args, text) if tool_result_failed?(text)
14
14
 
15
15
  case name
16
16
  when "read_file"
@@ -26,6 +26,10 @@ module Kward
26
26
  end
27
27
  end
28
28
 
29
+ def tool_result_failed?(content)
30
+ content.to_s.start_with?("Error:", "Declined:", "Cancelled.")
31
+ end
32
+
29
33
  def limit_tool_output_lines(content, line_limit)
30
34
  lines = content.to_s.lines
31
35
  return content.to_s if lines.length <= line_limit
data/lib/kward/cli.rb CHANGED
@@ -39,6 +39,7 @@ require_relative "workspace"
39
39
  require_relative "cli/commands"
40
40
  require_relative "cli/auth_commands"
41
41
  require_relative "cli/doctor"
42
+ require_relative "cli/sysprompt"
42
43
  require_relative "cli/stats"
43
44
  require_relative "cli/runtime_helpers"
44
45
  require_relative "cli/slash_commands"
@@ -69,6 +70,7 @@ module Kward
69
70
  include CLI::Commands
70
71
  include CLI::AuthCommands
71
72
  include CLI::Doctor
73
+ include CLI::Sysprompt
72
74
  include CLI::Stats
73
75
  include CLI::RuntimeHelpers
74
76
  include CLI::SlashCommands
@@ -159,6 +161,16 @@ module Kward
159
161
  return
160
162
  end
161
163
 
164
+ if @argv.first == "sysprompt"
165
+ if help_option_arguments?(@argv[1..] || [])
166
+ print_command_help("sysprompt")
167
+ return
168
+ end
169
+
170
+ print_sysprompt(@argv[1..] || [])
171
+ return
172
+ end
173
+
162
174
  if @argv.first == "rpc"
163
175
  if help_option_arguments?(@argv[1..] || [])
164
176
  print_command_help("rpc")
@@ -303,13 +315,13 @@ module Kward
303
315
  pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
304
316
  pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
305
317
  rescue StandardError => e
306
- @prompt.say("\nError: #{e.message}\n")
318
+ runtime_output("Error: #{e.message}")
307
319
  end
308
320
  end
309
321
 
310
322
  agent.conversation
311
323
  rescue Interrupt
312
- @prompt.say("\nGoodbye.")
324
+ runtime_output("Goodbye.")
313
325
  agent&.conversation
314
326
  ensure
315
327
  begin
@@ -14,13 +14,20 @@ module Kward
14
14
  return direct.to_s unless direct.to_s.empty?
15
15
 
16
16
  content = MessageAccess.content(message)
17
- return "" unless content.is_a?(Array)
17
+ if content.is_a?(Array)
18
+ text = content.filter_map do |part|
19
+ type = MessageAccess.value(part, :type)
20
+ next unless ["thinking", "reasoning"].include?(type)
21
+
22
+ MessageAccess.value(part, :thinking) || MessageAccess.value(part, :reasoning) || MessageAccess.value(part, :text)
23
+ end.join("\n")
24
+ return text unless text.empty?
25
+ end
18
26
 
19
- content.filter_map do |part|
20
- type = MessageAccess.value(part, :type)
21
- next unless ["thinking", "reasoning"].include?(type)
27
+ response_items(message).filter_map do |item|
28
+ next unless MessageAccess.value(item, :type) == "reasoning"
22
29
 
23
- MessageAccess.value(part, :thinking) || MessageAccess.value(part, :reasoning) || MessageAccess.value(part, :text)
30
+ response_item_text(MessageAccess.value(item, :summary)).empty? ? response_item_text(MessageAccess.value(item, :content)) : response_item_text(MessageAccess.value(item, :summary))
24
31
  end.join("\n")
25
32
  end
26
33
 
@@ -33,6 +40,18 @@ module Kward
33
40
  end
34
41
  end
35
42
 
43
+ def assistant_content_text(message)
44
+ text = content_text(MessageAccess.content(message))
45
+ return text unless text.empty?
46
+
47
+ response_items(message).filter_map do |item|
48
+ next unless MessageAccess.value(item, :type) == "message"
49
+ next if MessageAccess.value(item, :phase).to_s == "commentary"
50
+
51
+ response_item_text(MessageAccess.value(item, :content))
52
+ end.join
53
+ end
54
+
36
55
  def display_text(message)
37
56
  display_content = MessageAccess.display_content(message)
38
57
  return display_content.to_s unless display_content.nil?
@@ -96,6 +115,18 @@ module Kward
96
115
  end
97
116
  end
98
117
 
118
+ def response_items(message)
119
+ MessageAccess.response_items(message)
120
+ end
121
+
122
+ def response_item_text(parts)
123
+ Array(parts).filter_map do |part|
124
+ next unless part.is_a?(Hash)
125
+
126
+ MessageAccess.value(part, :text) || MessageAccess.value(part, :refusal)
127
+ end.join
128
+ end
129
+
99
130
  def image_part_reference(part)
100
131
  data = MessageAccess.value(part, :data)
101
132
  path = MessageAccess.value(part, :path)