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,4 +1,3 @@
1
- require "base64"
2
1
  require "securerandom"
3
2
  require "thread"
4
3
  require "time"
@@ -13,6 +12,8 @@ require_relative "../events"
13
12
  require_relative "../export_path"
14
13
  require_relative "../memory/manager"
15
14
  require_relative "../message_access"
15
+ require_relative "../message_text"
16
+ require_relative "../session_tree_tool_display"
16
17
  require_relative "../model/model_info"
17
18
  require_relative "../plugin_registry"
18
19
  require_relative "../prompts/commands"
@@ -23,17 +24,36 @@ require_relative "../tools/tool_call"
23
24
  require_relative "../tools/registry"
24
25
  require_relative "../transcript_export"
25
26
  require_relative "../workspace"
27
+ require_relative "attachment_normalizer"
28
+ require_relative "config_manager"
26
29
  require_relative "prompt_bridge"
27
30
  require_relative "runtime_payloads"
31
+ require_relative "session_metrics"
32
+ require_relative "session_tree"
33
+ require_relative "session_tree_rows"
28
34
  require_relative "tool_event_normalizer"
29
35
  require_relative "transcript_normalizer"
30
36
 
37
+ # Namespace for the Kward CLI agent runtime.
31
38
  module Kward
39
+ # JSON-RPC backend namespace used by UI clients.
32
40
  module RPC
41
+ # Owns RPC-visible session lifecycle, async turn queues, and frontend events.
42
+ #
43
+ # `Server` handles JSON-RPC framing/dispatch; `SessionManager` handles the
44
+ # product state behind those methods. It creates/resumes `SessionStore`
45
+ # sessions, builds agents with RPC prompt bridges, serializes turn events for
46
+ # clients, coordinates cancellation and follow-up queues, and integrates
47
+ # memory/plugin hooks for RPC sessions.
48
+ #
49
+ # Keep JSON-RPC wire shape normalization in the `RPC::*Normalizer` classes,
50
+ # persistence in `SessionStore`, and model/tool behavior in `Agent` and
51
+ # `ToolRegistry`. This class should coordinate those pieces rather than own
52
+ # their low-level mechanics.
33
53
  class SessionManager
34
54
  RECENT_EVENT_LIMIT = 1_000
35
- RPC_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
36
- RPC_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
55
+ RPC_ATTACHMENT_MAX_BYTES = AttachmentNormalizer::MAX_BYTES
56
+ RPC_IMAGE_MIME_TYPES = AttachmentNormalizer::IMAGE_MIME_TYPES
37
57
  STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
38
58
  FOOTER_REFRESH_INTERVAL = 1.0
39
59
  WORKER_STOP = Object.new.freeze
@@ -41,17 +61,32 @@ module Kward
41
61
  RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, :footer_worker, :last_footer_text, keyword_init: true)
42
62
  Turn = Struct.new(:id, :session_id, :input, :display_input, :status, :cancel_requested, :cancellation, :created_at, :started_at, :finished_at, :events, :next_sequence, :error, :streaming_behavior, :plugin_command_name, :plugin_arguments, :steering, keyword_init: true)
43
63
 
44
- def initialize(server:, client: Client.new, config_dir: ConfigFiles.config_dir, context_usage: ContextUsage.new, session_trash: SessionTrash.new)
64
+ # Creates an object for RPC session lifecycle and turn coordination.
65
+ def initialize(
66
+ server:,
67
+ client: Client.new,
68
+ config_dir: ConfigFiles.config_dir,
69
+ config_manager: ConfigManager.new(config_path: File.join(config_dir, "config.json")),
70
+ context_usage: ContextUsage.new,
71
+ session_trash: SessionTrash.new
72
+ )
45
73
  @server = server
46
74
  @client = client
47
75
  @config_dir = config_dir
76
+ @config_manager = config_manager
48
77
  @context_usage = context_usage
78
+ @session_metrics = SessionMetrics.new(context_usage: context_usage)
49
79
  @session_trash = session_trash
50
80
  @sessions = {}
51
81
  @turns = {}
52
82
  @mutex = Mutex.new
53
83
  end
54
84
 
85
+ # Creates a new RPC session or resumes the remembered session when allowed.
86
+ #
87
+ # Returns the normalized session payload expected by RPC clients. The RPC
88
+ # session id is separate from the persisted session id so one persisted file
89
+ # can be closed and reopened by different client connections.
55
90
  def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false)
