kward 0.67.0 → 0.68.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +5 -5
  5. data/doc/authentication.md +24 -1
  6. data/doc/configuration.md +9 -2
  7. data/doc/extensibility.md +1 -1
  8. data/doc/getting-started.md +4 -6
  9. data/doc/plugins.md +0 -2
  10. data/doc/releasing.md +7 -8
  11. data/doc/rpc.md +6 -6
  12. data/doc/usage.md +5 -2
  13. data/doc/web-search.md +2 -2
  14. data/kward.gemspec +4 -0
  15. data/lib/kward/agent.rb +29 -2
  16. data/lib/kward/ansi.rb +3 -0
  17. data/lib/kward/auth/anthropic_oauth.rb +291 -0
  18. data/lib/kward/auth/file.rb +2 -0
  19. data/lib/kward/auth/github_oauth.rb +3 -0
  20. data/lib/kward/auth/openai_oauth.rb +4 -0
  21. data/lib/kward/auth/openrouter_api_key.rb +2 -0
  22. data/lib/kward/cancellation.rb +3 -0
  23. data/lib/kward/cli/auth_commands.rb +82 -0
  24. data/lib/kward/cli/commands.rb +222 -0
  25. data/lib/kward/cli/compaction.rb +25 -0
  26. data/lib/kward/cli/doctor.rb +121 -0
  27. data/lib/kward/cli/interactive_turn.rb +225 -0
  28. data/lib/kward/cli/memory_commands.rb +133 -0
  29. data/lib/kward/cli/plugins.rb +112 -0
  30. data/lib/kward/cli/prompt_interface.rb +132 -0
  31. data/lib/kward/cli/rendering.rb +389 -0
  32. data/lib/kward/cli/runtime_helpers.rb +159 -0
  33. data/lib/kward/cli/sessions.rb +376 -0
  34. data/lib/kward/cli/settings.rb +663 -0
  35. data/lib/kward/cli/slash_commands.rb +112 -0
  36. data/lib/kward/cli/stats.rb +64 -0
  37. data/lib/kward/cli/tool_summaries.rb +153 -0
  38. data/lib/kward/cli.rb +38 -2790
  39. data/lib/kward/cli_transcript_formatter.rb +4 -7
  40. data/lib/kward/clipboard.rb +1 -0
  41. data/lib/kward/compaction/file_operation_tracker.rb +3 -0
  42. data/lib/kward/compactor.rb +29 -7
  43. data/lib/kward/config_files.rb +33 -24
  44. data/lib/kward/conversation.rb +70 -5
  45. data/lib/kward/events.rb +2 -0
  46. data/lib/kward/export_path.rb +2 -0
  47. data/lib/kward/image_attachments.rb +2 -0
  48. data/lib/kward/markdown_transcript.rb +2 -0
  49. data/lib/kward/memory/manager.rb +13 -0
  50. data/lib/kward/message_access.rb +23 -2
  51. data/lib/kward/message_text.rb +45 -0
  52. data/lib/kward/model/chat_invocation.rb +2 -0
  53. data/lib/kward/model/client.rb +295 -77
  54. data/lib/kward/model/context_overflow.rb +2 -0
  55. data/lib/kward/model/context_usage.rb +3 -0
  56. data/lib/kward/model/model_info.rb +143 -4
  57. data/lib/kward/model/payloads.rb +166 -13
  58. data/lib/kward/model/retry_message.rb +2 -0
  59. data/lib/kward/model/stream_parser.rb +129 -0
  60. data/lib/kward/pan/server.rb +3 -1
  61. data/lib/kward/plugin_registry.rb +12 -0
  62. data/lib/kward/private_file.rb +2 -0
  63. data/lib/kward/prompt_interface/banner.rb +3 -0
  64. data/lib/kward/prompt_interface/composer_controller.rb +262 -0
  65. data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
  66. data/lib/kward/prompt_interface/composer_state.rb +221 -0
  67. data/lib/kward/prompt_interface/key_handler.rb +365 -0
  68. data/lib/kward/prompt_interface/layout.rb +31 -0
  69. data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
  70. data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
  71. data/lib/kward/prompt_interface/question_prompt.rb +328 -0
  72. data/lib/kward/prompt_interface/runtime_state.rb +59 -0
  73. data/lib/kward/prompt_interface/screen.rb +186 -0
  74. data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
  75. data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
  76. data/lib/kward/prompt_interface/stream_state.rb +65 -0
  77. data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
  78. data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
  79. data/lib/kward/prompt_interface.rb +69 -1832
  80. data/lib/kward/prompts/commands.rb +2 -0
  81. data/lib/kward/prompts/templates.rb +3 -0
  82. data/lib/kward/prompts.rb +2 -0
  83. data/lib/kward/question_contract.rb +66 -0
  84. data/lib/kward/resources/avatar_kward_logo.rb +2 -0
  85. data/lib/kward/resources/pixel_logo.rb +2 -0
  86. data/lib/kward/rpc/attachment_normalizer.rb +60 -0
  87. data/lib/kward/rpc/auth_manager.rb +65 -11
  88. data/lib/kward/rpc/config_manager.rb +11 -0
  89. data/lib/kward/rpc/prompt_bridge.rb +5 -26
  90. data/lib/kward/rpc/redactor.rb +3 -0
  91. data/lib/kward/rpc/runtime_payloads.rb +4 -1
  92. data/lib/kward/rpc/server.rb +37 -10
  93. data/lib/kward/rpc/session_manager.rb +123 -347
  94. data/lib/kward/rpc/session_metrics.rb +68 -0
  95. data/lib/kward/rpc/session_tree.rb +48 -0
  96. data/lib/kward/rpc/session_tree_rows.rb +208 -0
  97. data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
  98. data/lib/kward/rpc/tool_metadata.rb +3 -0
  99. data/lib/kward/rpc/transcript_normalizer.rb +3 -0
  100. data/lib/kward/rpc/transport.rb +3 -0
  101. data/lib/kward/session_diff.rb +2 -0
  102. data/lib/kward/session_store.rb +125 -31
  103. data/lib/kward/session_trash.rb +1 -0
  104. data/lib/kward/session_tree_renderer.rb +8 -41
  105. data/lib/kward/session_tree_tool_display.rb +56 -0
  106. data/lib/kward/skills/registry.rb +3 -0
  107. data/lib/kward/starter_pack_installer.rb +1 -0
  108. data/lib/kward/steering.rb +2 -0
  109. data/lib/kward/telemetry/logger.rb +3 -0
  110. data/lib/kward/telemetry/stats.rb +3 -0
  111. data/lib/kward/tools/ask_user_question.rb +20 -32
  112. data/lib/kward/tools/base.rb +8 -0
  113. data/lib/kward/tools/code_search.rb +5 -0
  114. data/lib/kward/tools/edit_file.rb +5 -0
  115. data/lib/kward/tools/list_directory.rb +5 -0
  116. data/lib/kward/tools/read_file.rb +5 -0
  117. data/lib/kward/tools/read_skill.rb +5 -0
  118. data/lib/kward/tools/registry.rb +33 -2
  119. data/lib/kward/tools/run_shell_command.rb +5 -0
  120. data/lib/kward/tools/search/code.rb +7 -0
  121. data/lib/kward/tools/search/web.rb +17 -14
  122. data/lib/kward/tools/tool_call.rb +25 -5
  123. data/lib/kward/tools/web_search.rb +7 -1
  124. data/lib/kward/tools/write_file.rb +5 -0
  125. data/lib/kward/transcript_export.rb +2 -0
  126. data/lib/kward/version.rb +2 -1
  127. data/lib/kward/workspace.rb +45 -5
  128. metadata +43 -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
 
