kward 0.79.0 → 0.80.1

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 (126) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +6 -0
  3. data/CHANGELOG.md +65 -1
  4. data/Gemfile.lock +2 -2
  5. data/README.md +31 -38
  6. data/doc/agent-tools.md +9 -10
  7. data/doc/api.md +3 -3
  8. data/doc/authentication.md +79 -110
  9. data/doc/code-search.md +1 -1
  10. data/doc/composer.md +1 -1
  11. data/doc/configuration.md +69 -26
  12. data/doc/context-budgeting.md +6 -6
  13. data/doc/context-tools.md +2 -2
  14. data/doc/editor.md +7 -7
  15. data/doc/extensibility.md +7 -29
  16. data/doc/files.md +6 -6
  17. data/doc/getting-started.md +2 -2
  18. data/doc/git.md +8 -2
  19. data/doc/lifecycle-hooks.md +5 -4
  20. data/doc/local-models.md +2 -2
  21. data/doc/mcp.md +5 -5
  22. data/doc/permissions.md +13 -4
  23. data/doc/plugins.md +39 -4
  24. data/doc/prompt-templates.md +105 -0
  25. data/doc/providers.md +140 -0
  26. data/doc/releasing.md +10 -2
  27. data/doc/rpc.md +54 -31
  28. data/doc/sandboxing.md +22 -0
  29. data/doc/security.md +22 -7
  30. data/doc/session-management.md +2 -4
  31. data/doc/skills.md +2 -0
  32. data/doc/tabs.md +37 -0
  33. data/doc/telegram.md +138 -0
  34. data/doc/transports.md +209 -0
  35. data/doc/usage.md +11 -3
  36. data/doc/web-search.md +3 -3
  37. data/doc/workspace-tools.md +2 -2
  38. data/examples/plugins/stardate_footer.rb +10 -0
  39. data/examples/plugins/telegram/plugin.rb +39 -0
  40. data/examples/plugins/telegram/telegram_api.rb +136 -0
  41. data/examples/plugins/telegram/telegram_transport.rb +304 -0
  42. data/lib/kward/agent.rb +11 -6
  43. data/lib/kward/ansi.rb +1 -0
  44. data/lib/kward/auth/api_key_store.rb +99 -0
  45. data/lib/kward/cli/auth_commands.rb +72 -13
  46. data/lib/kward/cli/commands.rb +7 -0
  47. data/lib/kward/cli/git.rb +31 -8
  48. data/lib/kward/cli/memory_commands.rb +2 -2
  49. data/lib/kward/cli/plugins.rb +1 -0
  50. data/lib/kward/cli/prompt_interface.rb +44 -1
  51. data/lib/kward/cli/rendering.rb +6 -2
  52. data/lib/kward/cli/runtime_helpers.rb +5 -2
  53. data/lib/kward/cli/sessions.rb +1 -1
  54. data/lib/kward/cli/settings.rb +97 -33
  55. data/lib/kward/cli/slash_commands.rb +1 -1
  56. data/lib/kward/cli/tabs.rb +103 -37
  57. data/lib/kward/cli/transports.rb +67 -0
  58. data/lib/kward/cli/worktrees.rb +360 -0
  59. data/lib/kward/cli.rb +22 -0
  60. data/lib/kward/compactor.rb +7 -2
  61. data/lib/kward/config_files.rb +51 -12
  62. data/lib/kward/conversation.rb +13 -7
  63. data/lib/kward/git_worktree_manager.rb +291 -0
  64. data/lib/kward/hooks/audit_log.rb +5 -2
  65. data/lib/kward/model/azure_openai_config.rb +54 -0
  66. data/lib/kward/model/catalog.rb +113 -0
  67. data/lib/kward/model/client.rb +225 -20
  68. data/lib/kward/model/model_info.rb +49 -0
  69. data/lib/kward/model/payloads.rb +63 -4
  70. data/lib/kward/model/provider_catalog.rb +114 -0
  71. data/lib/kward/model/sources.rb +70 -0
  72. data/lib/kward/model/stream_parser.rb +83 -13
  73. data/lib/kward/permissions/policy.rb +18 -4
  74. data/lib/kward/plugin_chat_runtime.rb +374 -0
  75. data/lib/kward/plugin_registry.rb +99 -19
  76. data/lib/kward/private_file.rb +9 -3
  77. data/lib/kward/prompt_interface/approval_prompt.rb +2 -0
  78. data/lib/kward/prompt_interface/composer_renderer.rb +1 -5
  79. data/lib/kward/prompt_interface/editor/auto_indent.rb +46 -0
  80. data/lib/kward/prompt_interface/editor/buffer.rb +18 -4
  81. data/lib/kward/prompt_interface/editor/controller.rb +7 -7
  82. data/lib/kward/prompt_interface/editor/modes/vibe.rb +2 -2
  83. data/lib/kward/prompt_interface/editor/renderer.rb +8 -8
  84. data/lib/kward/prompt_interface/editor/state.rb +3 -9
  85. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +219 -19
  86. data/lib/kward/prompt_interface/file_overlay.rb +2 -2
  87. data/lib/kward/prompt_interface/git_prompt.rb +7 -3
  88. data/lib/kward/prompt_interface/interactive/controller.rb +4 -4
  89. data/lib/kward/prompt_interface/interactive/renderer.rb +4 -1
  90. data/lib/kward/prompt_interface/interactive/state.rb +2 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +25 -6
  92. data/lib/kward/prompt_interface/runtime_state.rb +22 -6
  93. data/lib/kward/prompt_interface/screen.rb +3 -0
  94. data/lib/kward/prompt_interface/selection_prompt.rb +7 -1
  95. data/lib/kward/prompt_interface/transcript_buffer.rb +24 -2
  96. data/lib/kward/prompt_interface.rb +53 -12
  97. data/lib/kward/prompts/commands.rb +1 -1
  98. data/lib/kward/prompts/templates.rb +11 -6
  99. data/lib/kward/prompts.rb +6 -5
  100. data/lib/kward/rpc/auth_manager.rb +112 -152
  101. data/lib/kward/rpc/config_manager.rb +46 -6
  102. data/lib/kward/rpc/plugin_chat_manager.rb +55 -196
  103. data/lib/kward/rpc/prompt_bridge.rb +8 -4
  104. data/lib/kward/rpc/redactor.rb +1 -1
  105. data/lib/kward/rpc/server.rb +60 -10
  106. data/lib/kward/rpc/session_manager.rb +109 -30
  107. data/lib/kward/session_store.rb +84 -24
  108. data/lib/kward/skills/registry.rb +16 -11
  109. data/lib/kward/tab_driver.rb +6 -3
  110. data/lib/kward/telemetry/logger.rb +5 -2
  111. data/lib/kward/tools/git_commit.rb +39 -0
  112. data/lib/kward/tools/registry.rb +7 -2
  113. data/lib/kward/tools/tool_call.rb +1 -0
  114. data/lib/kward/transport/gateway.rb +253 -0
  115. data/lib/kward/transport/host.rb +343 -0
  116. data/lib/kward/transport/manager.rb +179 -0
  117. data/lib/kward/transport/plugin_chat_gateway.rb +186 -0
  118. data/lib/kward/transport/runtime.rb +41 -0
  119. data/lib/kward/transport/store.rb +101 -0
  120. data/lib/kward/transport.rb +204 -0
  121. data/lib/kward/version.rb +1 -1
  122. data/lib/kward/workspace_factory.rb +19 -2
  123. data/templates/default/fulldoc/html/js/kward.js +2 -0
  124. data/templates/default/kward_navigation.rb +7 -2
  125. data/templates/default/layout/html/setup.rb +4 -0
  126. metadata +26 -1
