kward 0.67.1 → 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 (146) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +48 -0
  3. data/.yardopts +1 -0
  4. data/CHANGELOG.md +54 -0
  5. data/Gemfile.lock +8 -2
  6. data/README.md +37 -30
  7. data/Rakefile +14 -1
  8. data/doc/authentication.md +84 -43
  9. data/doc/code-search.md +55 -28
  10. data/doc/configuration.md +27 -2
  11. data/doc/extensibility.md +90 -129
  12. data/doc/getting-started.md +53 -57
  13. data/doc/memory.md +51 -118
  14. data/doc/personas.md +417 -0
  15. data/doc/plugins.md +55 -99
  16. data/doc/releasing.md +10 -9
  17. data/doc/rpc.md +7 -7
  18. data/doc/usage.md +125 -141
  19. data/doc/web-search.md +80 -14
  20. data/exe/kward +2 -0
  21. data/kward.gemspec +4 -0
  22. data/lib/kward/agent.rb +30 -3
  23. data/lib/kward/ansi.rb +3 -0
  24. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  25. data/lib/kward/auth/file.rb +2 -0
  26. data/lib/kward/auth/github_oauth.rb +3 -0
  27. data/lib/kward/auth/openai_oauth.rb +4 -0
  28. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  29. data/lib/kward/cancellation.rb +3 -0
  30. data/lib/kward/cli/auth_commands.rb +82 -0
  31. data/lib/kward/cli/commands.rb +229 -0
  32. data/lib/kward/cli/compaction.rb +25 -0
  33. data/lib/kward/cli/doctor.rb +121 -0
  34. data/lib/kward/cli/interactive_turn.rb +227 -0
  35. data/lib/kward/cli/memory_commands.rb +133 -0
  36. data/lib/kward/cli/plugins.rb +112 -0
  37. data/lib/kward/cli/prompt_interface.rb +134 -0
  38. data/lib/kward/cli/rendering.rb +378 -0
  39. data/lib/kward/cli/runtime_helpers.rb +170 -0
  40. data/lib/kward/cli/sessions.rb +376 -0
  41. data/lib/kward/cli/settings.rb +669 -0
  42. data/lib/kward/cli/slash_commands.rb +114 -0
  43. data/lib/kward/cli/stats.rb +64 -0
  44. data/lib/kward/cli/sysprompt.rb +57 -0
  45. data/lib/kward/cli/tool_summaries.rb +157 -0
  46. data/lib/kward/cli.rb +52 -2792
  47. data/lib/kward/cli_transcript_formatter.rb +40 -12
  48. data/lib/kward/clipboard.rb +1 -0
  49. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  50. data/lib/kward/compactor.rb +31 -9
  51. data/lib/kward/config_files.rb +78 -34
  52. data/lib/kward/conversation.rb +110 -13
  53. data/lib/kward/events.rb +2 -0
  54. data/lib/kward/export_path.rb +2 -0
  55. data/lib/kward/image_attachments.rb +2 -0
  56. data/lib/kward/markdown_transcript.rb +2 -0
  57. data/lib/kward/memory/manager.rb +144 -14
  58. data/lib/kward/message_access.rb +29 -2
  59. data/lib/kward/message_text.rb +45 -0
  60. data/lib/kward/model/chat_invocation.rb +2 -0
  61. data/lib/kward/model/client.rb +295 -77
  62. data/lib/kward/model/context_overflow.rb +2 -0
  63. data/lib/kward/model/context_usage.rb +14 -10
  64. data/lib/kward/model/model_info.rb +160 -4
  65. data/lib/kward/model/payloads.rb +254 -22
  66. data/lib/kward/model/retry_message.rb +2 -0
  67. data/lib/kward/model/stream_parser.rb +387 -25
  68. data/lib/kward/pan/server.rb +3 -1
  69. data/lib/kward/plugin_registry.rb +12 -0
  70. data/lib/kward/private_file.rb +2 -0
  71. data/lib/kward/prompt_interface/banner.rb +3 -0
  72. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  73. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  74. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  75. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  76. data/lib/kward/prompt_interface/layout.rb +31 -0
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  78. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  80. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  81. data/lib/kward/prompt_interface/screen.rb +186 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  83. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  84. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  85. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  86. data/lib/kward/prompt_interface/transcript_renderer.rb +151 -0
  87. data/lib/kward/prompt_interface.rb +69 -1832
  88. data/lib/kward/prompts/commands.rb +2 -0
  89. data/lib/kward/prompts/templates.rb +3 -0
  90. data/lib/kward/prompts.rb +63 -7
  91. data/lib/kward/question_contract.rb +66 -0
  92. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  93. data/lib/kward/resources/pixel_logo.rb +2 -0
  94. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  95. data/lib/kward/rpc/auth_manager.rb +65 -11
  96. data/lib/kward/rpc/config_manager.rb +11 -0
  97. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  98. data/lib/kward/rpc/redactor.rb +3 -0
  99. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  100. data/lib/kward/rpc/server.rb +43 -11
  101. data/lib/kward/rpc/session_manager.rb +139 -347
  102. data/lib/kward/rpc/session_metrics.rb +68 -0
  103. data/lib/kward/rpc/session_tree.rb +48 -0
  104. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  105. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  106. data/lib/kward/rpc/tool_metadata.rb +3 -0
  107. data/lib/kward/rpc/transcript_normalizer.rb +50 -0
  108. data/lib/kward/rpc/transport.rb +3 -0
  109. data/lib/kward/session_diff.rb +2 -0
  110. data/lib/kward/session_store.rb +154 -25
  111. data/lib/kward/session_trash.rb +1 -0
  112. data/lib/kward/session_tree_renderer.rb +8 -41
  113. data/lib/kward/session_tree_tool_display.rb +56 -0
  114. data/lib/kward/skills/registry.rb +3 -0
  115. data/lib/kward/starter_pack_installer.rb +3 -2
  116. data/lib/kward/steering.rb +2 -0
  117. data/lib/kward/telemetry/logger.rb +3 -0
  118. data/lib/kward/telemetry/stats.rb +3 -0
  119. data/lib/kward/tools/ask_user_question.rb +20 -32
  120. data/lib/kward/tools/base.rb +8 -0
  121. data/lib/kward/tools/code_search.rb +5 -0
  122. data/lib/kward/tools/edit_file.rb +5 -0
  123. data/lib/kward/tools/fetch_content.rb +41 -0
  124. data/lib/kward/tools/fetch_raw.rb +40 -0
  125. data/lib/kward/tools/list_directory.rb +5 -0
  126. data/lib/kward/tools/read_file.rb +5 -0
  127. data/lib/kward/tools/read_skill.rb +5 -0
  128. data/lib/kward/tools/registry.rb +42 -4
  129. data/lib/kward/tools/run_shell_command.rb +5 -0
  130. data/lib/kward/tools/search/code.rb +7 -0
  131. data/lib/kward/tools/search/web.rb +20 -17
  132. data/lib/kward/tools/search/web_fetch.rb +202 -0
  133. data/lib/kward/tools/tool_call.rb +27 -5
  134. data/lib/kward/tools/web_search.rb +7 -1
  135. data/lib/kward/tools/write_file.rb +5 -0
  136. data/lib/kward/transcript_export.rb +2 -0
  137. data/lib/kward/version.rb +2 -1
  138. data/lib/kward/workspace.rb +45 -5
  139. data/templates/default/fulldoc/html/css/kward.css +1501 -0
  140. data/templates/default/fulldoc/html/images/kward_logo.png +0 -0
  141. data/templates/default/fulldoc/html/js/kward.js +296 -0
  142. data/templates/default/fulldoc/html/setup.rb +8 -0
  143. data/templates/default/layout/html/breadcrumb.erb +11 -0
  144. data/templates/default/layout/html/layout.erb +141 -0
  145. data/templates/default/layout/html/setup.rb +139 -0
  146. metadata +56 -1