56
91
  workspace_root = validate_workspace_root(workspace_root)
57
92
  store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
@@ -61,7 +96,7 @@ module Kward
61
96
  end
62
97
 
63
98
  conversation = new_conversation(workspace_root: workspace_root)
64
- session = store.create(model: conversation.model, reasoning_effort: conversation.reasoning_effort)
99
+ session = store.create(provider: conversation.provider, model: conversation.model, reasoning_effort: conversation.reasoning_effort)
65
100
  session.rename(name) unless name.to_s.strip.empty?
66
101
  session.attach(conversation)
67
102
  rpc_session = build_rpc_session(store, session, conversation, workspace_root)
@@ -80,6 +115,7 @@ module Kward
80
115
  session, conversation = store.load(
81
116
  location[:path],
82
117
  workspace: configured_workspace(root),
118
+ provider: current_model[:provider],
83
119
  model: current_model_id,
84
120
  reasoning_effort: current_reasoning_effort
85
121
  )
@@ -93,21 +129,23 @@ module Kward
93
129
  payload
94
130
  end
95
131
 
96
- def list_sessions(workspace_root: Dir.pwd, limit: nil)
132
+ def list_sessions(workspace_root: Dir.pwd, limit: nil, current_session_path: nil)
97
133
  root = validate_workspace_root(workspace_root)
98
134
  store = SessionStore.new(config_dir: @config_dir, cwd: root)
99
135
  requested_limit = limit.to_i if limit
100
136
  requested_limit = nil unless requested_limit&.positive?
101
- store.recent(limit: requested_limit)
137
+ store.recent(limit: requested_limit, keep_empty_path: current_session_path)
102
138
  .map { |info| session_info_payload(info, workspace_root: root) }
103
139
  end
104
140
 
141
+ # Renames the persisted session attached to an RPC session id.
105
142
  def rename_session(session_id:, name:)
106
143
  rpc_session = fetch_session(session_id)
107
144
  rpc_session.session.rename(name)
108
145
  session_payload(rpc_session)
109
146
  end
110
147
 
148
+ # Creates an independent copy of the current conversation branch.
111
149
  def clone_session(session_id:)
112
150
  source = fetch_session(session_id)
113
151
  session, conversation = source.store.create_independent_from_conversation(source.conversation, parent_session: source.session)
@@ -118,6 +156,7 @@ module Kward
118
156
  session_payload(rpc_session)
119
157
  end
120
158
 
159
+ # Compacts an RPC session and emits start/end events for UI progress.
121
160
  def compact_session(session_id:, custom_instructions: "")
122
161
  rpc_session = fetch_session(session_id)
123
162
  emit_session_event(rpc_session, "compactionStart", {})
@@ -135,10 +174,11 @@ module Kward
135
174
  raise e
136
175
  end
137
176
 
177
+ # Lists user-message entries that can be used as fork points.
138
178
  def fork_messages(session_id:)
139
179
  rpc_session = fetch_session(session_id)
140
180
  {
141
- messages: tree_entries(rpc_session).filter_map do |record|
181
+ messages: session_tree_helper(rpc_session).entries.filter_map do |record|
142
182
  message = record["message"]
143
183
  next unless message.is_a?(Hash) && message_role(message) == "user"
144
184
 
@@ -147,10 +187,12 @@ module Kward
147
187
  }
148
188
  end
149
189
 
190
+ # Creates a new session from history before the selected user message.
150
191
  def fork_session(session_id:, entry_id:)
151
192
  source = fetch_session(session_id)
152
- entries = tree_entries(source)
153
- resolved_entry_id = resolve_tree_entry_id(entries, entry_id)
193
+ tree = session_tree_helper(source)
194
+ entries = tree.entries
195
+ resolved_entry_id = tree.resolve_entry_id(entry_id, entries: entries)
154
196
  selected_index = entries.index { |record| record["id"].to_s == resolved_entry_id.to_s }
155
197
  selected = selected_index && entries[selected_index]
156
198
  raise ArgumentError, "Unknown fork entryId: #{entry_id}" unless selected
@@ -160,6 +202,7 @@ module Kward
160
202
 
161
203
  session, conversation = source.store.create_independent_from_messages(
162
204
  entries[0...selected_index].filter_map { |record| record["message"] },
205
+ provider: source.conversation.provider,
163
206
  model: source.conversation.model,
164
207
  reasoning_effort: source.conversation.reasoning_effort,
165
208
  parent_session: source.session
@@ -175,28 +218,32 @@ module Kward
175
218
  }