@@ -55,6 +55,9 @@ module Kward
55
55
  controller = @interactive_state[:controller]
56
56
  return false if controller.exited?
57
57
 
58
+ return true if controller.dirty?
59
+ return false unless controller.tickable?
60
+
58
61
  now = monotonic_now
59
62
  interval = 1.0 / controller.fps
60
63
  elapsed = now - @last_interactive_tick
@@ -64,7 +67,7 @@ module Kward
64
67
  @last_interactive_tick += steps * interval
65
68
  result = controller.invoke_tick
66
69
  controller.exit if result == :exit
67
- true
70
+ controller.dirty?
68
71
  end
69
72
  end
70
73
  end
@@ -7,8 +7,8 @@ module Kward
7
7
  module InteractiveState
8
8
  private
9
9
 
10
- def interactive_canvas_width
11
- [screen_width - 6, 1].max
10
+ def interactive_canvas_width(width = screen_width)
11
+ [width - 6, 1].max
12
12
  end
13
13
 
14
14
  def route_interactive_key(key)
@@ -125,6 +125,14 @@ module Kward
125
125
  insert_selected_project_browser_mention
126
126
  elsif text == "/" && !project_browser_search_active?
127
127
  activate_project_browser_search
128
+ elsif text == "j" && !project_browser_search_active?
129
+ select_next_project_browser_row
130
+ elsif text == "k" && !project_browser_search_active?
131
+ select_previous_project_browser_row
132
+ elsif text == "h" && !project_browser_search_active?
133
+ collapse_selected_project_browser_row
134
+ elsif text == "l" && !project_browser_search_active?
135
+ expand_selected_project_browser_row
128
136
  elsif project_browser_search_active?