@@ -79,13 +82,7 @@ module Kward
79
82
  end
80
83
 
81
84
  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
85
+ MessageText.full_text(message)
89
86
  end
90
87
 
91
88
  def content_part_text(part)
@@ -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
@@ -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
@@ -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
@@ -73,6 +83,7 @@ module Kward
73
83
  }
74
84
  end
75
85
 
86
+ # Performs ensure default config for configuration file and path handling.
76
87
  def ensure_default_config!(path = config_path)
77
88
  path = File.expand_path(path)
78
89
  return false if File.exist?(path)
@@ -105,7 +116,9 @@ module Kward
105
116
  # Reads the JSON config file.
106
117
  #
107
118
  # Missing files are treated as an empty config. Invalid JSON raises a
108
- # user-facing error that includes the file path.
119
+ # user-facing error that includes the file path. This method does not merge
120
+ # defaults; callers should apply feature-specific defaults at the point where
121
+ # behavior is decided.
109
122
  #
110
123
  # @param path [String] config file path
111
124
  # @return [Hash] parsed config object
@@ -126,6 +139,7 @@ module Kward
126
139
  PrivateFile.write_json(path, config)
127
140
  end
128
141
 
142
+ # Merges top-level config values and writes the updated config privately.
129
143
  def update_config(values, path = config_path)