176
219
  end
177
220
 
221
+ # Returns the flattened session tree rows consumed by RPC clients.
178
222
  def session_tree(session_id:)
179
223
  rpc_session = fetch_session(session_id)
180
224
  { items: flatten_session_tree(rpc_session) }
181
225
  end
182
226
 
227
+ # Persists a label override for one tree entry.
183
228
  def set_tree_label(session_id:, entry_id:, label: nil)
184
229
  rpc_session = fetch_session(session_id)
185
230
  rpc_session.session.append_label_change(entry_id, label)
186
231
  { ok: true }
187
232
  end
188
233
 
234
+ # Moves the active branch to a tree entry, optionally summarizing abandoned history.
189
235
  def navigate_tree(session_id:, entry_id:, summarize: false, custom_instructions: nil)
190
236
  rpc_session = fetch_session(session_id)
191
- entries = tree_entries(rpc_session)
192
- resolved_entry_id = resolve_tree_entry_id(entries, entry_id)
237
+ tree = session_tree_helper(rpc_session)
238
+ entries = tree.entries
239
+ resolved_entry_id = tree.resolve_entry_id(entry_id, entries: entries)
193
240
  entry = rpc_session.store.session_entry(rpc_session.session.path, resolved_entry_id)
194
241
  raise ArgumentError, "Unknown tree entryId: #{entry_id}" unless entry
195
242
 
196
- raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless selectable_tree_entry?(entry)
243
+ raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless tree.selectable_entry?(entry)
197
244
 
198
245
  message = entry["message"]
199
- user_entry = user_tree_entry?(entry)
246
+ user_entry = tree.user_entry?(entry)
200
247
  target_leaf = user_entry ? entry["parentId"] : entry["id"]
201
248
  editor_text = user_entry ? full_message_text(message) : nil
202
249
  previous_leaf = rpc_session.session.leaf_id
@@ -217,6 +264,7 @@ module Kward
217
264
  }.compact
218
265
  end
219
266
 
267
+ # Exports the current transcript in markdown or JSON format.
220
268
  def export_session(session_id:, path: nil, format: nil)
221
269
  rpc_session = fetch_session(session_id)
222
270
  format = export_format(format)
@@ -226,6 +274,7 @@ module Kward
226
274
  { path: path, format: format }
227
275
  end
228
276
 
277
+ # Deletes the backing session file through the configured trash strategy.
229
278
  def delete_session(session_id:)
230
279
  rpc_session = fetch_session(session_id)
231
280
  path = rpc_session.session.path
@@ -234,12 +283,14 @@ module Kward
234
283
  { deleted: deleted, path: path }
235
284
  end
236
285
 
286
+ # Stops workers and removes an RPC session from the live session map.
237
287
  def close_session(session_id:)
238
288
  rpc_session = fetch_session(session_id)
239
289
  close_rpc_session(rpc_session)
240
290
  { closed: true }
241
291
  end
242
292
 
293
+ # Closes idle empty sessions left behind by UI lifecycle transitions.
243
294
  def cleanup_unused_sessions
244
295
  rpc_sessions = @mutex.synchronize { @sessions.values.dup }
245
296
  rpc_sessions.reverse_each do |rpc_session|
@@ -250,11 +301,18 @@ module Kward
250
301
  { closed: true }
251
302
  end
252
303
 
304
+ # Returns the normalized transcript for the active RPC session.
253
305
  def transcript(session_id:)
254
306
  rpc_session = fetch_session(session_id)
255
307
  { session: session_payload(rpc_session), messages: TranscriptNormalizer.new(rpc_session.conversation.messages).normalize }
256
308
  end
257
309
 
310
+ # Queues or starts an async model turn for an RPC session.
311
+ #
312
+ # `streaming_behavior` controls busy-session behavior: create a new turn,
313
+ # queue a follow-up, or steer the running turn when the active provider
314
+ # supports native steering. The returned turn id is used for status,
315
+ # cancellation, and event replay.
258
316
  def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [])
259
317
  rpc_session = fetch_session(session_id)
260
318
  normalized_attachments = normalize_attachments(attachments)
@@ -439,13 +497,30 @@ module Kward
439
497
  normalize_model(provider: provider, id: model, model: model, contextWindow: context_window, current: true)
