kward 0.66.0 → 0.67.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,12 +17,14 @@ require_relative "../model/model_info"
17
17
  require_relative "../plugin_registry"
18
18
  require_relative "../prompts/commands"
19
19
  require_relative "../session_store"
20
+ require_relative "../session_trash"
20
21
  require_relative "../steering"
21
22
  require_relative "../tools/tool_call"
22
23
  require_relative "../tools/registry"
23
24
  require_relative "../transcript_export"
24
25
  require_relative "../workspace"
25
26
  require_relative "prompt_bridge"
27
+ require_relative "runtime_payloads"
26
28
  require_relative "tool_event_normalizer"
27
29
  require_relative "transcript_normalizer"
28
30
 
@@ -33,24 +35,31 @@ module Kward
33
35
  RPC_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024
34
36
  RPC_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"].freeze
35
37
  STREAMING_BEHAVIORS = ["newTurn", "followUp", "steer"].freeze
38
+ FOOTER_REFRESH_INTERVAL = 1.0
36
39
  WORKER_STOP = Object.new.freeze
37
40
 
38
- RpcSession = Struct.new(:id, :workspace_root, :store, :session, :conversation, :agent, :tool_registry, :prompt, :plugin_output, :queue, :worker, :running_turn_id, keyword_init: true)
39
- Turn = Struct.new(:id, :session_id, :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)
41
+ 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
+ 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)
40
43
 
41
- def initialize(server:, client: Client.new, config_dir: ConfigFiles.config_dir, context_usage: ContextUsage.new)
44
+ def initialize(server:, client: Client.new, config_dir: ConfigFiles.config_dir, context_usage: ContextUsage.new, session_trash: SessionTrash.new)
42
45
  @server = server
43
46
  @client = client
44
47
  @config_dir = config_dir
45
48
  @context_usage = context_usage
49
+ @session_trash = session_trash
46
50
  @sessions = {}
47
51
  @turns = {}
48
52
  @mutex = Mutex.new
49
53
  end
50
54
 
51
- def create_session(workspace_root: Dir.pwd, name: nil)
55
+ def create_session(workspace_root: Dir.pwd, name: nil, resume_last: false)
52
56
  workspace_root = validate_workspace_root(workspace_root)
53
57
  store = SessionStore.new(config_dir: @config_dir, cwd: workspace_root)
58
+ if resume_last && session_auto_resume_enabled? && name.to_s.strip.empty?
59
+ path = store.remembered_last_session_path
60
+ return resume_session(path: path, workspace_root: workspace_root, include_transcript: true) if path
61
+ end
62
+
54
63
  conversation = new_conversation(workspace_root: workspace_root)
55
64
  session = store.create(model: conversation.model, reasoning_effort: conversation.reasoning_effort)
56
65
  session.rename(name) unless name.to_s.strip.empty?
@@ -58,10 +67,11 @@ module Kward
58
67
  rpc_session = build_rpc_session(store, session, conversation, workspace_root)
59
68
  remember_session(rpc_session)
60
69
  cleanup_other_unused_sessions(rpc_session)
70
+ emit_footer_update(rpc_session)
61
71
  session_payload(rpc_session)
62
72
  end
63
73
 
64
- def resume_session(path:, workspace_root: nil)
74
+ def resume_session(path:, workspace_root: nil, include_transcript: false)
65
75
  root = validate_workspace_root(workspace_root || Dir.pwd)
66
76
  store = SessionStore.new(config_dir: @config_dir, cwd: root)
67
77
  location = store.session_location(path)
@@ -69,23 +79,26 @@ module Kward
69
79
  store = SessionStore.new(config_dir: @config_dir, cwd: root)
70
80
  session, conversation = store.load(
71
81
  location[:path],
72
- workspace: Workspace.new(root: root),
82
+ workspace: configured_workspace(root),
73
83
  model: current_model_id,
74
84
  reasoning_effort: current_reasoning_effort
75
85
  )
76
86
  rpc_session = build_rpc_session(store, session, conversation, root)
77
87
  remember_session(rpc_session)
78
88
  cleanup_other_unused_sessions(rpc_session)