129
137
  project_browser_append_search(text)
130
138
  else
@@ -148,6 +156,14 @@ module Kward
148
156
  toggle_project_browser_search
149
157
  when "/"
150
158
  activate_project_browser_search
159
+ when "j"
160
+ project_browser_search_active? ? project_browser_append_search(key) : select_next_project_browser_row
161
+ when "k"
162
+ project_browser_search_active? ? project_browser_append_search(key) : select_previous_project_browser_row
163
+ when "h"
164
+ project_browser_search_active? ? project_browser_append_search(key) : collapse_selected_project_browser_row
165
+ when "l"
166
+ project_browser_search_active? ? project_browser_append_search(key) : expand_selected_project_browser_row
151
167
  else
152
168
  project_browser_append_search(key) if project_browser_search_active? && printable_key?(key)
153
169
  end
@@ -319,7 +335,7 @@ module Kward
319
335
  if rows.empty?
320
336
  lines << overlay_text_line(project_browser_empty_message, :muted)
321
337
  else
322
- visible = visible_project_browser_rows(rows, height: height)
338
+ visible = visible_project_browser_rows(rows, height: height, width: width)
323
339
  visible[:rows].each_with_index do |row, offset|
324
340
  index = visible[:start] + offset
325
341
  lines << overlay_choice_line(project_browser_row_text(row), selected: index == @project_browser_state[:selection_index])
@@ -476,7 +492,7 @@ module Kward
476
492
  end
477
493
 
478
494
  def project_browser_workspace_root
479
- ConfigFiles.canonical_workspace_root(Dir.pwd)
495
+ ConfigFiles.canonical_workspace_root(prompt_workspace_root)
480
496
  end
481
497
 
482
498
  def restored_project_browser_expanded_paths(paths, saved_state)
@@ -536,14 +552,17 @@ module Kward
536
552
  @project_browser_state[:selection_index] = [[@project_browser_state[:selection_index], 0].max, rows.length - 1].min unless rows.empty?
537
553
  end
538
554
 
539
- def visible_project_browser_rows(rows, height: screen_height)
540
- max_rows = max_project_browser_rows(height)
555
+ def visible_project_browser_rows(rows, height: screen_height, width: screen_width)
556
+ max_rows = max_project_browser_rows(height, width: width)
541
557
  start = centered_list_window_start(@project_browser_state[:selection_index], rows.length, max_rows)
542
558
  { start: start, rows: rows[start, max_rows] || [] }
543
559
  end
544
560
 