440
498
  end
441
499
 
500
+ def session_model(rpc_session)
501
+ current = current_model
502
+ provider = rpc_session.conversation.provider || current[:provider]
503
+ model = rpc_session.conversation.model || current[:id]
504
+ reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
505
+ reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
506
+ context_window = current[:contextWindow] if provider == current[:provider] && model == current[:id]
507
+ normalize_model(
508
+ provider: provider,
509
+ id: model,
510
+ model: model,
511
+ reasoningEffort: reasoning_effort,
512
+ contextWindow: context_window,
513
+ current: true
514
+ )
515
+ end
516
+
442
517
  def in_flight_steer_supported?
443
518
  supports_in_flight_steer?
444
519
  end
445
520
 
446
521
  def runtime_state(session_id:)
447
522
  rpc_session = fetch_session(session_id)
448
- model = current_model
523
+ model = session_model(rpc_session)
449
524
  compaction_settings = self.compaction_settings
450
525
  auto_compaction_reserve_tokens = compaction_reserve_tokens(
451
526
  context_window: model[:contextWindow],
@@ -459,7 +534,7 @@ module Kward
459
534
  steering_supported: supports_in_flight_steer?,
460
535
  auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
461
536
  active_persona_label: active_persona_label(rpc_session),
462
- message_count: message_count(rpc_session.conversation),
537
+ message_count: @session_metrics.message_count(rpc_session.conversation),
463
538
  pending_count: pending_turn_count(rpc_session.id),
464
539
  compaction_enabled: compaction_settings.enabled,
465
540
  workspace_guardrails_enabled: workspace_guardrails_enabled?
@@ -469,8 +544,8 @@ module Kward
469
544
  def runtime_stats(session_id:)
470
545
  rpc_session = fetch_session(session_id)
471
546
  session = session_payload(rpc_session)
472
- counts = message_stats(rpc_session.conversation)
473
- model = current_model
547
+ counts = @session_metrics.message_stats(rpc_session.conversation)
548
+ model = session_model(rpc_session)
474
549
  compaction_settings = self.compaction_settings
475
550
  auto_compaction_reserve_tokens = compaction_reserve_tokens(
476
551
  context_window: model[:contextWindow],
@@ -481,7 +556,7 @@ module Kward
481
556
  counts: counts,
482
557
  model: model,
483
558
  auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
484
- context_usage: context_usage(rpc_session, model),
559
+ context_usage: @session_metrics.context_usage(rpc_session, model, client: @client),
485
560
  compaction_enabled: compaction_settings.enabled
486
561
  )
487
562
  end
@@ -529,6 +604,7 @@ module Kward
529
604
  def new_conversation(workspace_root: Dir.pwd)
530
605
  Conversation.new(
531
606
  workspace_root: workspace_root,
607
+ provider: (@client.current_provider if @client.respond_to?(:current_provider)),
532
608
  model: (@client.current_model if @client.respond_to?(:current_model)),
533
609
  reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort)),
534
610
  plugin_registry: plugin_registry
@@ -540,8 +616,7 @@ module Kward
540
616
  reasoning_effort = current_reasoning_effort
541
617
  sessions = @mutex.synchronize { @sessions.values }
542
618
  sessions.each do |rpc_session|
543
- rpc_session.conversation.update_runtime_context!(model: model, reasoning_effort: reasoning_effort)
544
- rpc_session.session.update_runtime(model: model, reasoning_effort: reasoning_effort)
619
+ rpc_session.conversation.update_runtime_context!(provider: current_model[:provider], model: model, reasoning_effort: reasoning_effort)
545
620
  end
546
621
  end
547
622
 
@@ -621,42 +696,6 @@ module Kward
621
696
  @mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
622
697
  end
623
698
 