@@ -1,8 +1,11 @@
1
1
  require "base64"
2
2
  require_relative "image_attachments"
3
3
  require_relative "message_access"
4
+ require_relative "message_text"
4
5
 
6
+ # Namespace for the Kward CLI agent runtime.
5
7
  module Kward
8
+ # Formats conversation messages for terminal transcript display.
6
9
  module CLITranscriptFormatter
7
10
  module_function
8
11
 
@@ -11,13 +14,20 @@ module Kward
11
14
  return direct.to_s unless direct.to_s.empty?
12
15
 
13
16
  content = MessageAccess.content(message)
14
- 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
15
26
 
16
- content.filter_map do |part|
17
- type = MessageAccess.value(part, :type)
18
- next unless ["thinking", "reasoning"].include?(type)
27
+ response_items(message).filter_map do |item|
28
+ next unless MessageAccess.value(item, :type) == "reasoning"
19
29
 
20
- 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))
21
31
  end.join("\n")
22
32
  end
23
33
 
@@ -30,6 +40,18 @@ module Kward
30
40
  end
31
41
  end
32
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
+
33
55
  def display_text(message)
34
56
  display_content = MessageAccess.display_content(message)
35
57
  return display_content.to_s unless display_content.nil?
@@ -79,13 +101,7 @@ module Kward
79
101
  end