545
- def max_project_browser_rows(height)
546
- [[height - 8, 4].max, 20].min
561
+ def max_project_browser_rows(height, width: screen_width)
562
+ composer_bottom_rows = @tabs.empty? ? 1 : tab_border_rows(width).length
563
+ footer_rows = footer_text.empty? ? 0 : 1
564
+ attachment_rows = attachment_badge_texts.length
565
+ [height - 7 - composer_bottom_rows - footer_rows - attachment_rows, 4].max
547
566
  end
548
567
 
549
568
  def default_project_browser_expanded_paths(paths)
@@ -40,14 +40,30 @@ module Kward
40
40
 
41
41
  def tick_footer_locked
42
42
  return false unless @footer && @started && @asking
43
+ return false unless footer_refresh_due?
43
44
 
44
- now = monotonic_now
45
- elapsed = now - @last_footer_refresh
46
- return false if elapsed < FOOTER_REFRESH_INTERVAL
45
+ previous_text = @cached_footer_text
46
+ refresh_footer_text_locked
47
+ previous_text != @cached_footer_text
48
+ end
47
49
 
48
- steps = (elapsed / FOOTER_REFRESH_INTERVAL).floor
49
- @last_footer_refresh += steps * FOOTER_REFRESH_INTERVAL
50
- true
50
+ def cached_footer_text
51
+ return "" unless @footer
52
+
53
+ refresh_footer_text_locked if footer_refresh_due?
54
+ @cached_footer_text.to_s
55
+ end
56
+
57
+ def footer_refresh_due?
58
+ @last_footer_refresh.nil? || monotonic_now - @last_footer_refresh >= FOOTER_REFRESH_INTERVAL
59
+ end
60
+
61
+ def refresh_footer_text_locked
62
+ @cached_footer_text = @footer.call.to_s.gsub(/\s+/, " ").strip
63
+ rescue StandardError
64
+ @cached_footer_text = ""
65
+ ensure
66
+ @last_footer_refresh = monotonic_now
51
67
  end
52
68
 
53
69
  def cached_composer_status_text
@@ -114,6 +114,9 @@ module Kward
114
114
  @reserved_rows = 0
115
115
  @last_width = current_width
116
116
  @last_height = current_height
117
+ if interactive_active_locked?
118
+ @interactive_state[:controller].resize(width: interactive_canvas_width(current_width))
119
+ end
117
120
  redraw_screen_locked(width: current_width, height: current_height)
118
121
  true
119
122
  end
@@ -626,7 +626,13 @@ module Kward
626
626
 
627
627
  def selection_matches
628
628
  choices = @select_state ? @select_state[:choices] : []
629
- filter = (select_search_active? || !composer_input.strip.empty?) ? composer_input.downcase.strip : ""
629
+ filter = if select_input_active?
630
+ ""
631
+ elsif select_search_active? || !composer_input.strip.empty?
632
+ composer_input.downcase.strip
633
+ else
634
+ ""
635
+ end
630
636
  matches = filter.empty? ? choices : choices.select { |choice| choice.downcase.include?(filter) }
631
637
  clamp_selection_index(matches.length)
632
638
  matches
@@ -4,10 +4,14 @@ module Kward
4
4
  class PromptInterface
5
5
  # Bounded in-memory transcript buffer used by the prompt interface.
6
6
  class TranscriptBuffer
7
+ TRIM_RATIO = 0.9
8
+ TRIM_BOUNDARY_SEARCH = 1_024
9
+
7
10
  attr_reader :text
8
11
 
9
12
  def initialize(limit:)
10
- @limit = limit
13
+ @limit = [limit.to_i, 1].max
14
+ @trim_target = [(@limit * TRIM_RATIO).floor, 1].max
11
15
  @text = +""
12
16
  @display_rows_cache_width = nil
13
17
  @display_rows_cache = nil
@@ -42,7 +46,7 @@ module Kward
42
46
 
43
47
  def append(text)
44
48
  @text << ANSI.sanitize_transcript(text)
45
- @text = @text[-@limit, @limit] if @text.length > @limit
49
+ trim_to_limit if @text.length > @limit
46
50
  invalidate_display_rows_cache
47
51
  @text
48
52
  end