130
144
  raise "Config values must be an object" unless values.is_a?(Hash)
131
145
 
@@ -135,6 +149,7 @@ module Kward
135
149
  config
136
150
  end
137
151
 
152
+ # Removes a top-level config key when it exists.
138
153
  def delete_config_key(key, path = config_path)
139
154
  config = read_config(path)
140
155
  existed = config.key?(key.to_s)
@@ -143,6 +158,7 @@ module Kward
143
158
  existed
144
159
  end
145
160
 
161
+ # Returns the first present non-empty string value among several config keys.
146
162
  def config_value(config, *keys)
147
163
  keys.each do |key|
148
164
  text = presence(config[key])
@@ -166,26 +182,37 @@ module Kward
166
182
  settings
167
183
  end
168
184
 
185
+ # Returns whether the composer should show busy-state keyboard help.
169
186
  def composer_busy_help?(config = read_config)
170
187
  composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
171
188
  composer["busy_help"] != false
172
189
  end
173
190
 
191
+ # Returns whether the terminal startup banner should be displayed.
174
192
  def banner_enabled?(config = read_config)
175
193
  banner = config["banner"].is_a?(Hash) ? config["banner"] : {}
176
194
  banner["enabled"] != false
177
195
  end
178
196
 
197
+ # Returns whether file tools must stay inside the active workspace root.
179
198
  def workspace_guardrails_enabled?(config = read_config)
180
199
  tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
181
200
  tools["workspace_guardrails"] != false
182
201
  end
183
202
 
203
+ # Returns whether new frontends should resume the last active session automatically.
184
204
  def session_auto_resume_enabled?(config = read_config)
185
205
  sessions = config["sessions"].is_a?(Hash) ? config["sessions"] : {}
186
206
  sessions["auto_resume"] == true
187
207
  end
188
208
 
209
+ # Returns the nested web-search config object, or an empty config when absent.
210
+ def web_search_config(config = read_config)
211
+ value = config["web_search"]
212
+ value.is_a?(Hash) ? value : {}
213
+ end
214
+
215
+ # Validates and persists terminal overlay settings.
189
216
  def update_overlay_settings(values)
190
217
  raise "Overlay settings must be an object" unless values.is_a?(Hash)
191
218
 
@@ -220,6 +247,10 @@ module Kward
220
247
  # Builds persona prompt text from default, workspace, model, reasoning,
221
248
  # time-of-day, weekday, and suffix config entries.
222
249
  #