79
- session_payload(rpc_session)
89
+ emit_footer_update(rpc_session)
90
+ payload = session_payload(rpc_session)
91
+ payload[:messages] = TranscriptNormalizer.new(rpc_session.conversation.messages).normalize if include_transcript
92
+ payload[:resumed] = true
93
+ payload
80
94
  end
81
95
 
82
- def list_sessions(workspace_root: Dir.pwd, limit: 20)
96
+ def list_sessions(workspace_root: Dir.pwd, limit: nil, current_session_path: nil)
83
97
  root = validate_workspace_root(workspace_root)
84
98
  store = SessionStore.new(config_dir: @config_dir, cwd: root)
85
- limit = limit.to_i <= 0 ? 20 : limit.to_i
86
- store.recent_tree(limit: limit + active_session_count(root))
87
- .reject { |info| active_empty_unnamed_session_info?(info, root) }
88
- .first(limit)
99
+ requested_limit = limit.to_i if limit
100
+ requested_limit = nil unless requested_limit&.positive?
101
+ store.recent(limit: requested_limit, keep_empty_path: current_session_path)
89
102
  .map { |info| session_info_payload(info, workspace_root: root) }
90
103
  end
91
104
 
@@ -101,6 +114,7 @@ module Kward
101
114
  rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
102
115
  remember_session(rpc_session)
103
116
  cleanup_other_unused_sessions(rpc_session)
117
+ emit_footer_update(rpc_session)
104
118
  session_payload(rpc_session)
105
119
  end
106
120
 
@@ -124,39 +138,85 @@ module Kward
124
138
  def fork_messages(session_id:)
125
139
  rpc_session = fetch_session(session_id)
126
140
  {
127
- messages: entry_messages(rpc_session.conversation).each_with_index.filter_map do |message, index|
128
- next unless message_role(message) == "user"
141
+ messages: tree_entries(rpc_session).filter_map do |record|
142
+ message = record["message"]
143
+ next unless message.is_a?(Hash) && message_role(message) == "user"
129
144
 
130
- { entryId: entry_id(index), text: display_message_text(message) }
145
+ { entryId: record["id"], text: display_message_text(message) }
131
146
  end
132
147
  }
133
148
  end
134
149
 
135
150
  def fork_session(session_id:, entry_id:)
136
151
  source = fetch_session(session_id)
137
- index = entry_index(entry_id)
138
- messages = entry_messages(source.conversation)
139
- selected = messages[index] || raise(ArgumentError, "Unknown fork entryId: #{entry_id}")
140
- raise ArgumentError, "Entry is not forkable: #{entry_id}" unless message_role(selected) == "user"
152
+ entries = tree_entries(source)
153
+ resolved_entry_id = resolve_tree_entry_id(entries, entry_id)
154
+ selected_index = entries.index { |record| record["id"].to_s == resolved_entry_id.to_s }
155
+ selected = selected_index && entries[selected_index]
156
+ raise ArgumentError, "Unknown fork entryId: #{entry_id}" unless selected
157
+
158
+ message = selected["message"]
159
+ raise ArgumentError, "Entry is not forkable: #{entry_id}" unless message.is_a?(Hash) && message_role(message) == "user"
141
160
 
142
161
  session, conversation = source.store.create_independent_from_messages(
143
- messages[0...index],
162
+ entries[0...selected_index].filter_map { |record| record["message"] },
144
163
  model: source.conversation.model,
145
164
  reasoning_effort: source.conversation.reasoning_effort,
146
165
  parent_session: source.session
147
166
  )
148
167
 
149
- # ensure forked sessions retain the original persona context
150
168
  rpc_session = build_rpc_session(source.store, session, conversation, source.workspace_root)
151
169
  remember_session(rpc_session)
152
170
  cleanup_other_unused_sessions(rpc_session)
153
171
  {
154
172
  session: session_payload(rpc_session),
155
- text: full_message_text(selected),
173
+ text: full_message_text(message),
156
174
  cancelled: false
157
175
  }
158
176
  end
159
177
 