80
102
 
81
103
  def full_text(message)
82
- content = MessageAccess.content(message)
83
- text = if content.is_a?(Array)
84
- content.filter_map { |part| MessageAccess.value(part, :text) }.join("\n")
85
- else
86
- content.to_s
87
- end
88
- text.strip
104
+ MessageText.full_text(message)
89
105
  end
90
106
 
91
107
  def content_part_text(part)
@@ -99,6 +115,18 @@ module Kward
99
115
  end
100
116
  end
101
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
+
102
130
  def image_part_reference(part)
103
131
  data = MessageAccess.value(part, :data)
104
132
  path = MessageAccess.value(part, :path)
@@ -2,6 +2,7 @@ require "base64"
2
2
  require "open3"
3
3
  require "rbconfig"
4
4
 
5
+ # Namespace for the Kward CLI agent runtime.
5
6
  module Kward
6
7
  # Best-effort local clipboard writer used by explicit user copy commands.
7
8
  class Clipboard
@@ -1,8 +1,11 @@
1
1
  require_relative "../message_access"
2
2
  require_relative "../tools/tool_call"
3
3
 
4
+ # Namespace for the Kward CLI agent runtime.
4
5
  module Kward
6
+ # Conversation compaction settings, planning, and summary generation.
5
7
  module Compaction
8
+ # Tracks file operations while preparing compaction summaries.
6
9
  class FileOperationTracker
7
10
  def call(messages, previous_details: {})
8
11
  read_files = Array(path_values(previous_details, "read_files", :read_files))
@@ -2,15 +2,23 @@ require "json"
2
2
  require_relative "model/chat_invocation"
3
3
  require_relative "compaction/file_operation_tracker"
4
4
  require_relative "config_files"
5
+ require_relative "message_access"
5
6
  require_relative "prompts"
6
7
  require_relative "tools/tool_call"
7
8
 
9
+ # Namespace for the Kward CLI agent runtime.
8
10
  module Kward
11
+ # Conversation compaction settings, planning, and summary generation.
9
12
  module Compaction
13
+ # Compaction support object used by conversation summarization.
10
14
  class Error < StandardError; end
15
+ # Compaction support object used by conversation summarization.
11
16
  class NothingToCompact < Error; end
17
+ # Compaction support object used by conversation summarization.
12
18
  class AlreadyCompacted < Error; end
19
+ # Compaction support object used by conversation summarization.
13
20
  class Cancelled < Error; end
21
+ # Compaction support object used by conversation summarization.
14
22
  class SummarizationFailed < Error; end
15
23
 