250
+ # Persona resolution is intentionally data-driven so users can edit config
251
+ # without plugin code. Keep new persona selectors additive and deterministic;
252
+ # prompt construction depends on stable ordering.
253
+ #
223
254
  # @param workspace_root [String] active workspace root
224
255
  # @param model [String, nil] active model name
225
256
  # @param reasoning_effort [String, nil] active reasoning effort
@@ -235,6 +266,7 @@ module Kward
235
266
  text
236
267
  end
237
268
 
269
+ # Returns the label of the persona selected by default/workspace/model rules.
238
270
  def active_persona_label(workspace_root:, model: nil, config: read_config)
239
271
  personas = config["personas"]
240
272
  return nil unless personas.is_a?(Hash)
@@ -325,17 +357,6 @@ module Kward
325
357
  nil
326
358
  end
327
359
 
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
360
  def canonical_workspace_root(path)
340
361
  expanded = File.expand_path(path.to_s.empty? ? Dir.pwd : path.to_s)
341
362
  File.directory?(expanded) ? File.realpath(expanded) : expanded
@@ -460,7 +481,6 @@ module Kward
460
481
  # @return [Array<String>] sorted plugin file paths
461
482
  def plugin_paths
462
483
  plugins_root = plugin_dir
463
- warn_legacy_plugin_dir(plugins_root)
464
484
  return [] unless Dir.exist?(plugins_root)
465
485
 
466
486
  Dir.glob(File.join(plugins_root, "*.rb")).sort
@@ -469,17 +489,6 @@ module Kward
469
489
  []
470
490
  end
471
491
 
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
492
  # Lists prompt templates exposed as slash commands.
484
493
  #
485
494
  # @param reserved_commands [Array<String>] command names unavailable to templates
@@ -4,15 +4,57 @@ require_relative "message_access"
4
4
  require_relative "plugin_registry"
5
5
  require_relative "prompts"
6
6
 
7
+ # Namespace for the Kward CLI agent runtime.
7
8
  module Kward
9
+ # Mutable transcript and runtime context for one agent session.
10
+ #
11
+ # `Conversation` owns message ordering, system prompt refresh, read-before-write
12
+ # state, memory prompt context, and persistence hooks. It intentionally stores
13
+ # plain hashes because provider payload builders, session JSONL files, and RPC
14
+ # normalizers all share the same transcript shape. Use `MessageAccess` when
15
+ # reading messages so symbol/string key and legacy field compatibility stays in
16
+ # one place.
17
+ #
18
+ # Frontends should not mutate `messages` directly after attaching a
19
+ # `SessionStore::Session`; use append/compact helpers so persistence callbacks
20
+ # run and session trees stay consistent.
8
21
  class Conversation
9
22
  DEFAULT_SYSTEM_MESSAGE = Object.new.freeze
10
23
 
11
- attr_reader :messages, :read_paths, :workspace_root, :compaction_system_message, :model, :reasoning_effort, :session_memories
12
- attr_accessor :on_append, :on_compact, :on_tool_execution, :memory_context, :last_memory_retrieval, :plugin_registry
13
-
14
- def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
24
+ # @return [Array<Hash>] ordered transcript entries sent to providers and persisted in sessions
25
+ attr_reader :messages
26
+ # @return [Set<String>] resolved paths read by file tools during the active context
27
+ attr_reader :read_paths
28
+ # @return [String] canonical workspace root used for prompts and file guardrails
29
+ attr_reader :workspace_root
30
+ # @return [Hash, nil] system prompt used when summarizing old context
31
+ attr_reader :compaction_system_message
32
+ # @return [String, nil] provider captured for session/runtime prompts
33
+ attr_reader :provider
34
+ # @return [String, nil] model id captured for session/runtime prompts
35
+ attr_reader :model
36
+ # @return [String, nil] reasoning effort captured for session/runtime prompts
37
+ attr_reader :reasoning_effort
38
+ # @return [Array<Hash>] memories scoped to this conversation session
39
+ attr_reader :session_memories
40
+ # @return [Proc, nil] persistence callback invoked after appending a message
41
+ attr_accessor :on_append
42
+ # @return [Proc, nil] persistence callback invoked after compaction replaces history
43
+ attr_accessor :on_compact
44
+ # @return [Proc, nil] callback invoked when a tool execution record should be persisted
45
+ attr_accessor :on_tool_execution
46
+ # @return [Proc, nil] callback invoked when runtime metadata should be persisted
47
+ attr_accessor :on_runtime_update
48
+ # @return [String, nil] memory prompt context injected into refreshed system messages
49
+ attr_accessor :memory_context
50
+ # @return [Hash, nil] metadata for the last memory retrieval attached to the session
51
+ attr_accessor :last_memory_retrieval
52
+ # @return [PluginRegistry, nil] registry used to collect plugin prompt context
53
+ attr_accessor :plugin_registry
54
+
55
+ def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
15
56
  @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