178
+ def session_tree(session_id:)
179
+ rpc_session = fetch_session(session_id)
180
+ { items: flatten_session_tree(rpc_session) }
181
+ end
182
+
183
+ def set_tree_label(session_id:, entry_id:, label: nil)
184
+ rpc_session = fetch_session(session_id)
185
+ rpc_session.session.append_label_change(entry_id, label)
186
+ { ok: true }
187
+ end
188
+
189
+ def navigate_tree(session_id:, entry_id:, summarize: false, custom_instructions: nil)
190
+ rpc_session = fetch_session(session_id)
191
+ entries = tree_entries(rpc_session)
192
+ resolved_entry_id = resolve_tree_entry_id(entries, entry_id)
193
+ entry = rpc_session.store.session_entry(rpc_session.session.path, resolved_entry_id)
194
+ raise ArgumentError, "Unknown tree entryId: #{entry_id}" unless entry
195
+
196
+ raise ArgumentError, "Tree entry is not selectable: #{entry_id}" unless selectable_tree_entry?(entry)
197
+
198
+ message = entry["message"]
199
+ user_entry = user_tree_entry?(entry)
200
+ target_leaf = user_entry ? entry["parentId"] : entry["id"]
201
+ editor_text = user_entry ? full_message_text(message) : nil
202
+ previous_leaf = rpc_session.session.leaf_id
203
+
204
+ if summarize
205
+ summary = summarize_branch(rpc_session, from_id: previous_leaf, to_id: target_leaf, custom_instructions: custom_instructions)
206
+ target_leaf = rpc_session.session.append_branch_summary(target_leaf, from_id: previous_leaf, summary: summary, details: {})
207
+ else
208
+ target_leaf ? rpc_session.session.branch(target_leaf) : rpc_session.session.reset_leaf
209
+ end
210
+
211
+ reload_rpc_session(rpc_session)
212
+ {
213
+ session: session_payload(rpc_session),
214
+ editorText: editor_text,
215
+ cancelled: false,
216
+ aborted: false
217
+ }.compact
218
+ end
219
+
160
220
  def export_session(session_id:, path: nil, format: nil)
161
221
  rpc_session = fetch_session(session_id)
162
222
  format = export_format(format)
@@ -169,9 +229,9 @@ module Kward
169
229
  def delete_session(session_id:)
170
230
  rpc_session = fetch_session(session_id)
171
231
  path = rpc_session.session.path
172
- close_session(session_id: session_id)
173
- File.delete(path) if File.exist?(path)
174
- { deleted: true, path: path }
232
+ close_rpc_session(rpc_session, delete_unused: false)
233
+ deleted = @session_trash.delete(path)
234
+ { deleted: deleted, path: path }
175
235
  end
176
236
 
177
237
  def close_session(session_id:)
@@ -199,6 +259,7 @@ module Kward
199
259
  rpc_session = fetch_session(session_id)
200
260
  normalized_attachments = normalize_attachments(attachments)
201
261
  plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
262
+ display_input = input.to_s if input.is_a?(String)
202
263
  content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
203
264
  streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
204
265
  if streaming_behavior == "steer"
@@ -211,6 +272,7 @@ module Kward
211
272
  id: SecureRandom.uuid,
212
273
  session_id: rpc_session.id,
213
274
  input: content,
275
+ display_input: display_input,
214
276
  status: "queued",
215
277
  cancel_requested: false,
216
278
  cancellation: Cancellation.new,
@@ -260,7 +322,6 @@ module Kward
260
322
 
261
323
  def run_command(session_id:, command:, arguments: "")
262
324
  name = command.to_s.delete_prefix("/")
263
- return { ok: false, error: "unsupported", reason: "notImplemented" } if name == "crew"
264
325
  return { ok: false, error: "unsupported", reason: "clientClipboardOwnedByUi" } if name == "copy"
265
326
 
266
327
  run_plugin_command(session_id: session_id, command: name, arguments: arguments)
@@ -295,8 +356,8 @@ module Kward
295
356
  { autoSummary: false }
296
357
  end
297
358
 
298
- def memory_list(include_inactive: false)
299
- memory_manager.list(include_inactive: include_inactive)
359
+ def memory_list(include_inactive: false, workspace_root: Dir.pwd)
360
+ memory_manager.hierarchy(include_inactive: include_inactive, workspace_root: workspace_root)
300
361
  end
301
362
 
302
363
  def memory_add(text:, scope: nil, tags: [])
@@ -312,7 +373,11 @@ module Kward
312
373
  end
313
374
 
314
375
  def memory_promote(id:)