16
24
  PreparationResult = Struct.new(
@@ -28,6 +36,7 @@ module Kward
28
36
 
29
37
  Cut = Struct.new(:first_kept_index, :messages_to_summarize, :turn_prefix_messages, :split_turn, :preserved_messages, :preserved_start_index, keyword_init: true)
30
38
 
39
+ # Interactive settings menu actions mixed into the CLI frontend.
31
40
  class Settings
32
41
  DEFAULT_ENABLED = true
33
42
  DEFAULT_RESERVE_TOKENS = 16_384
@@ -35,6 +44,7 @@ module Kward
35
44
 
36
45
  attr_reader :enabled, :reserve_tokens, :keep_recent_tokens, :context_window
37
46
 
47
+ # Creates an object for conversation compaction.
38
48
  def initialize(enabled: DEFAULT_ENABLED, reserve_tokens: DEFAULT_RESERVE_TOKENS, keep_recent_tokens: DEFAULT_KEEP_RECENT_TOKENS, context_window: nil)
39
49
  @enabled = enabled != false
40
50
  @reserve_tokens = positive_integer(reserve_tokens, DEFAULT_RESERVE_TOKENS)
@@ -64,6 +74,7 @@ module Kward
64
74
  end
65
75
  end
66
76
 
77
+ # Compaction support object used by conversation summarization.
67
78
  class TokenEstimator
68
79
  def estimate_tokens(text)
69
80
  (text.to_s.length / 4.0).ceil
@@ -169,9 +180,11 @@ module Kward
169
180
  end
170
181
  end
171
182
 
183
+ # Compaction support object used by conversation summarization.
172
184
  class ConversationSerializer
173
185
  TOOL_RESULT_LIMIT = 2_000
174
186
 
187
+ # Creates an object for conversation compaction.
175
188
  def initialize(tool_result_summarizer: nil)
176
189
  @tool_result_summarizer = tool_result_summarizer
177
190
  end
@@ -288,28 +301,27 @@ module Kward
288
301
  end
289
302
 
290
303
  def message_role(message)
291
- message["role"] || message[:role]
304
+ MessageAccess.role(message)
292
305
  end
293
306
 
294
307
  def message_content(message)
295
- message["content"] || message[:content]
308
+ MessageAccess.content(message)
296
309
  end
297
310
 
298
311
  def message_summary(message)
299
- message["summary"] || message[:summary] || message_content(message)
312
+ MessageAccess.summary(message) || message_content(message)
300
313
  end
301
314
 
302
315
  def message_name(message)
303
- message["name"] || message[:name]
316
+ MessageAccess.tool_name(message)
304
317
  end
305
318
 
306
319
  def message_tool_call_id(message)
307
- message["tool_call_id"] || message[:tool_call_id]
320
+ MessageAccess.tool_call_id(message)
308
321
  end
309
322
 
310
323
  def message_tool_calls(message)
311
- value = message["tool_calls"] || message[:tool_calls]
312
- value.is_a?(Array) ? value : []
324
+ MessageAccess.tool_calls(message)
313
325
  end
314
326
 
315
327
  def tool_call_id(tool_call)
@@ -350,9 +362,11 @@ module Kward
350
362
  end
351
363
  end
352
364
 
365
+ # Compaction support object used by conversation summarization.
353
366
  class CutPointFinder
354
367
  VALID_CUT_ROLES = ["user", "assistant", "bash", "custom", "branchSummary"].freeze
355
368
 
369
+ # Creates an object for conversation compaction.
356
370
  def initialize(estimator: TokenEstimator.new)
357
371
  @estimator = estimator
358
372
  end
@@ -431,7 +445,9 @@ module Kward
431
445
  end
432
446
  end
433
447
 
448
+ # Compaction support object used by conversation summarization.
434
449
  class Preparation
450
+ # Creates an object for conversation compaction.
435
451
  def initialize(conversation:, settings: Settings.new, estimator: TokenEstimator.new, cut_point_finder: CutPointFinder.new(estimator: estimator), file_operation_tracker: FileOperationTracker.new)
436
452
  @conversation = conversation
437
453
  @settings = settings
@@ -466,7 +482,7 @@ module Kward
466
482
  kept_messages: kept_messages,
467
483
  turn_prefix_messages: cut.turn_prefix_messages,
468
484
  split_turn: cut.split_turn,
469
- tokens_before: @estimator.context_tokens(@conversation.messages),
485
+ tokens_before: @estimator.context_tokens(@conversation.context_messages),
470
486
  previous_summary: previous_entry ? compaction_summary(previous_entry) : nil,
471
487
  file_ops: file_ops,
472
488
  settings: @settings
@@ -521,6 +537,7 @@ module Kward
521
537
  end
522
538
  end
523
539
 
540
+ # Compaction support object used by conversation summarization.
524
541
  class PromptBuilder
525
542
  SYSTEM_PROMPT = <<~PROMPT.strip.freeze
526
543
  You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
@@ -673,6 +690,7 @@ module Kward
673
690
  Be concise. Focus only on what is needed to understand and continue from the kept suffix. Preserve exact file paths, commands, class names, module names, method names, constants, spec names, migration names, and error messages.
674
691
  PROMPT
675
692
 
693
+ # Creates an object for conversation compaction.
676
694
  def initialize(serializer: ConversationSerializer.new)
677
695
  @serializer = serializer
678
696
  end
@@ -728,7 +746,9 @@ module Kward
728
746
  end
729
747
  end
730
748
 
749
+ # Compaction support object used by conversation summarization.
731
750
  class Summarizer
751
+ # Creates an object for conversation compaction.
732
752
  def initialize(client:, prompt_builder: PromptBuilder.new)
733
753
  @client = client
734
754
  @prompt_builder = prompt_builder
@@ -785,6 +805,7 @@ module Kward
785
805
  end
786
806
  end
787
807
 
808
+ # Compaction support object used by conversation summarization.
788
809
  class Compactor
789
810
  Result = Struct.new(:summary, :old_message_count, :new_message_count, :first_kept_entry_id, :tokens_before, :details, keyword_init: true)
790
811
  NothingToCompact = Compaction::NothingToCompact
@@ -795,6 +816,7 @@ module Kward
795
816
  AUTO_COMPACTION_GUARD_RATIO = 0.10
796
817
  AUTO_COMPACTION_EXTRA_GUARD_CAP = 12_000
797
818
 
819
+ # Creates an object for conversation compaction.
798
820
  def initialize(conversation:, client:, tool_result_summarizer: nil, settings: nil, summarizer: nil)
799
821
  @conversation = conversation
800
822
  @client = client
@@ -844,7 +866,7 @@ module Kward
844
866
  context_window ||= @settings.context_window
845
867
  return nil unless context_window
846
868
 
847
- context_tokens ||= Compaction::TokenEstimator.new.context_tokens(@conversation.messages)
869
+ context_tokens ||= Compaction::TokenEstimator.new.context_tokens(@conversation.context_messages)
848
870
  reserve_tokens = auto_compaction_reserve_tokens(context_window: context_window.to_i)
849
871
  return nil unless context_tokens.to_i > context_window.to_i - reserve_tokens
850
872
 
@@ -5,9 +5,19 @@ require_relative "private_file"
5
5
  require_relative "prompts/templates"
6
6
  require_relative "skills/registry"
7
7
 
8
+ # Namespace for the Kward CLI agent runtime.
8
9
  module Kward
9
10
  # Resolves Kward configuration, cache, memory, prompt, skill, and plugin
10
11
  # paths, and reads/writes the JSON config file used by the CLI and RPC server.
12
+ #
13
+ # This module is the configuration boundary, not a runtime settings cache.
14
+ # Most methods read the filesystem each time so CLI commands and RPC reloads can
15
+ # observe edits made outside the process. Callers that need caching should own
16
+ # invalidation explicitly, as `Client#reload_config` does for provider state.
17
+ #
18
+ # Keep path decisions here. Higher-level code should ask `ConfigFiles` for
19
+ # config, prompt, skill, plugin, cache, memory, and session locations instead of
20
+ # reconstructing `~/.kward` paths independently.
11
21
  module ConfigFiles
12
22
  MAX_SKILL_FILE_BYTES = 100_000
13
23
  MAX_PROMPT_FILE_BYTES = 32 * 1024
@@ -67,12 +77,14 @@ module Kward
67
77
  "sessions" => {
68
78
  "auto_resume" => false
69
79
  },
80
+ "enforce_workspace_agents_file" => false,
70
81
  "tools" => {
71
82
  "workspace_guardrails" => true
72
83
  }
73
84
  }