624
- def message_count(conversation)
625
- conversation.messages.count { |message| message_role(message) != "system" }
626
- end
627
-
628
- def context_usage(rpc_session, model)
629
- context_parts = if @client.respond_to?(:current_context_parts)
630
- @client.current_context_parts(rpc_session.conversation.messages, rpc_session.tool_registry.schemas)
631
- else
632
- { provider: model[:provider], model: model[:id], messages: rpc_session.conversation.messages, tools: rpc_session.tool_registry.schemas }
633
- end
634
- @context_usage.call(
635
- provider: model[:provider],
636
- model: model[:id],
637
- context_window: model[:contextWindow],
638
- context_parts: context_parts
639
- )
640
- end
641
-
642
- def message_stats(conversation)
643
- conversation.messages.each_with_object({ userMessages: 0, assistantMessages: 0, toolCalls: 0, toolResults: 0, totalMessages: 0 }) do |message, counts|
644
- role = message_role(message)
645
- next if role == "system"
646
-
647
- counts[:totalMessages] += 1
648
- case role
649
- when "user"
650
- counts[:userMessages] += 1
651
- when "assistant"
652
- counts[:assistantMessages] += 1
653
- counts[:toolCalls] += tool_calls(message).length
654
- when "tool", "toolResult"
655
- counts[:toolResults] += 1
656
- end
657
- end
658
- end
659
-
660
699
  def tool_calls(message)
661
700
  MessageAccess.tool_calls(message)
662
701
  end
@@ -669,24 +708,15 @@ module Kward
669
708
  MessageAccess.content(message)
670
709
  end
671
710
 
672
- def tree_entries(rpc_session)
673
- rpc_session.store.session_entries(rpc_session.session.path)
674
- end
675
-
676
- def resolve_tree_entry_id(entries, entry_id)
677
- id = entry_id.to_s
678
- return id if entries.any? { |record| record["id"].to_s == id }
679
-
680
- match = id.match(/\Amessage:(\d+)\z/)
681
- return entries[match[1].to_i]&.dig("id") if match
682
-
683
- id
711
+ def session_tree_helper(rpc_session)
712
+ SessionTree.new(rpc_session)
684
713
  end
685
714
 
686
715
  def reload_rpc_session(rpc_session)
687
716
  session, conversation = rpc_session.store.load(
688
717
  rpc_session.session.path,
689
718
  workspace: configured_workspace(rpc_session.workspace_root),
719
+ provider: current_model[:provider],
690
720
  model: current_model_id,
691
721
  reasoning_effort: current_reasoning_effort
692
722
  )
@@ -700,242 +730,19 @@ module Kward
700
730
  def flatten_session_tree(rpc_session)
701
731
  roots = rpc_session.store.session_tree(rpc_session.session.path)
702
732
  current_leaf = rpc_session.session.leaf_id || rpc_session.store.current_leaf(rpc_session.session.path)