315
- { memory: memory_manager.promote_soft_to_core(id) }
376
+ { memory: memory_manager.promote_memory(id) }
377
+ end
378
+
379
+ def memory_relax(id:, workspace_root: Dir.pwd)
380
+ { memory: memory_manager.relax_core(id, workspace_root: workspace_root) }
316
381
  end
317
382
 
318
383
  def memory_inspect
@@ -387,35 +452,18 @@ module Kward
387
452
  compaction_settings: compaction_settings
388
453
  )
389
454
  session = session_payload(rpc_session)
390
- pending_count = pending_turn_count(rpc_session.id)
391
- {
455
+ RuntimePayloads.state(
456
+ session: session,
392
457
  model: model,
393
- thinkingLevel: model[:reasoningEffort],
394
- isStreaming: streaming?(rpc_session),
395
- isCompacting: false,
396
- steeringMode: supports_in_flight_steer? ? "in-flight" : "one-at-a-time",
397
- followUpMode: "one-at-a-time",
398
- sessionFile: session[:path],
399
- sessionId: session[:persistentId],
400
- rpcSessionId: session[:id],
401
- persistentSessionId: session[:persistentId],
402
- sessionName: session[:name],
403
- autoCompactionEnabled: compaction_settings.enabled,
404
- autoCompactionReserveTokens: auto_compaction_reserve_tokens,
405
- autoRetryEnabled: false,
406
- defaultProvider: model[:provider],
407
- defaultModel: default_model_label(model),
408
- defaultThinkingLevel: model[:reasoningEffort],
409
- hideThinkingBlock: false,
410
- quietStartup: false,
411
- transport: "kward-rpc",
412
- imageAutoResize: false,
413
- blockImages: false,
414
- enabledModels: [],
415
- enableSkillCommands: true,
416
- messageCount: message_count(rpc_session.conversation),
417
- pendingMessageCount: pending_count
418
- }.compact
458
+ streaming: streaming?(rpc_session),
459
+ steering_supported: supports_in_flight_steer?,
460
+ auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
461
+ active_persona_label: active_persona_label(rpc_session),
462
+ message_count: message_count(rpc_session.conversation),
463
+ pending_count: pending_turn_count(rpc_session.id),
464
+ compaction_enabled: compaction_settings.enabled,
465
+ workspace_guardrails_enabled: workspace_guardrails_enabled?
466
+ )
419
467
  end
420
468
 
421
469
  def runtime_stats(session_id:)
@@ -428,22 +476,14 @@ module Kward
428
476
  context_window: model[:contextWindow],
429
477
  compaction_settings: compaction_settings
430
478
  )
431
- {
432
- sessionFile: session[:path],
433
- sessionId: session[:persistentId],
434
- rpcSessionId: session[:id],
435
- persistentSessionId: session[:persistentId],
436
- sessionName: session[:name],
437
- userMessages: counts[:userMessages],
438
- assistantMessages: counts[:assistantMessages],
439
- toolCalls: counts[:toolCalls],
440
- toolResults: counts[:toolResults],
441
- totalMessages: counts[:totalMessages],
442
- usingSubscription: model[:provider] == "Codex",
443
- autoCompactionEnabled: compaction_settings.enabled,
444
- autoCompactionReserveTokens: auto_compaction_reserve_tokens,
445
- contextUsage: context_usage(rpc_session, model)
446
- }.compact
479
+ RuntimePayloads.stats(
480
+ session: session,
481
+ counts: counts,
482
+ model: model,
483
+ auto_compaction_reserve_tokens: auto_compaction_reserve_tokens,
484
+ context_usage: context_usage(rpc_session, model),
485
+ compaction_enabled: compaction_settings.enabled
486
+ )
447
487
  end
448
488
 
449
489
  def refresh_client_config
@@ -452,19 +492,25 @@ module Kward
452
492
  refresh_session_tool_registries
453
493
  end
454
494
 
495
+ def reload_plugins
496
+ registry = PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
497
+ sessions = @mutex.synchronize do
498
+ @plugin_registry = registry
499
+ @sessions.values
500
+ end
501
+ sessions.each do |rpc_session|
502
+ rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
503
+ rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
504
+ emit_footer_update(rpc_session)
505
+ end
506
+ end
507
+
455
508
  def session_payload(rpc_session)