@@ -77,6 +81,24 @@ module Kward
77
81
  @display_rows_cache_width = nil
78
82
  @display_rows_cache = nil
79
83
  end
84
+
85
+ private
86
+
87
+ def trim_to_limit
88
+ cutoff = @text.length - @trim_target
89
+ newline = @text.index("\n", cutoff)
90
+ cutoff = newline + 1 if newline && newline - cutoff <= TRIM_BOUNDARY_SEARCH
91
+ cutoff = safe_escape_boundary(cutoff)
92
+ @text = @text[cutoff..].to_s
93
+ end
94
+
95
+ def safe_escape_boundary(cutoff)
96
+ escape_start = @text.rindex("\e", cutoff)
97
+ return cutoff unless escape_start
98
+
99
+ escape = ANSI.escape_sequence_at(@text, escape_start)
100
+ escape && escape_start + escape.length > cutoff ? escape_start + escape.length : cutoff
101
+ end
80
102
  end
81
103
  end
82
104
  end
@@ -128,12 +128,13 @@ module Kward
128
128
  end
129
129
  end
130
130
 
131
- def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
131
+ def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, project_browser_icon_theme: "off", footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil, tab_keybindings: nil, prompt_history: nil, workspace_root: Dir.pwd, editor_mode: nil, editor_mode_source: nil, editor_auto_indent: true, editor_auto_indent_source: nil, editor_auto_close_pairs: true, editor_auto_close_pairs_source: nil, editor_soft_wrap: true, editor_soft_wrap_source: nil, editor_bar_cursor: true, editor_bar_cursor_source: nil, editor_line_numbers: "absolute", editor_line_numbers_source: nil, diff_view: "auto", diff_view_source: nil)
132
132
  @input_io = input
133
133
  @output_io = output
134
134
  @reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
135
135
  @mutex = Mutex.new
136
136
  @prompt_history = prompt_history
137
+ @workspace_root = File.expand_path(workspace_root.to_s.empty? ? Dir.pwd : workspace_root)
137
138
  @composer = ComposerState.new
138
139
  load_history(@prompt_history.values) if @prompt_history
139
140
  self.composer_input = @composer.input
@@ -146,7 +147,8 @@ module Kward
146
147
  @steered_count = 0
147
148
  @spinner_frame_index = 0
148
149
  @last_spinner_tick = monotonic_now
149
- @last_footer_refresh = monotonic_now
150
+ @last_footer_refresh = nil
151
+ @cached_footer_text = nil
150
152
  @last_composer_status_refresh = 0.0
151
153
  @cached_composer_status_text = nil
152
154
  @prompt_label = "You>"
@@ -340,10 +342,23 @@ module Kward
340
342
  @mutex.synchronize { editor_active? }
341
343
  end
342
344
 
343
- def edit_file(path, base_dir: Dir.pwd, allow_new: true)
345
+ def update_workspace_root(root, prompt_history: nil)
346
+ @mutex.synchronize do
347
+ expanded_root = File.expand_path(root.to_s.empty? ? Dir.pwd : root)
348
+ return false if expanded_root == @workspace_root
349
+
350
+ @workspace_root = expanded_root
351
+ @prompt_history = prompt_history if prompt_history
352
+ load_history(@prompt_history.values) if @prompt_history
353
+ reset_workspace_file_state_locked
354
+ true
355
+ end
356
+ end
357
+
358
+ def edit_file(path, base_dir: nil, allow_new: true)
344
359
  start(render: false)
345
360
  opened = @mutex.synchronize do
346
- open_editor(path, allow_new: allow_new, base_dir: base_dir, restrict_to_workspace: false).tap do
361
+ open_editor(path, allow_new: allow_new, base_dir: base_dir || prompt_workspace_root, restrict_to_workspace: false).tap do
347
362
  render_prompt_locked
348
363
  end
349
364
  end
@@ -610,9 +625,14 @@ module Kward
610
625
 
611
626
  def update_tabs(labels:, active_index: 0)
612
627
  @mutex.synchronize do