703
- active_path = tree_active_path(roots, current_leaf)
704
- tool_calls_by_id = tree_tool_calls(roots)
705
- visible_roots = roots.flat_map { |root| visible_tree_nodes(root, current_leaf) }
706
- multiple_roots = visible_roots.length > 1
707
- result = []
708
-
709
- walk = lambda do |node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child|
710
- entry = node[:source]["entry"] || {}
711
- entry_id = entry["id"].to_s
712
- formatted = tree_entry_display(entry, tool_calls_by_id)
713
- display_indent = multiple_roots ? [indent - 1, 0].max : indent
714
- result << {
715
- entryId: entry_id,
716
- parentId: entry["parentId"],
717
- role: formatted[:role],
718
- text: formatted[:text],
719
- current: !current_leaf.to_s.empty? && entry_id == current_leaf.to_s,
720
- depth: display_indent,
721
- isLast: is_last,
722
- ancestorContinues: gutters.map { |gutter| gutter[:show] },
723
- activePath: active_path.include?(entry_id),
724
- selectable: selectable_tree_entry?(entry),
725
- label: node[:source]["label"] || entry["resolvedLabel"],
726
- labelTimestamp: node[:source]["labelTimestamp"],
727
- prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
728
- }.compact
729
-
730
- children = node[:children].sort_by { |child| tree_contains_active_path?(child, active_path) ? 0 : 1 }
731
- multiple_children = children.length > 1
732
- child_indent = if multiple_children
733
- indent + 1
734
- elsif just_branched && indent.positive?
735
- indent + 1
736
- else
737
- indent
738
- end
739
- connector_position = [display_indent - 1, 0].max
740
- child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
741
- children.each_with_index do |child, index|
742
- walk.call(child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false)
743
- end
744
- end
745
-
746
- visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index do |root, index|
747
- walk.call(root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots)
748
- end
749
- result
750
- end
751
-
752
- def user_tree_entry?(entry)
753
- message = entry["message"]
754
- message.is_a?(Hash) && message_role(message) == "user"
755
- end
756
-
757
- def selectable_tree_entry?(entry)
758
- !entry["id"].to_s.empty? && ["message", "compaction", "branch_summary"].include?(entry["type"])
759
- end
760
-
761
- def nearest_visible_parent_by_id(user_entries, entries)
762
- user_ids = user_entries.map { |entry| entry["id"].to_s }.to_h { |id| [id, true] }
763
- by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
764
- user_entries.each_with_object({}) do |entry, parents|
765
- parent_id = entry["parentId"]
766
- while parent_id && by_id[parent_id.to_s] && !user_ids[parent_id.to_s]
767
- parent_id = by_id[parent_id.to_s]["parentId"]
768
- end
769
- parents[entry["id"].to_s] = user_ids[parent_id.to_s] ? parent_id.to_s : nil
770
- end
771
- end
772
-
773
- def active_path_ids(entries, leaf_id)
774
- by_id = entries.to_h { |entry| [entry["id"].to_s, entry] }
775
- ids = []
776
- current = by_id[leaf_id.to_s]
777
- while current
778
- ids << current["id"].to_s
779
- current = by_id[current["parentId"].to_s]
780
- end
781
- ids
782
- end
783
-
784
- def tree_active_path(roots, leaf_id)
785
- by_id = tree_entries_by_id(roots)
786
- ids = []
787
- current = by_id[leaf_id.to_s]
788
- while current
789
- ids << current["id"].to_s
790
- current = by_id[current["parentId"].to_s]
791
- end
792
- ids
793
- end
794
-
795
- def tree_entries_by_id(roots)
796
- roots.each_with_object({}) do |root, map|
797
- stack = [root]
798
- until stack.empty?
799
- node = stack.pop
800
- entry = node["entry"] || {}
801
- map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
802
- stack.concat(Array(node["children"]))
803
- end
804
- end
805
- end
806
-
807
- def visible_tree_nodes(node, current_leaf)
808
- children = Array(node["children"]).flat_map { |child| visible_tree_nodes(child, current_leaf) }
809
- return children if hidden_tree_entry?(node["entry"] || {}, current_leaf)
810
-
811
- [{ source: node, children: children }]
812
- end
813
-
814
- def hidden_tree_entry?(entry, current_leaf)
815
- return false if current_leaf && entry["id"].to_s == current_leaf.to_s
816
- return false unless entry["type"] == "message"
817
-
818
- message = entry["message"]
819
- return false unless message.is_a?(Hash) && message_role(message) == "assistant"
820
-
821
- content = message_content(message)
822
- content_tool_calls = content.is_a?(Array) && content.any? { |part| ToolCall.value(part, :type) == "toolCall" }
823
- (content_tool_calls && !tree_text_content?(content)) || (!tool_calls(message).empty? && full_message_text(message).empty?)
824
- end
825
-
826
- def tree_text_content?(content)
827
- Array(content).any? { |part| ToolCall.value(part, :type) == "text" && ToolCall.value(part, :text).to_s.strip != "" }
828
- end
829
-
830
- def tree_contains_active_path?(node, active_path)
831
- entry_id = (node[:source]["entry"] || {})["id"].to_s
832
- active_path.include?(entry_id) || node[:children].any? { |child| tree_contains_active_path?(child, active_path) }
833
- end
834
-
835
- def tree_tool_calls(roots)
836
- roots.each_with_object({}) do |root, tool_calls_by_id|
837
- stack = [root]
838
- until stack.empty?
839
- node = stack.pop
840
- entry = node["entry"] || {}
841
- message = entry["message"]
842
- if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
843
- tool_calls(message).each { |tool_call| tool_calls_by_id[ToolCall.id(tool_call).to_s] = tool_call }
844
- end
845
- stack.concat(Array(node["children"]))
846
- end
847
- end
848
- end
849
-
850
- def tree_entry_display(entry, tool_calls_by_id = {})
851
- case entry["type"]
852
- when "message"
853
- message = entry["message"] || {}
854
- role = message_role(message).to_s
855
- return { role: "tool", text: format_tool_result(message, tool_calls_by_id) } if ["tool", "toolResult"].include?(role)
856
- return { role: role.empty? ? "message" : role, text: display_message_text(message) }
857
- when "compaction"
858
- return { role: "compaction", text: display_message_text(entry["message"] || {}) }
859
- when "branch_summary"
860
- return { role: "summary", text: truncate_tree_text(entry["summary"]) }
861
- end
862
-
863
- { role: entry["type"].to_s.empty? ? "entry" : entry["type"].to_s, text: entry["type"].to_s }
864
- end
865
-
866
- def tree_prefix(display_indent, gutters, show_connector, is_last, foldable)
867
- return "" if display_indent.to_i <= 0
868
-
869
- connector_position = show_connector ? display_indent - 1 : -1
870
- (0...(display_indent * 3)).map do |index|
871
- level = index / 3
872
- position = index % 3
873
- gutter = gutters.find { |candidate| candidate[:position] == level }
874
-
875
- if gutter
876
- position.zero? && gutter[:show] ? "│" : " "
877
- elsif show_connector && level == connector_position
878
- if position.zero?
879
- is_last ? "└" : "├"
880
- elsif position == 1
881
- foldable ? "⊟" : "─"
882
- else
883
- " "
884
- end
885
- else
886
- " "
887
- end
888
- end.join
889
- end
890
-
891
- def format_tool_result(message, tool_calls_by_id)
892
- tool_call = tool_calls_by_id[tree_message_tool_call_id(message).to_s]
893
- return format_tool_call(tool_call) if tool_call
894
-
895
- name = tree_message_tool_name(message).to_s
896
- name = "tool" if name.empty?
897
- "[#{name}]"
898
- end
899
-
900
- def tree_message_tool_call_id(message)
901
- MessageAccess.tool_call_id(message) || ToolCall.value(message, :toolCallId)
902
- end
903
-
904
- def tree_message_tool_name(message)
905
- MessageAccess.name(message) || ToolCall.value(message, :toolName)
906
- end
907
-
908
- def format_tool_call(tool_call)
909
- name = ToolCall.display_name(tool_call)
910
- args = ToolCall.arguments(tool_call)
911
- case name
912
- when "read"
913
- path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
914
- offset = args["offset"] || args[:offset]
915
- limit = args["limit"] || args[:limit]
916
- display = path.to_s
917
- if offset || limit
918
- start_line = offset || 1
919
- end_line = limit ? start_line.to_i + limit.to_i - 1 : nil
920
- display += ":#{start_line}#{end_line ? "-#{end_line}" : ""}"
921
- end
922
- "[read: #{display}]"
923
- when "write", "edit"
924
- path = args["path"] || args[:path] || args["file_path"] || args[:file_path]
925
- "[#{name}: #{path}]"
926
- when "bash"
927
- command = (args["command"] || args[:command]).to_s.gsub(/[\n\t]/, " ").strip
928
- "[bash: #{command.length > 50 ? "#{command.slice(0, 50)}..." : command}]"
929
- else
930
- serialized = JSON.dump(args)
931
- "[#{name}: #{serialized.length > 40 ? "#{serialized.slice(0, 40)}..." : serialized}]"
932
- end
733
+ tree = session_tree_helper(rpc_session)
734
+ SessionTreeRows.new(
735
+ roots: roots,
736
+ current_leaf: current_leaf,
737
+ selectable: ->(entry) { tree.selectable_entry?(entry) }
738
+ ).rows
933
739
  end