456
- {
457
- id: rpc_session.id,
458
- persistentId: rpc_session.session.id,
459
- path: rpc_session.session.path,
460
- workspaceRoot: rpc_session.workspace_root,
461
- cwd: rpc_session.session.cwd.to_s.empty? ? rpc_session.workspace_root : rpc_session.session.cwd,
462
- name: rpc_session.session.name,
463
- createdAt: rpc_session.session.created_at&.utc&.iso8601(3),
464
- modifiedAt: session_modified_at(rpc_session.session)&.utc&.iso8601(3),
465
- parentId: rpc_session.session.parent_id,
466
- parentPath: rpc_session.session.parent_path
467
- }
509
+ RuntimePayloads.session(
510
+ rpc_session,
511
+ modified_at: session_modified_at(rpc_session.session),
512
+ active_persona_label: active_persona_label(rpc_session)
513
+ )
468
514
  end
469
515
 
470
516
  def session_modified_at(session)
@@ -515,8 +561,7 @@ module Kward
515
561
  end
516
562
 
517
563
  def compaction_settings
518
- path = File.join(@config_dir, "config.json")
519
- Kward::Compaction::Settings.from_config(ConfigFiles.read_config(path))
564
+ Kward::Compaction::Settings.from_config(ConfigFiles.read_config(config_path))
520
565
  rescue StandardError
521
566
  Kward::Compaction::Settings.new
522
567
  end
@@ -548,10 +593,11 @@ module Kward
548
593
  )
549
594
  end
550
595
 
551
- def default_model_label(model)
552
- return nil if model[:provider].to_s.empty? || model[:id].to_s.empty?
553
-
554
- "#{model[:provider]}/#{model[:id]}"
596
+ def active_persona_label(rpc_session)
597
+ ConfigFiles.active_persona_label(
598
+ workspace_root: rpc_session.workspace_root,
599
+ model: rpc_session.conversation.model
600
+ ) || "Assistant"
555
601
  end
556
602
 
557
603
  def streaming?(rpc_session)
@@ -575,17 +621,6 @@ module Kward
575
621
  @mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
576
622
  end
577
623
 
578
- def active_empty_unnamed_session_info?(info, workspace_root)
579
- rpc_sessions = @mutex.synchronize { @sessions.values.dup }
580
- rpc_sessions.any? do |rpc_session|
581
- rpc_session.workspace_root == workspace_root &&
582
- File.expand_path(rpc_session.session.path) == File.expand_path(info.path) &&
583
- session_idle?(rpc_session) &&
584
- info.name.to_s.strip.empty? &&
585
- info.message_count.to_i.zero?
586
- end
587
- end
588
-
589
624
  def message_count(conversation)
590
625
  conversation.messages.count { |message| message_role(message) != "system" }
591
626
  end
@@ -634,23 +669,293 @@ module Kward
634
669
  MessageAccess.content(message)
635
670
  end
636
671
 
637
- def entry_messages(conversation)
638
- conversation.messages.reject { |message| message_role(message) == "system" }
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
684
+ end
685
+
686
+ def reload_rpc_session(rpc_session)
687
+ session, conversation = rpc_session.store.load(
688
+ rpc_session.session.path,
689
+ workspace: configured_workspace(rpc_session.workspace_root),
690
+ model: current_model_id,
691
+ reasoning_effort: current_reasoning_effort
692
+ )
693
+ conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
694
+ rpc_session.session = session
695
+ rpc_session.conversation = conversation
696
+ rebuild_session_tools(rpc_session)
697
+ emit_footer_update(rpc_session)
698
+ end
699
+
700
+ def flatten_session_tree(rpc_session)
701
+ roots = rpc_session.store.session_tree(rpc_session.session.path)
702
+ 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 != "" }
639
828
  end
640
829
 
641
- def entry_id(index)
642
- "message:#{index}"
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
643
848
  end
644
849
 
645
- def entry_index(entry_id)
646
- match = entry_id.to_s.match(/\Amessage:(\d+)\z/)
647
- raise ArgumentError, "Invalid entryId: #{entry_id}" unless match
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
648
862
 