613
- @tabs = Array(labels).map { |label| normalize_tab_label(label) }
614
- @active_tab_index = active_index.to_i
628
+ normalized_labels = Array(labels).map { |label| normalize_tab_label(label) }
629
+ normalized_index = active_index.to_i
630
+ return false if @tabs == normalized_labels && @active_tab_index == normalized_index
631
+
632
+ @tabs = normalized_labels
633
+ @active_tab_index = normalized_index
615
634
  render_prompt_locked if @started && @asking
635
+ true
616
636
  end
617
637
  end
618
638
 
@@ -625,16 +645,19 @@ module Kward
625
645
  end
626
646
  end
627
647
 
628
- def tab_view_snapshot
648
+ def tab_view_snapshot(include_transcript: true)
629
649
  @mutex.synchronize do
630
- {
650
+ snapshot = {
631
651
  composer: @composer.dup,
632
652
  prompt_label: @prompt_label.dup,
633
- editor_state: @editor_state&.dup,
634
- transcript_buffer: @transcript_buffer.dup,
635
- transcript_viewport_rows: @transcript_viewport_rows,
636
- stream_state: @stream_state.dup
653
+ editor_state: @editor_state&.dup
637
654
  }
655
+ if include_transcript
656
+ snapshot[:transcript_buffer] = @transcript_buffer.dup
657
+ snapshot[:transcript_viewport_rows] = @transcript_viewport_rows
658
+ snapshot[:stream_state] = @stream_state.dup
659
+ end
660
+ snapshot
638
661
  end
639
662
  end
640
663
 
@@ -859,6 +882,8 @@ module Kward
859
882
 
860
883
  def refresh_composer_status
861
884
  @mutex.synchronize do
885
+ @cached_footer_text = nil
886
+ @last_footer_refresh = nil
862
887
  @cached_composer_status_text = nil
863
888
  @last_composer_status_refresh = 0.0
864
889
  render_prompt_locked if @started && @asking
@@ -879,6 +904,22 @@ module Kward
879
904
 
880
905
  private
881
906
 
907
+ def prompt_workspace_root
908
+ @workspace_root
909
+ end
910
+
911
+ def reset_workspace_file_state_locked
912
+ @file_mention_paths = nil
913
+ @file_mention_path_entries_paths = nil
914
+ @file_mention_path_entries = nil
915
+ @project_browser_state = nil
916
+ @project_browser_tree_paths = nil
917
+ @project_browser_tree = nil
918
+ @file_overlay_dismissed_token = nil
919
+ @file_open_dismissed_token = nil
920
+ @file_editor_open_status = nil
921
+ end
922
+
882
923
  def modal_active_locked?
883
924
  @question_prompt_active || !@question_state.nil? || !@select_state.nil? || !@git_state.nil?
884
925
  end
@@ -30,7 +30,7 @@ module Kward
30
30
  { name: "shell", description: "Open the embedded Kward shell.", argument_hint: "" },
31
31
  { name: "scratchpad", description: "Open an unsaved editor buffer.", argument_hint: "[text|markdown|ruby]" },
32
32
  { name: "pty", description: "Run a command in an interactive PTY passthrough session.", argument_hint: "<command>" },
33
- { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name]" },
33
+ { name: "tab", description: "Manage tabs.", argument_hint: "[1-n|move|close|new|name|worktree]" },
34
34
  { name: "status", description: "Show the current status message.", argument_hint: "" },
35
35
  { name: "stats", description: "Show telemetry logging stats.", argument_hint: "[range]" },
36
36
  { name: "sandbox", description: "Inspect or configure model command sandboxing.", argument_hint: "[status|off|read_only|workspace_write|network allow|network deny]" },
@@ -4,10 +4,11 @@ module Kward
4
4
  module Prompts
5
5
  # Parsed prompt template loaded from disk.
6
6
  class Templates
7
- def initialize(config_dir:, template_class:, markdown_parser:)
7
+ def initialize(config_dir:, template_class:, markdown_parser:, warning_sink: nil)
8
8
  @config_dir = config_dir
9
9
  @template_class = template_class
10
10
  @markdown_parser = markdown_parser
11
+ @warning_sink = warning_sink
11
12
  end
12
13
 
13
14
  def prompt_templates(reserved_commands: [])
@@ -21,11 +22,11 @@ module Kward
21
22
  next unless template
22
23
 
23
24
  if reserved.include?(template.command)
24
- warn "Warning: skipping Kward prompt command /#{template.command}: reserved command"
25
+ emit_warning "Warning: skipping Kward prompt command /#{template.command}: reserved command"
25
26
  next
26
27
  end
27
28
  if seen[template.command]
28
- warn "Warning: skipping duplicate Kward prompt command /#{template.command}: #{path}"
29
+ emit_warning "Warning: skipping duplicate Kward prompt command /#{template.command}: #{path}"
29
30
  next
30
31
  end
31
32
 
@@ -33,7 +34,7 @@ module Kward
33
34
  template
34
35
  end
35
36
  rescue StandardError => e
36
- warn "Warning: skipping Kward prompt templates in #{prompts_root}: #{e.message}"
37
+ emit_warning "Warning: skipping Kward prompt templates in #{prompts_root}: #{e.message}"
37
38
  []
38
39
  end
39
40
 
@@ -42,7 +43,7 @@ module Kward
42
43
  def parse_prompt_template(path)
43
44
  command = File.basename(path, ".md")
44
45
  unless command.match?(/\A[A-Za-z0-9][A-Za-z0-9_-]*\z/)
45
- warn "Warning: skipping Kward prompt template #{path}: invalid command name"
46
+ emit_warning "Warning: skipping Kward prompt template #{path}: invalid command name"
46
47
  return nil
47
48
  end
48
49
 
@@ -55,9 +56,13 @@ module Kward
55
56
  path: path
56
57
  )