74
85
  end
75
86
 
87
+ # Performs ensure default config for configuration file and path handling.
76
88
  def ensure_default_config!(path = config_path)
77
89
  path = File.expand_path(path)
78
90
  return false if File.exist?(path)
@@ -105,7 +117,9 @@ module Kward
105
117
  # Reads the JSON config file.
106
118
  #
107
119
  # Missing files are treated as an empty config. Invalid JSON raises a
108
- # user-facing error that includes the file path.
120
+ # user-facing error that includes the file path. This method does not merge
121
+ # defaults; callers should apply feature-specific defaults at the point where
122
+ # behavior is decided.
109
123
  #
110
124
  # @param path [String] config file path
111
125
  # @return [Hash] parsed config object
@@ -126,6 +140,7 @@ module Kward
126
140
  PrivateFile.write_json(path, config)
127
141
  end
128
142
 
143
+ # Merges top-level config values and writes the updated config privately.
129
144
  def update_config(values, path = config_path)
130
145
  raise "Config values must be an object" unless values.is_a?(Hash)
131
146
 
@@ -135,6 +150,7 @@ module Kward
135
150
  config
136
151
  end
137
152
 
153
+ # Removes a top-level config key when it exists.
138
154
  def delete_config_key(key, path = config_path)
139
155
  config = read_config(path)