649
- match[1].to_i
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
933
+ end
934
+
935
+ 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)
939
+ target_lookup = target.to_h { |id| [id, true] }
940
+ abandoned = active.reject { |id| target_lookup[id] }
941
+ messages = entries.select { |entry| abandoned.include?(entry["id"].to_s) }.filter_map { |entry| entry["message"] }
942
+ source_text = messages.map { |message| "#{message_role(message)}: #{full_message_text(message)}" }.join("\n\n")
943
+ prompt = [
944
+ { role: "system", content: "Summarize the abandoned conversation branch concisely for future context." },
945
+ { role: "user", content: [custom_instructions.to_s.strip, source_text].reject(&:empty?).join("\n\n") }
946
+ ]
947
+ response = @client.chat(prompt, tools: [])
948
+ text = full_message_text(response)
949
+ text.empty? ? "Branch summary unavailable." : text
650
950
  end
651
951
 
652
952
  def display_message_text(message)
653
- full_message_text(message).gsub(/\s+/, " ").strip.slice(0, 120).to_s
953
+ truncate_tree_text(full_message_text(message))
954
+ end
955
+
956
+ def truncate_tree_text(text)
957
+ normalized = text.to_s.gsub(/\s+/, " ").strip
958
+ normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
654
959
  end
655
960
 
656
961
  def full_message_text(message)
@@ -716,23 +1021,23 @@ module Kward
716
1021
  def normalize_attachment(attachment)
717
1022
  raise ArgumentError, "attachment must be an object" unless attachment.is_a?(Hash)
718
1023
 
719
- type = value(attachment, :type).to_s
1024
+ type = ToolCall.value(attachment, :type).to_s
720
1025
  raise ArgumentError, "Unsupported attachment type: #{type.empty? ? "unknown" : type}" unless type == "image"
721
1026
 
722
- mime_type = normalize_attachment_mime_type(value(attachment, :mimeType) || value(attachment, :mime_type) || value(attachment, :media_type))
1027
+ mime_type = normalize_attachment_mime_type(ToolCall.value(attachment, :mimeType) || ToolCall.value(attachment, :mime_type) || ToolCall.value(attachment, :media_type))
723
1028
  raise ArgumentError, "Unsupported image MIME type: #{mime_type.empty? ? "unknown" : mime_type}" unless RPC_IMAGE_MIME_TYPES.include?(mime_type)
724
1029
 
725
- data = value(attachment, :data).to_s
1030
+ data = ToolCall.value(attachment, :data).to_s
726
1031
  raise ArgumentError, "Image attachment data must be valid base64" if data.empty?
727
1032
  raise ArgumentError, "Image attachment data must be raw base64" if data.start_with?("data:")
728
- declared_size = value(attachment, :sizeBytes) || value(attachment, :size_bytes)
1033
+ declared_size = ToolCall.value(attachment, :sizeBytes) || ToolCall.value(attachment, :size_bytes)
729
1034
  raise ArgumentError, "Image attachment is too large" if declared_size && declared_size.to_i > RPC_ATTACHMENT_MAX_BYTES
730
1035
 
731
1036
  decoded_size = Base64.strict_decode64(data).bytesize
732
1037
  raise ArgumentError, "Image attachment is too large" if decoded_size > RPC_ATTACHMENT_MAX_BYTES
733
1038
 
734
1039
  result = { type: "image", data: data, mimeType: mime_type }
735
- name = value(attachment, :name)
1040
+ name = ToolCall.value(attachment, :name)
736
1041
  result[:alt] = name.to_s unless name.to_s.empty?
737
1042
  result
738
1043
  rescue ArgumentError => e
@@ -745,14 +1050,6 @@ module Kward
745
1050
  mime_type.to_s.downcase
746
1051
  end
747
1052
 
748
- def value(object, key)
749
- return nil unless object.respond_to?(:key?)
750
- return object[key] if object.key?(key)
751
- return object[key.to_s] if object.key?(key.to_s)
752
-
753
- nil
754
- end
755
-
756
1053
  def plugin_registry
757
1054
  @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
758
1055
  end
@@ -788,11 +1085,29 @@ module Kward
788
1085
  end
789
1086
 
790
1087
  def build_tool_registry(workspace_root, prompt)