57
+ @provider = provider
16
58
  @model = model
17
59
  @reasoning_effort = reasoning_effort
18
60
  @plugin_registry = plugin_registry
@@ -36,8 +78,13 @@ module Kward
36
78
  @on_append = on_append
37
79
  @on_compact = on_compact
38
80
  @on_tool_execution = on_tool_execution
81
+ @on_runtime_update = on_runtime_update
39
82
  end
40
83
 
84
+ # Appends a user message and normalizes image attachment syntax.
85
+ #
86
+ # `display_content` is transcript/UI text for cases where the model input is
87
+ # expanded, decorated, or contains encoded attachment content.
41
88
  def append_user(content, display_content: nil)
42
89
  content = ImageAttachments.content_from_text(content) unless content.is_a?(Array)
43
90
  message = { role: "user", content: content }
@@ -63,6 +110,12 @@ module Kward
63
110
  @on_tool_execution&.call(tool_call, content)
64
111
  end
65
112
 
113
+ # Rebuilds the system message from current config, memory, plugins, and
114
+ # workspace AGENTS.md state.
115
+ #
116
+ # Conversations created with `system_message: nil` keep system prompts
117
+ # disabled; this preserves tests, compaction summaries, and imported
118
+ # transcripts that intentionally do not include runtime instructions.
66
119
  def refresh_system_message!
67
120
  return nil unless @system_message_enabled
68
121
 
@@ -74,12 +127,17 @@ module Kward
74
127
  replacement
75
128
  end
76
129
 
77
- def update_runtime_context!(model:, reasoning_effort:)
130
+ def update_runtime_context!(provider: nil, model:, reasoning_effort:)
131
+ @provider = provider unless provider.to_s.empty?
78
132
  @model = model
79
133
  @reasoning_effort = reasoning_effort
80
134
  refresh_system_message!
81
135
  end
82
136
 
137
+ def persist_runtime_context!
138
+ @on_runtime_update&.call(provider: @provider, model: @model, reasoning_effort: @reasoning_effort)
139
+ end
140
+
83
141
  def refresh_system_message_if_workspace_agents_changed!
84
142
  refresh_system_message! if @system_message_enabled && workspace_agents_mtime != @workspace_agents_mtime
85
143
  end
@@ -95,6 +153,13 @@ module Kward
95
153
  plugin_registry.prompt_context(context)
96
154
  end
97
155
 
156
+ # Replaces most transcript entries with a compaction summary and optional
157
+ # recent messages to keep.
158
+ #
159
+ # Compaction clears read-before-write state because file contents observed
160
+ # before the summary may no longer be represented exactly in the active
161
+ # context. Callers that need file mutation after compaction should read files
162
+ # again through the normal tools.
98
163
  def compact!(summary, compaction_summary: false, first_kept_entry_id: nil, tokens_before: nil, from_hook: false, details: {}, keep_messages: [])
99
164
  message = if compaction_summary
100
165
  { role: "compactionSummary", summary: summary.to_s }