57
58
  rescue StandardError => e
58
- warn "Warning: skipping Kward prompt template #{path}: #{e.message}"
59
+ emit_warning "Warning: skipping Kward prompt template #{path}: #{e.message}"
59
60
  nil
60
61
  end
62
+
63
+ def emit_warning(message)
64
+ @warning_sink ? @warning_sink.call(message) : warn(message)
65
+ end
61
66
  end
62
67
  end
63
68
  end
data/lib/kward/prompts.rb CHANGED
@@ -22,25 +22,26 @@ module Kward
22
22
  # @param plugin_context [String, nil] trusted plugin-provided instructions
23
23
  # @return [Hash] role/content system message
24
24
  # @api public
25
- def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil)
25
+ def system_message(workspace_root: Dir.pwd, include_workspace_personality: true, model: nil, reasoning_effort: nil, now: Time.now, memory_context: nil, plugin_context: nil, execution_profile_context: nil)
26
26
  {
27
27
  role: "system",
28
- content: prompt_parts(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).compact.join("\n\n")
28
+ content: prompt_parts(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, execution_profile_context: execution_profile_context).compact.join("\n\n")
29
29
  }
30
30
  end
31
31
 
32
- 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)
33
- 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] }
32
+ 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, execution_profile_context: nil)
33
+ 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, execution_profile_context: execution_profile_context).map { |section| section[:content] }
34
34
  end
35
35
 
36
36
  # Returns labeled prompt sections for inspection by CLI and RPC frontends.
37
37
  #
38
38
  # @return [Array<Hash>] section hashes with label, content, and optional source
39
39
  # @api public
40
- 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)
40
+ 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, execution_profile_context: nil)
41
41
  return replacement_prompt_sections if ConfigFiles.replacement_system_prompt?
42
42
 
43
43
  sections = [prompt_section("Built-in system prompt", base_prompt)]
44
+ sections << prompt_section("Execution profile", execution_profile_context) unless execution_profile_context.to_s.empty?
44
45
  sections << prompt_section(config_agents_prompt_label, config_agents_prompt, source: config_agents_prompt_source)
45
46
  sections << prompt_section("Memory context", memory_context) unless memory_context.to_s.empty?
46
47
  if include_workspace_personality