791
- ToolRegistry.new(workspace: Workspace.new(root: workspace_root), prompt: prompt)
1088
+ ToolRegistry.new(workspace: configured_workspace(workspace_root), prompt: prompt)
1089
+ end
1090
+
1091
+ def configured_workspace(root)
1092
+ Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
1093
+ end
1094
+
1095
+ def workspace_guardrails_enabled?
1096
+ ConfigFiles.workspace_guardrails_enabled?(ConfigFiles.read_config(config_path))
1097
+ end
1098
+
1099
+ def session_auto_resume_enabled?
1100
+ ConfigFiles.session_auto_resume_enabled?(ConfigFiles.read_config(config_path))
1101
+ end
1102
+
1103
+ def config_path
1104
+ File.join(@config_dir, "config.json")
792
1105
  end
793
1106
 
794
1107
  def remember_session(rpc_session)
795
1108
  @mutex.synchronize { @sessions[rpc_session.id] = rpc_session }
1109
+ rpc_session.store.remember_last_session(rpc_session.session) if rpc_session.store.respond_to?(:remember_last_session)
1110
+ start_footer_worker(rpc_session)
796
1111
  end
797
1112
 
798
1113
  def fetch_session(session_id)
@@ -809,10 +1124,11 @@ module Kward
809
1124
  rpc_session.worker = Thread.new { worker_loop(rpc_session) }
810
1125
  end
811
1126
 
812
- def close_rpc_session(rpc_session)
1127
+ def close_rpc_session(rpc_session, delete_unused: true)
813
1128
  @mutex.synchronize { @sessions.delete(rpc_session.id) }
814
1129
  stop_worker(rpc_session)
815
- rpc_session.session.delete_if_unused if rpc_session.session.respond_to?(:delete_if_unused)
1130
+ stop_footer_worker(rpc_session)
1131
+ rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
816
1132
  end
817
1133
 
818
1134
  def cleanup_other_unused_sessions(current_session)
@@ -826,6 +1142,7 @@ module Kward
826
1142
 
827
1143
  @mutex.synchronize { @sessions.delete(rpc_session.id) }
828
1144
  stop_worker(rpc_session)
1145
+ stop_footer_worker(rpc_session)
829
1146
  end
830
1147
  end
831
1148
 
@@ -837,6 +1154,34 @@ module Kward
837
1154
  rpc_session.queue << WORKER_STOP
838
1155
  end
839
1156
 
1157
+ def start_footer_worker(rpc_session)
1158
+ return unless plugin_registry.footer_renderer
1159
+ return if rpc_session.footer_worker&.alive?
1160
+
1161
+ rpc_session.footer_worker = Thread.new do
1162
+ loop do
1163
+ sleep FOOTER_REFRESH_INTERVAL
1164
+ break unless @mutex.synchronize { @sessions[rpc_session.id] == rpc_session }
1165
+
1166
+ emit_footer_update(rpc_session)
1167
+ end
1168
+ rescue StandardError => e
1169
+ @server.log_error(e)
1170
+ ensure
1171
+ rpc_session.footer_worker = nil if rpc_session.footer_worker == Thread.current
1172
+ end
1173
+ end
1174
+
1175
+ def stop_footer_worker(rpc_session)
1176
+ worker = rpc_session.footer_worker
1177
+ rpc_session.footer_worker = nil
1178
+ return unless worker&.alive?
1179
+ return if worker == Thread.current
1180
+
1181
+ worker.kill
1182
+ worker.join(0.1)
1183
+ end
1184
+
840
1185
  def worker_loop(rpc_session)
841
1186
  loop do
842
1187
  turn_id = rpc_session.queue.pop
@@ -870,8 +1215,9 @@ module Kward
870
1215
  if turn.plugin_command_name
871
1216
  run_plugin_turn(rpc_session, turn)
872
1217
  else
1218
+ auto_name_session(rpc_session, turn.display_input || turn.input)
873
1219
  prepare_memory_context(rpc_session.conversation, turn.input)
874
- rpc_session.agent.ask(turn.input, cancellation: turn.cancellation, steering: turn.steering) do |event|
1220
+ rpc_session.agent.ask(turn.input, display_input: turn_display_input(turn), cancellation: turn.cancellation, steering: turn.steering) do |event|
875
1221
  next if turn.cancel_requested
876
1222
 
877
1223
  notify_plugin_transcript_event(rpc_session, event)