934
740
 
935
741
  def summarize_branch(rpc_session, from_id:, to_id:, custom_instructions: nil)
936
- entries = tree_entries(rpc_session)
937
- active = active_path_ids(entries, from_id)
938
- target = active_path_ids(entries, to_id)
742
+ tree = session_tree_helper(rpc_session)
743
+ entries = tree.entries
744
+ active = tree.active_path_ids(entries, from_id)
745
+ target = tree.active_path_ids(entries, to_id)
939
746
  target_lookup = target.to_h { |id| [id, true] }
940
747
  abandoned = active.reject { |id| target_lookup[id] }
941
748
  messages = entries.select { |entry| abandoned.include?(entry["id"].to_s) }.filter_map { |entry| entry["message"] }
@@ -959,13 +766,7 @@ module Kward
959
766
  end
960
767
 
961
768
  def full_message_text(message)
962
- content = message["content"] || message[:content]
963
- text = if content.is_a?(Array)
964
- content.filter_map { |part| part["text"] || part[:text] }.join("\n")
965
- else
966
- content.to_s
967
- end
968
- text.strip
769
+ MessageText.full_text(message)
969
770
  end
970
771
 
971
772
  def supports_in_flight_steer?
@@ -1012,42 +813,7 @@ module Kward
1012
813
  end
1013
814
 
1014
815
  def normalize_attachments(attachments)