data/lib/kward/events.rb CHANGED
@@ -1,4 +1,6 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Frontend-neutral event objects emitted during agent turns.
2
4
  module Events
3
5
  ReasoningDelta = Struct.new(:delta, keyword_init: true)
4
6
  AssistantDelta = Struct.new(:delta, keyword_init: true)
@@ -1,6 +1,8 @@
1
1
  require "pathname"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Resolves safe output paths for transcript exports.
4
6
  class ExportPath
5
7
  def self.resolve(path, workspace_root:, default_path:, session_dir: nil)
6
8
  explicit = path.to_s.strip
@@ -4,7 +4,9 @@ require "shellwords"
4
4
  require "tmpdir"
5
5
  require "uri"
6
6
 
7
+ # Namespace for the Kward CLI agent runtime.
7
8
  module Kward
9
+ # Image attachment parsing, validation, encoding, and display helpers.
8
10
  module ImageAttachments
9
11
  MAX_IMAGE_BYTES = 20 * 1024 * 1024
10
12
  MIME_TYPES = {
@@ -1,6 +1,8 @@
1
1
  require_relative "message_access"
2
2
 
3
+ # Namespace for the Kward CLI agent runtime.
3
4
  module Kward
5
+ # Markdown renderer for conversation transcripts.
4
6
  class MarkdownTranscript
5
7
  def initialize(conversation)
6
8
  @conversation = conversation
@@ -7,7 +7,9 @@ require_relative "../config_files"
7
7
  require_relative "../message_access"
8
8
  require_relative "../model/client"
9
9
 
10
+ # Namespace for the Kward CLI agent runtime.
10
11
  module Kward
12
+ # Memory subsystem for core, soft, session, and retrieval state.
11
13
  module Memory
12
14
  # Manages Kward's opt-in structured memory store.
13
15
  #
@@ -35,6 +37,7 @@ module Kward
35
37
  )
36
38
  end
37
39
 
40
+ # Creates an object for memory storage and retrieval.
38
41
  def initialize(config_path: ConfigFiles.config_path, core_path: ConfigFiles.memory_core_path, soft_path: ConfigFiles.memory_soft_path, events_path: ConfigFiles.memory_events_path, now: nil)
39
42
  @config_path = config_path
40
43
  @core_path = core_path
@@ -299,6 +302,11 @@ module Kward
299
302
  @last_retrieval || { "enabled" => enabled?, "core" => [], "soft" => [], "reasons" => [], "message" => "No memory retrieval has run yet." }
300
303
  end
301
304
 
305
+ # Formats retrieved memories for system prompt injection.
306
+ #
307
+ # Keep this block compact and explicit: it is read by the model, shown in
308
+ # transcripts, and explained by `/memory why`. Do not include inactive or
309
+ # forgotten memories here; retrieval already decides the active set.
302
310
  def memory_block(retrieval)
303
311
  core = Array(retrieval["core"])
304
312
  soft = Array(retrieval["soft"])
@@ -330,6 +338,11 @@ module Kward
330
338
  lines.join("\n")
331
339
  end
332
340
 
341
+ # Infers bounded session/workspace soft memories from a conversation.
342
+ #
343
+ # This is best-effort and intentionally conservative. It may use an LLM when
344
+ # configured, but failures fall back to heuristic text or no-op behavior so
345
+ # memory summarization never blocks normal session flow.
333
346
  def summarize_conversation(conversation, client: nil)
334
347
  text = messages_for_summarization(conversation).map { |message| MessageAccess.content(message) }.compact.join("\n")
335
348
  existing_texts = Array(conversation.session_memories).map { |memory| memory["text"] }