@@ -895,6 +1241,24 @@ module Kward
895
1241
  Steering.new
896
1242
  end
897
1243
 
1244
+ def auto_name_session(rpc_session, input)
1245
+ return unless rpc_session.session.name.to_s.strip.empty?
1246
+
1247
+ name = default_session_name(input)
1248
+ rpc_session.session.rename(name) unless name.empty?
1249
+ end
1250
+
1251
+ def default_session_name(input)
1252
+ input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
1253
+ end
1254
+
1255
+ def turn_display_input(turn)
1256
+ return nil if turn.display_input.nil?
1257
+ return nil if turn.display_input == turn.input
1258
+
1259
+ turn.display_input
1260
+ end
1261
+
898
1262
  def prepare_memory_context(conversation, input)
899
1263
  manager = memory_manager
900
1264
  retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
@@ -994,6 +1358,30 @@ module Kward
994
1358
  turn.status = status
995
1359
  turn.finished_at = now
996
1360
  emit_turn_event(turn, "turnFinished", { status: status, error: turn.error })
1361
+ rpc_session = @mutex.synchronize { @sessions[turn.session_id] }
1362
+ emit_footer_update(rpc_session) if rpc_session
1363
+ end
1364
+
1365
+ def emit_footer_update(rpc_session)
1366
+ renderer = plugin_registry.footer_renderer
1367
+ return unless renderer
1368
+
1369
+ text = begin
1370
+ context = PluginRegistry::Context.new(
1371
+ conversation: rpc_session.conversation,
1372
+ session: rpc_session.session,
1373
+ workspace_root: rpc_session.workspace_root,
1374
+ say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
1375
+ )
1376
+ renderer.call(context).to_s.gsub(/\s+/, " ").strip
1377
+ rescue StandardError => e
1378
+ warn "Warning: Kward plugin footer error: #{e.message}"
1379
+ ""
1380
+ end
1381
+ return if rpc_session.last_footer_text == text
1382
+
1383
+ rpc_session.last_footer_text = text
1384
+ @server.notify("ui/footer", { sessionId: rpc_session.id, text: text })
997
1385
  end
998
1386
 
999
1387
  def emit_turn_event(turn, type, payload)
@@ -1069,11 +1457,11 @@ module Kward
1069
1457
  end
1070
1458
 
1071
1459
  def normalized_tool_event_payload(tool_call)
1072
- ToolEventNormalizer.new(tool_call).call_payload(legacy_tool: tool_metadata(tool_call))
1460
+ ToolEventNormalizer.new(tool_call).call_payload
1073
1461
  end
1074
1462
 
1075
1463
  def normalized_tool_result_event_payload(tool_call, content)
1076
- ToolEventNormalizer.new(tool_call, content: content).result_payload(legacy_tool: tool_metadata(tool_call))
1464
+ ToolEventNormalizer.new(tool_call, content: content).result_payload
1077
1465
  end
1078
1466
 
1079
1467
  def turn_error_payload(error)
@@ -1084,35 +1472,6 @@ module Kward
1084
1472
  }
1085
1473
  end
1086
1474
 
1087
- def tool_metadata(tool_call)
1088
- name = ToolCall.name(tool_call)
1089
- args = ToolCall.arguments(tool_call)
1090
-
1091
- case name
1092
- when "edit_file"
1093
- edits = Array(args["edits"] || args[:edits]).map do |edit|
1094
- {
1095
- oldText: edit["old_text"] || edit[:old_text],
1096
- newText: edit["new_text"] || edit[:new_text]
1097
- }.compact
1098
- end
1099
- first_edit = edits.first || {}
1100
- {
1101
- kind: "edit",
1102
- path: args["path"] || args[:path],
1103
- edits: edits,
1104
- oldText: first_edit[:oldText],
1105
- newText: first_edit[:newText]
1106
- }.compact
1107
- when "write_file"
1108
- { kind: "write", path: args["path"] || args[:path] }.compact
1109
- when "run_shell_command"
1110
- { kind: "shell", command: args["command"] || args[:command] }.compact
1111
- else
1112
- nil
1113
- end
1114
- end
1115
-
1116
1475
 
1117
1476
  def now
1118
1477
  Time.now.utc.iso8601(3)