1015
- return [] if attachments.nil?
1016
- raise ArgumentError, "attachments must be an array" unless attachments.is_a?(Array)
1017
-
1018
- attachments.map { |attachment| normalize_attachment(attachment) }
1019
- end
1020
-
1021
- def normalize_attachment(attachment)
1022
- raise ArgumentError, "attachment must be an object" unless attachment.is_a?(Hash)
1023
-
1024
- type = ToolCall.value(attachment, :type).to_s
1025
- raise ArgumentError, "Unsupported attachment type: #{type.empty? ? "unknown" : type}" unless type == "image"
1026
-
1027
- mime_type = normalize_attachment_mime_type(ToolCall.value(attachment, :mimeType) || ToolCall.value(attachment, :mime_type) || ToolCall.value(attachment, :media_type))
1028
- raise ArgumentError, "Unsupported image MIME type: #{mime_type.empty? ? "unknown" : mime_type}" unless RPC_IMAGE_MIME_TYPES.include?(mime_type)
1029
-
1030
- data = ToolCall.value(attachment, :data).to_s
1031
- raise ArgumentError, "Image attachment data must be valid base64" if data.empty?
1032
- raise ArgumentError, "Image attachment data must be raw base64" if data.start_with?("data:")
1033
- declared_size = ToolCall.value(attachment, :sizeBytes) || ToolCall.value(attachment, :size_bytes)
1034
- raise ArgumentError, "Image attachment is too large" if declared_size && declared_size.to_i > RPC_ATTACHMENT_MAX_BYTES
1035
-
1036
- decoded_size = Base64.strict_decode64(data).bytesize
1037
- raise ArgumentError, "Image attachment is too large" if decoded_size > RPC_ATTACHMENT_MAX_BYTES
1038
-
1039
- result = { type: "image", data: data, mimeType: mime_type }
1040
- name = ToolCall.value(attachment, :name)
1041
- result[:alt] = name.to_s unless name.to_s.empty?
1042
- result
1043
- rescue ArgumentError => e
1044
- raise e if e.message.start_with?("Unsupported", "Image attachment", "attachment")
1045
-
1046
- raise ArgumentError, "Image attachment data must be valid base64"
1047
- end
1048
-
1049
- def normalize_attachment_mime_type(mime_type)
1050
- mime_type.to_s.downcase
816
+ AttachmentNormalizer.new(max_bytes: RPC_ATTACHMENT_MAX_BYTES, mime_types: RPC_IMAGE_MIME_TYPES).normalize(attachments)
1051
817
  end
1052
818
 
1053
819
  def plugin_registry
@@ -1058,6 +824,11 @@ module Kward
1058
824
  PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES + ConfigFiles.prompt_templates(reserved_commands: PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES).map(&:command)
1059
825
  end
1060
826
 
827
+ # Wires together the per-RPC-session runtime objects.
828
+ #
829
+ # This is the RPC counterpart to the CLI interactive setup: attach plugin
830
+ # context, create a prompt bridge for UI questions/footer output, advertise
831
+ # tools with the workspace guardrail policy, and build the shared `Agent`.
1061
832
  def build_rpc_session(store, session, conversation, workspace_root)
1062
833
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
1063
834
  id = SecureRandom.uuid
@@ -1093,11 +864,11 @@ module Kward
1093
864
  end
1094
865
 
1095
866
  def workspace_guardrails_enabled?
1096
- ConfigFiles.workspace_guardrails_enabled?(ConfigFiles.read_config(config_path))
867
+ @config_manager.workspace_guardrails_enabled?
1097
868
  end
1098
869
 
1099
870
  def session_auto_resume_enabled?
1100
- ConfigFiles.session_auto_resume_enabled?(ConfigFiles.read_config(config_path))
871
+ @config_manager.session_auto_resume_enabled?
1101
872
  end
1102
873
 
1103
874
  def config_path
@@ -1200,6 +971,11 @@ module Kward
1200
971
  rpc_session.worker = nil if rpc_session.worker == Thread.current
1201
972
  end
1202
973
 
974
+ # Executes one queued turn and emits normalized RPC events.
975
+ #
976
+ # This method is intentionally the only place that calls `Agent#ask` for RPC
977
+ # turns. Keep event translation near this boundary so CLI rendering and RPC
978
+ # protocol details do not leak into `Agent`.
1203
979
  def run_turn(rpc_session, turn)
1204
980
  rpc_session.running_turn_id = turn.id
1205
981
  turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name