@@ -1,7 +1,20 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Compatibility reader for persisted conversation message hashes.
4
+ #
5
+ # Kward stores transcript entries as plain hashes because model payloads,
6
+ # JSONL sessions, plugins, and RPC normalizers all need to pass them around
7
+ # without framework objects. Restored sessions may contain either symbol keys,
8
+ # string keys, or Tauren-style camelCase aliases. `MessageAccess` centralizes
9
+ # those lookup rules so callers do not grow one-off compatibility branches.
2
10
  module MessageAccess
3
11
  module_function
4
12
 
13
+ # Reads a field from a hash-like object using symbol or string keys.
14
+ #
15
+ # @param object [#key?, nil] hash-like object to read
16
+ # @param key [String, Symbol] canonical field name
17
+ # @return [Object, nil] stored value when present
5
18
  def value(object, key)
6
19
  return nil unless object.respond_to?(:key?)
7
20
  return object[key] if object.key?(key)
@@ -10,14 +23,17 @@ module Kward
10
23
  nil
11
24
  end
12
25
 
26
+ # @return [String, nil] message role such as `user`, `assistant`, or `tool`
13
27
  def role(message)
14
28
  value(message, :role)
15
29
  end
16
30
 
31
+ # @return [Object, nil] raw message content
17
32
  def content(message)
18
33
  value(message, :content)
19
34
  end
20
35
 
36
+ # @return [String, nil] UI-facing content preserved separately from model input
21
37
  def display_content(message)
22
38
  value(message, :display_content) || value(message, :displayContent)
23
39
  end
@@ -31,11 +47,16 @@ module Kward
31
47
  end
32
48
 
33
49
  def tool_call_id(message)
34
- value(message, :tool_call_id)
50
+ value(message, :tool_call_id) || value(message, :toolCallId)
35
51
  end
36
52
 
53
+ def tool_name(message)
54
+ value(message, :name) || value(message, :toolName)
55
+ end
56
+
57
+ # @return [Array<Hash>] assistant tool calls, or an empty array
37
58
  def tool_calls(message)
38
- calls = value(message, :tool_calls)
59
+ calls = value(message, :tool_calls) || value(message, :toolCalls)
39
60
  calls.is_a?(Array) ? calls : []
40
61
  end
41
62
  end
@@ -0,0 +1,45 @@
1
+ require_relative "message_access"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Builds user-visible plain text from persisted conversation messages.
6
+ #
7
+ # Conversations may store one value for the model and another value for the UI.
8
+ # Prompt templates, for example, keep expanded instructions in `content` while
9
+ # preserving the submitted slash command in `display_content`. This helper keeps
10
+ # tree navigation, forks, copy/export features, and RPC payloads aligned on the
11
+ # same visible text rules.
12
+ module MessageText
13
+ module_function
14
+
15
+ # Returns the plain text a user should see or edit for a message.
16
+ #
17
+ # User messages prefer `display_content`/`displayContent` when present. Other
18
+ # messages, and user messages without display text, are reduced from their
19
+ # stored content. Array content contributes only textual parts so image and
20
+ # tool-call blocks do not leak implementation details into editable text.
21
+ #
22
+ # @param message [Hash] persisted conversation message
23
+ # @return [String] stripped visible text
24
+ def full_text(message)
25
+ display_content = MessageAccess.display_content(message)
26
+ return display_content.to_s.strip unless display_content.nil?
27
+
28
+ content_text(MessageAccess.content(message)).strip
29
+ end
30
+
31
+ # Converts message content into plain text without applying display-content
32
+ # overrides.
33
+ #
34
+ # @param content [String, Array<Hash>, nil] message content field
35
+ # @return [String] textual content joined with newlines
36
+ def content_text(content)
37
+ case content
38
+ when Array
39
+ content.filter_map { |part| MessageAccess.value(part, :text) }.join("\n")
40
+ else
41
+ content.to_s
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,4 +1,6 @@
1
+ # Namespace for the Kward CLI agent runtime.
1
2
  module Kward
3
+ # Thin adapter that invokes the configured model client.
2
4
  module ChatInvocation
3
5
  module_function
4
6