140
156
  existed = config.key?(key.to_s)
@@ -143,6 +159,7 @@ module Kward
143
159
  existed
144
160
  end
145
161
 
162
+ # Returns the first present non-empty string value among several config keys.
146
163
  def config_value(config, *keys)
147
164
  keys.each do |key|
148
165
  text = presence(config[key])
@@ -166,26 +183,43 @@ module Kward
166
183
  settings
167
184
  end
168
185
 
186
+ # Returns whether the composer should show busy-state keyboard help.
169
187
  def composer_busy_help?(config = read_config)
170
188
  composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
171
189
  composer["busy_help"] != false
172
190
  end
173
191
 
192
+ # Returns whether the terminal startup banner should be displayed.
174
193
  def banner_enabled?(config = read_config)
175
194
  banner = config["banner"].is_a?(Hash) ? config["banner"] : {}
176
195
  banner["enabled"] != false
177
196
  end
178
197
 
198
+ # Returns whether file tools must stay inside the active workspace root.
179
199
  def workspace_guardrails_enabled?(config = read_config)
180
200
  tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
181
201
  tools["workspace_guardrails"] != false
182
202
  end
183
203
 
204
+ # Returns whether new frontends should resume the last active session automatically.
184
205
  def session_auto_resume_enabled?(config = read_config)
185
206
  sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
186
207
  sessions["auto_resume"] == true
187
208
  end
188
209
 
210
+ # Returns whether workspace AGENTS.md contents should be injected directly
211
+ # instead of a compact read-when-relevant instruction.
212
+ def enforce_workspace_agents_file?(config = read_config)
213
+ config["enforce_workspace_agents_file"] == true
214
+ end
215
+
216
+ # Returns the nested web-search config object, or an empty config when absent.
217
+ def web_search_config(config = read_config)
218
+ value = config["web_search"]
219
+ value.is_a?(Hash) ? value : {}
220
+ end
221
+
222
+ # Validates and persists terminal overlay settings.
189
223
  def update_overlay_settings(values)
190
224
  raise "Overlay settings must be an object" unless values.is_a?(Hash)
191
225
 
@@ -209,17 +243,34 @@ module Kward
209
243
  overlay_settings(config)
210
244
  end
211
245
 
212
- # Reads global agent instructions from the config directory.
246
+ # Reads global principle instructions from the config directory.
247
+ #
248
+ # `PRINCIPLES.md` is preferred. `AGENTS.md` remains a backwards-compatible
249
+ # alias for existing installations.
213
250
  #
214
251
  # @return [String, nil] prompt text, or nil when absent/too large
215
252
  def agents_prompt
216
- path = File.join(config_dir, "AGENTS.md")
217
- read_prompt_file(path, "Kward prompt file")
253
+ path = config_principles_path
254
+ return read_prompt_file(path, "Kward principles file") if File.exist?(path)
255
+
256
+ read_prompt_file(config_agents_path, "Kward AGENTS.md alias")
257
+ end
258
+
259
+ def config_principles_path
260
+ File.join(config_dir, "PRINCIPLES.md")
261
+ end
262
+
263
+ def config_agents_path
264
+ File.join(config_dir, "AGENTS.md")
218
265
  end
219
266
 
220
267
  # Builds persona prompt text from default, workspace, model, reasoning,
221
268
  # time-of-day, weekday, and suffix config entries.
222
269
  #
270
+ # Persona resolution is intentionally data-driven so users can edit config
271
+ # without plugin code. Keep new persona selectors additive and deterministic;
272
+ # prompt construction depends on stable ordering.
273
+ #
223
274
  # @param workspace_root [String] active workspace root
224
275
  # @param model [String, nil] active model name
225
276
  # @param reasoning_effort [String, nil] active reasoning effort
@@ -235,6 +286,7 @@ module Kward
235
286
  text
236
287
  end
237
288
 
289
+ # Returns the label of the persona selected by default/workspace/model rules.
238
290
  def active_persona_label(workspace_root:, model: nil, config: read_config)
239
291
  personas = config["personas"]
240
292
  return nil unless personas.is_a?(Hash)
@@ -267,22 +319,30 @@ module Kward
267
319
 
268
320
  characters = crew_characters(personas)
269
321
  entries = []
270
-
271
- add_persona_entry(entries, "default", resolved_persona_text(personas["default"], characters: characters))
322
+ active_persona = { layer: "default", value: personas["default"], name: nil }
272
323
 
273
324
  workspaces = personas["workspaces"]
274
325
  if workspaces.is_a?(Hash)
275
326
  root = canonical_workspace_root(workspace_root)
276
327
  workspaces.each do |path, key|
277
328
  if canonical_workspace_root(path) == root
278
- add_persona_entry(entries, "workspace", resolved_persona_text(key, characters: characters), name: path)
329
+ active_persona = { layer: "workspace", value: key, name: path }
279
330
  break
280
331
  end
281
332
  end
282
333
  end
283
334
 
284
335
  models = personas["models"]
285
- add_persona_entry(entries, "model", resolved_persona_text(models[model.to_s], characters: characters), name: model.to_s) if models.is_a?(Hash) && !model.to_s.empty?
336
+ if models.is_a?(Hash) && !model.to_s.empty? && models.key?(model.to_s)
337
+ active_persona = { layer: "model", value: models[model.to_s], name: model.to_s }
338
+ end
339
+
340
+ add_persona_entry(
341
+ entries,
342
+ active_persona.fetch(:layer),
343
+ resolved_persona_text(active_persona.fetch(:value), characters: characters),
344
+ name: active_persona[:name]
345
+ )
286
346
 
287
347
  modifiers = personas["persona_modifiers"]
288
348
  if modifiers.is_a?(Hash)
@@ -304,10 +364,17 @@ module Kward
304
364
 
305
365
  entries
306
366
  end
367
+
368
+ def workspace_agents_path(workspace_root)
369
+ File.join(canonical_workspace_root(workspace_root), "AGENTS.md")
370
+ end
371
+
372
+ def workspace_agents_file?(workspace_root)
373
+ File.exist?(workspace_agents_path(workspace_root))
374
+ end
375
+
307
376
  def workspace_agents_prompt(workspace_root)
308
- root = canonical_workspace_root(workspace_root)
309
- path = File.join(root, "AGENTS.md")
310
- read_prompt_file(path, "workspace AGENTS.md")
377
+ read_prompt_file(workspace_agents_path(workspace_root), "workspace AGENTS.md")
311
378
  end
312
379
 
313
380
  def read_prompt_file(path, label)
@@ -325,17 +392,6 @@ module Kward
325
392
  nil
326
393
  end
327
394
 
328
- def workspace_config(workspace_root, config = read_config)
329
- workspaces = config["workspaces"]
330
- return nil unless workspaces.is_a?(Hash)
331
-
332
- root = canonical_workspace_root(workspace_root)
333
- workspaces.each do |path, entry|
334
- return entry if canonical_workspace_root(path) == root
335
- end
336
- nil
337
- end
338
-
339
395
  def canonical_workspace_root(path)
340
396
  expanded = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
341
397
  File.directory?(expanded) ? File.realpath(expanded) : expanded
@@ -460,7 +516,6 @@ module Kward
460
516
  # @return [Array<String>] sorted plugin file paths
461
517
  def plugin_paths
462
518
  plugins_root = plugin_dir
463
- warn_legacy_plugin_dir(plugins_root)
464
519
  return [] unless Dir.exist?(plugins_root)
465
520
 
466
521
  Dir.glob(File.join(plugins_root, "*.rb")).sort
@@ -469,17 +524,6 @@ module Kward
469
524
  []
470
525
  end
471
526
 
472
- def warn_legacy_plugin_dir(plugins_root)
473
- config_path = ENV["KWARD_CONFIG_PATH"]
474
- return if config_path.to_s.empty?
475
-
476
- legacy_root = File.expand_path(File.join(File.dirname(config_path), "plugins"))
477
- return if legacy_root == File.expand_path(plugins_root)
478
- return unless Dir.exist?(legacy_root)
479
-
480
- warn "Warning: ignoring Kward plugins in #{legacy_root}; plugins are only loaded from #{File.expand_path(plugins_root)}"
481
- end
482
-
483
527
  # Lists prompt templates exposed as slash commands.
484
528
  #
485
529
  # @param reserved_commands [Array<String>] command names unavailable to templates