kward 0.74.0 → 0.75.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +56 -3
  7. data/doc/extensibility.md +6 -19
  8. data/doc/getting-started.md +1 -1
  9. data/doc/lifecycle-hooks.md +424 -0
  10. data/doc/memory.md +1 -1
  11. data/doc/personas.md +2 -2
  12. data/doc/plugins.md +1 -1
  13. data/doc/releasing.md +7 -15
  14. data/doc/rpc.md +108 -16
  15. data/doc/session-management.md +9 -9
  16. data/doc/skills.md +199 -0
  17. data/doc/usage.md +7 -7
  18. data/lib/kward/agent.rb +72 -22
  19. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  20. data/lib/kward/auth/oauth_helpers.rb +105 -0
  21. data/lib/kward/auth/openai_oauth.rb +4 -86
  22. data/lib/kward/cli/commands.rb +9 -2
  23. data/lib/kward/cli/compaction.rb +7 -0
  24. data/lib/kward/cli/git.rb +30 -3
  25. data/lib/kward/cli/hook_commands.rb +258 -0
  26. data/lib/kward/cli/memory_commands.rb +1 -5
  27. data/lib/kward/cli/plugins.rb +41 -1
  28. data/lib/kward/cli/prompt_interface.rb +32 -3
  29. data/lib/kward/cli/rendering.rb +1 -1
  30. data/lib/kward/cli/runtime_helpers.rb +44 -5
  31. data/lib/kward/cli/sessions.rb +13 -0
  32. data/lib/kward/cli/settings.rb +226 -152
  33. data/lib/kward/cli/slash_commands.rb +81 -42
  34. data/lib/kward/cli/tabs.rb +26 -6
  35. data/lib/kward/cli.rb +26 -3
  36. data/lib/kward/cli_transcript_formatter.rb +0 -7
  37. data/lib/kward/compaction/token_estimator.rb +112 -0
  38. data/lib/kward/compactor.rb +42 -116
  39. data/lib/kward/config_files.rb +117 -25
  40. data/lib/kward/deep_copy.rb +42 -0
  41. data/lib/kward/diff_view_mode.rb +36 -0
  42. data/lib/kward/events.rb +1 -0
  43. data/lib/kward/export_path.rb +2 -6
  44. data/lib/kward/frontmatter.rb +43 -0
  45. data/lib/kward/hooks/audit_log.rb +121 -0
  46. data/lib/kward/hooks/catalog.rb +110 -0
  47. data/lib/kward/hooks/command_handler.rb +66 -0
  48. data/lib/kward/hooks/config_loader.rb +104 -0
  49. data/lib/kward/hooks/decision.rb +102 -0
  50. data/lib/kward/hooks/event.rb +60 -0
  51. data/lib/kward/hooks/http_handler.rb +72 -0
  52. data/lib/kward/hooks/manager.rb +158 -0
  53. data/lib/kward/hooks/matcher.rb +75 -0
  54. data/lib/kward/hooks.rb +16 -0
  55. data/lib/kward/markdown_transcript.rb +4 -3
  56. data/lib/kward/mcp/client.rb +1 -1
  57. data/lib/kward/mcp/server_config.rb +7 -3
  58. data/lib/kward/mcp/stdio_transport.rb +7 -1
  59. data/lib/kward/memory/turn_context.rb +19 -0
  60. data/lib/kward/message_access.rb +2 -2
  61. data/lib/kward/message_text.rb +19 -0
  62. data/lib/kward/model/client.rb +13 -45
  63. data/lib/kward/model/context_usage.rb +10 -4
  64. data/lib/kward/model/copilot_models.rb +44 -0
  65. data/lib/kward/model/model_info.rb +24 -0
  66. data/lib/kward/model/payloads.rb +15 -17
  67. data/lib/kward/model/stream_parser.rb +178 -13
  68. data/lib/kward/pan/server.rb +51 -2
  69. data/lib/kward/path_guard.rb +13 -0
  70. data/lib/kward/plugin_registry.rb +86 -25
  71. data/lib/kward/prompt_interface/editor/controller.rb +60 -1
  72. data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
  73. data/lib/kward/prompt_interface/editor/state.rb +5 -1
  74. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  75. data/lib/kward/prompt_interface.rb +4 -1
  76. data/lib/kward/prompts/commands.rb +3 -3
  77. data/lib/kward/prompts.rb +7 -7
  78. data/lib/kward/rpc/mcp_status.rb +50 -0
  79. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  80. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  81. data/lib/kward/rpc/server.rb +94 -14
  82. data/lib/kward/rpc/session_manager.rb +258 -33
  83. data/lib/kward/rpc/session_metrics.rb +1 -1
  84. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  85. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  86. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  87. data/lib/kward/rpc/turn_context.rb +109 -0
  88. data/lib/kward/session_store.rb +6 -20
  89. data/lib/kward/session_tree_nodes.rb +98 -18
  90. data/lib/kward/session_tree_renderer.rb +6 -61
  91. data/lib/kward/skills/registry.rb +109 -17
  92. data/lib/kward/tools/mcp_tool.rb +4 -0
  93. data/lib/kward/tools/read_skill.rb +9 -4
  94. data/lib/kward/tools/registry.rb +266 -8
  95. data/lib/kward/tools/search/code.rb +4 -22
  96. data/lib/kward/tools/search/web.rb +28 -2
  97. data/lib/kward/update_check.rb +117 -0
  98. data/lib/kward/version.rb +1 -1
  99. data/lib/kward/workers/manager.rb +43 -3
  100. data/lib/kward/workspace.rb +3 -1
  101. data/templates/default/fulldoc/html/css/kward.css +22 -0
  102. data/templates/default/fulldoc/html/js/kward.js +2 -0
  103. data/templates/default/kward_navigation.rb +4 -2
  104. data/templates/default/layout/html/setup.rb +2 -0
  105. metadata +25 -1
@@ -11,6 +11,7 @@ require_relative "../conversation"
11
11
  require_relative "../events"
12
12
  require_relative "../export_path"
13
13
  require_relative "../memory/manager"
14
+ require_relative "../memory/turn_context"
14
15
  require_relative "../message_access"
15
16
  require_relative "../message_text"
16
17
  require_relative "../session_tree_tool_display"
@@ -35,6 +36,7 @@ require_relative "session_tree"
35
36
  require_relative "session_tree_rows"
36
37
  require_relative "tool_event_normalizer"
37
38
  require_relative "transcript_normalizer"
39
+ require_relative "turn_context"
38
40
 
39
41
  # Namespace for the Kward CLI agent runtime.
40
42
  module Kward
@@ -63,7 +65,7 @@ module Kward
63
65
  WORKER_STOP = Object.new.freeze
64
66
 
65
67
  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)
66
- 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)
68
+ 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, :options, :tool_registry, keyword_init: true)
67
69
 
68
70
  # Creates an object for RPC session lifecycle and turn coordination.
69
71
  def initialize(
@@ -142,6 +144,19 @@ module Kward
142
144
  .map { |info| session_info_payload(info, workspace_root: root) }
143
145
  end
144
146
 
147
+ def active_sessions
148
+ { sessions: @mutex.synchronize { @sessions.values.map { |rpc_session| session_payload(rpc_session) } } }
149
+ end
150
+
151
+ def tool_schemas(session_id: nil)
152
+ registry = if session_id
153
+ fetch_session(session_id).tool_registry
154
+ else
155
+ build_tool_registry(Dir.pwd, nil)
156
+ end
157
+ { tools: registry.schemas }
158
+ end
159
+
145
160
  # Renames the persisted session attached to an RPC session id.
146
161
  def rename_session(session_id:, name:)
147
162
  rpc_session = fetch_session(session_id)
@@ -326,12 +341,14 @@ module Kward
326
341
  # queue a follow-up, or steer the running turn when the active provider
327
342
  # supports native steering. The returned turn id is used for status,
328
343
  # cancellation, and event replay.
329
- def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [])
344
+ def start_turn(session_id:, input:, streaming_behavior: nil, attachments: [], options: {}, context: nil)
330
345
  rpc_session = fetch_session(session_id)
346
+ normalized_options = normalize_turn_options(options)
347
+ normalized_context = TurnContext.normalize(context)
331
348
  normalized_attachments = normalize_attachments(attachments)
332
349
  plugin_command, plugin_arguments = plugin_command_turn(input, normalized_attachments)
333
350
  display_input = input.to_s if input.is_a?(String)
334
- content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
351
+ content = plugin_command ? input.to_s : user_turn_content(turn_input_with_context(expand_prompt_input(input), normalized_context), normalized_attachments)
335
352
  streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
336
353
  if streaming_behavior == "steer"
337
354
  return steer_running_turn(rpc_session, content)
@@ -349,7 +366,9 @@ module Kward
349
366
  next_sequence: 1,
350
367
  streaming_behavior: streaming_behavior,
351
368
  plugin_command_name: plugin_command&.name,
352
- plugin_arguments: plugin_arguments
369
+ plugin_arguments: plugin_arguments,
370
+ options: normalized_options,
371
+ tool_registry: scoped_tool_registry(rpc_session, normalized_options)
353
372
  )
354
373
  @mutex.synchronize { @turns[turn.id] = turn }
355
374
  rpc_session.queue << turn.id
@@ -382,19 +401,52 @@ module Kward
382
401
  }
383
402
  end
384
403
 
404
+ def list_turns(session_id: nil, active: false)
405
+ turns = @mutex.synchronize { @turns.values.dup }
406
+ turns.select! { |turn| turn.session_id == session_id.to_s } if session_id
407
+ turns.select! { |turn| ["queued", "running"].include?(turn.status) } if active
408
+ { turns: turns.map { |turn| turn_payload(turn) } }
409
+ end
410
+
385
411
  def answer_question(session_id:, question_request_id:, answers:)
386
412
  rpc_session = fetch_session(session_id)
387
413
  rpc_session.prompt.answer(question_request_id, answers)
388
414
  { ok: true }
389
415
  end
390
416
 
417
+ def answer_tool_approval(session_id:, approval_request_id:, approved:)
418
+ rpc_session = fetch_session(session_id)
419
+ rpc_session.prompt.answer_tool_approval(approval_request_id, approved: approved)
420
+ { ok: true }
421
+ end
422
+
391
423
  def run_command(session_id:, command:, arguments: "")
392
424
  name = command.to_s.delete_prefix("/")
393
425
  return { ok: false, error: "unsupported", reason: "clientClipboardOwnedByUi" } if name == "copy"
426
+ return run_skill_command(session_id: session_id, name: name.delete_prefix("skill:")) if name.start_with?("skill:")
427
+ return run_skill_command(session_id: session_id, name: arguments) if name == "skill"
394
428
 
395
429
  run_plugin_command(session_id: session_id, command: name, arguments: arguments)
396
430
  end
397
431
 
432
+ def run_skill_command(session_id:, name:)
433
+ skill_name = name.to_s.strip
434
+ raise ArgumentError, "Missing skill name" if skill_name.empty?
435
+
436
+ rpc_session = fetch_session(session_id)
437
+ tool_call = {
438
+ "id" => "skill_#{skill_name.gsub(/[^a-zA-Z0-9_-]/, "_")}",
439
+ "type" => "function",
440
+ "function" => {
441
+ "name" => "read_skill",
442
+ "arguments" => JSON.dump({ name: skill_name })
443
+ }
444
+ }
445
+ rpc_session.conversation.append_assistant("role" => "assistant", "content" => nil, "tool_calls" => [tool_call])
446
+ result = rpc_session.tool_registry.dispatch(tool_call, rpc_session.conversation)
447
+ { command: "skill:#{skill_name}", output: [result.start_with?("Error:") ? result : "Activated skill: #{skill_name}"], result: result }
448
+ end
449
+
398
450
  def run_plugin_command(session_id:, command:, arguments: "")
399
451
  rpc_session = fetch_session(session_id)
400
452
  command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
@@ -420,8 +472,9 @@ module Kward
420
472
  def current_model
421
473
  provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
422
474
  model = @client.respond_to?(:current_model) ? @client.current_model : nil
475
+ reasoning_effort = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
423
476
  context_window = @client.respond_to?(:current_context_window) ? @client.current_context_window : nil
424
- normalize_model(provider: provider, id: model, model: model, contextWindow: context_window, current: true)
477
+ ModelInfo.current_payload(provider: provider, model: model, reasoning_effort: reasoning_effort, context_window: context_window)
425
478
  end
426
479
 
427
480
  def session_model(rpc_session)
@@ -431,13 +484,11 @@ module Kward
431
484
  reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
432
485
  reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
433
486
  context_window = context_window_for(provider, model)
434
- normalize_model(
487
+ ModelInfo.current_payload(
435
488
  provider: provider,
436
- id: model,
437
489
  model: model,
438
- reasoningEffort: reasoning_effort,
439
- contextWindow: context_window,
440
- current: true
490
+ reasoning_effort: reasoning_effort,
491
+ context_window: context_window
441
492
  )
442
493
  end
443
494
 
@@ -503,6 +554,7 @@ module Kward
503
554
  sessions.each do |rpc_session|
504
555
  rpc_session.conversation.plugin_registry = registry if rpc_session.conversation.respond_to?(:plugin_registry=)
505
556
  rpc_session.conversation.refresh_system_message! if rpc_session.conversation.respond_to?(:refresh_system_message!)
557
+ rebuild_session_tools(rpc_session)
506
558
  if registry.footer_renderer
507
559
  start_footer_worker(rpc_session)
508
560
  emit_footer_update(rpc_session)
@@ -563,12 +615,25 @@ module Kward
563
615
  end
564
616
 
565
617
  def rebuild_session_tools(rpc_session)
566
- tool_registry = build_tool_registry(rpc_session.workspace_root, rpc_session.prompt)
618
+ hook_context = lifecycle_hook_context(
619
+ conversation: rpc_session.conversation,
620
+ session: rpc_session.session,
621
+ workspace_root: rpc_session.workspace_root
622
+ )
623
+ hook_manager = lifecycle_hook_manager(rpc_session.workspace_root)
624
+ tool_registry = build_tool_registry(
625
+ rpc_session.workspace_root,
626
+ rpc_session.prompt,
627
+ hook_manager: hook_manager,
628
+ hook_context: hook_context
629
+ )
567
630
  rpc_session.tool_registry = tool_registry
568
631
  rpc_session.agent = Agent.new(
569
632
  client: @client,
570
633
  tool_registry: tool_registry,
571
- conversation: rpc_session.conversation
634
+ conversation: rpc_session.conversation,
635
+ hook_manager: hook_manager,
636
+ hook_context: hook_context
572
637
  )
573
638
  end
574
639
 
@@ -602,12 +667,12 @@ module Kward
602
667
  id = model[:id] || model["id"] || model[:model] || model["model"]
603
668
  model = model.merge(contextWindow: context_window_for(provider, id))
604
669
  end
605
- ModelInfo.normalize(
606
- model,
670
+ ModelInfo.normalize_available(
671
+ [model],
607
672
  current_provider: (@client.current_provider if @client.respond_to?(:current_provider)),
608
673
  current_model: (@client.current_model if @client.respond_to?(:current_model)),
609
674
  current_reasoning_effort: (@client.current_reasoning_effort if @client.respond_to?(:current_reasoning_effort))
610
- )
675
+ ).first
611
676
  end
612
677
 
613
678
  def context_window_for(provider, model)
@@ -702,12 +767,7 @@ module Kward
702
767
  end
703
768
 
704
769
  def display_message_text(message)
705
- truncate_tree_text(full_message_text(message))
706
- end
707
-
708
- def truncate_tree_text(text)
709
- normalized = text.to_s.gsub(/\s+/, " ").strip
710
- normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
770
+ MessageText.preview(message)
711
771
  end
712
772
 
713
773
  def full_message_text(message)
@@ -761,6 +821,93 @@ module Kward
761
821
  AttachmentNormalizer.new(max_bytes: RPC_ATTACHMENT_MAX_BYTES, mime_types: RPC_IMAGE_MIME_TYPES).normalize(attachments)
762
822
  end
763
823
 
824
+ def turn_input_with_context(input, context)
825
+ return input unless context
826
+
827
+ [input.to_s, TurnContext.prompt(context)].reject(&:empty?).join("\n\n")
828
+ end
829
+
830
+ def normalize_turn_options(options)
831
+ options = {} if options.nil?
832
+ raise ArgumentError, "turn options must be an object" unless options.is_a?(Hash)
833
+
834
+ provider = option_value(options, "provider")
835
+ model = option_value(options, "model")
836
+ model = option_value(model, "id") || option_value(model, "model") if model.is_a?(Hash)
837
+ reasoning = option_value(options, "reasoningEffort") || option_value(options, "reasoning")
838
+ approval_mode = blank_to_nil(option_value(options, "approvalMode"))
839
+ raise ArgumentError, "Unsupported approvalMode: #{approval_mode}" if approval_mode && !%w[none ask].include?(approval_mode)
840
+ allowed_tools = option_array(options, "allowedTools")
841
+ disabled_tools = option_array(options, "disabledTools")
842
+ raise ArgumentError, "allowedTools and disabledTools cannot both be set" if allowed_tools && disabled_tools
843
+
844
+ {
845
+ provider: blank_to_nil(provider),
846
+ model: blank_to_nil(model),
847
+ reasoning: blank_to_nil(reasoning),
848
+ approval_mode: approval_mode,
849
+ allowed_tools: allowed_tools,
850
+ disabled_tools: disabled_tools
851
+ }.compact
852
+ end
853
+
854
+ def option_value(hash, key)
855
+ hash[key] || hash[key.to_sym]
856
+ end
857
+
858
+ def option_array(hash, key)
859
+ value = option_value(hash, key)
860
+ return nil if value.nil?
861
+ raise ArgumentError, "#{key} must be an array" unless value.is_a?(Array)
862
+
863
+ value.map(&:to_s).reject(&:empty?)
864
+ end
865
+
866
+ def blank_to_nil(value)
867
+ value = value.to_s if value.is_a?(Symbol)
868
+ value.to_s.empty? ? nil : value
869
+ end
870
+
871
+ def scoped_tool_registry(rpc_session, options)
872
+ names = scoped_tool_names(rpc_session, options)
873
+ approval = tool_approval_callback(rpc_session, options)
874
+ return nil unless names || approval
875
+
876
+ build_tool_registry(
877
+ rpc_session.workspace_root,
878
+ rpc_session.prompt,
879
+ allowed_tool_names: names,
880
+ tool_approval: approval,
881
+ hook_manager: lifecycle_hook_manager(rpc_session.workspace_root),
882
+ hook_context: plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
883
+ )
884
+ end
885
+
886
+ def tool_approval_callback(rpc_session, options)
887
+ return nil unless options[:approval_mode] == "ask"
888
+
889
+ lambda do |tool_call:, name:, args:, cancellation:|
890
+ fields = ToolMetadata.normalized_tool_fields(tool_call)
891
+ rpc_session.prompt.request_tool_approval(
892
+ tool_call_id: fields[:toolCallId] || ToolCall.id(tool_call),
893
+ tool_name: fields[:toolName] || name,
894
+ args: fields[:args] || ToolCall.camelize_args(args),
895
+ cancellation: cancellation
896
+ )
897
+ end
898
+ end
899
+
900
+ def scoped_tool_names(rpc_session, options)
901
+ return options[:allowed_tools] if options[:allowed_tools]
902
+ return nil unless options[:disabled_tools]
903
+
904
+ disabled = options[:disabled_tools].to_h { |name| [name, true] }
905
+ rpc_session.tool_registry.schemas.filter_map do |schema|
906
+ name = schema.dig(:function, :name) || schema.dig("function", "name")
907
+ name unless disabled[name]
908
+ end
909
+ end
910
+
764
911
  def plugin_registry
765
912
  @plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_plugin_command_names)
766
913
  end
@@ -778,11 +925,15 @@ module Kward
778
925
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
779
926
  id = SecureRandom.uuid
780
927
  prompt = PromptBridge.new(server: @server, session_id: id)
781
- tool_registry = build_tool_registry(workspace_root, prompt)
928
+ hook_context = lifecycle_hook_context(conversation: conversation, session: session, workspace_root: workspace_root)
929
+ hook_manager = lifecycle_hook_manager(workspace_root)
930
+ tool_registry = build_tool_registry(workspace_root, prompt, hook_manager: hook_manager, hook_context: hook_context)
782
931
  agent = Agent.new(
783
932
  client: @client,
784
933
  tool_registry: tool_registry,
785
- conversation: conversation
934
+ conversation: conversation,
935
+ hook_manager: hook_manager,
936
+ hook_context: hook_context
786
937
  )
787
938
  RpcSession.new(
788
939
  id: id,
@@ -800,8 +951,59 @@ module Kward
800
951
  )
801
952
  end
802
953
 
803
- def build_tool_registry(workspace_root, prompt)
804
- ToolRegistry.new(workspace: configured_workspace(workspace_root), prompt: prompt)
954
+ def build_tool_registry(workspace_root, prompt, allowed_tool_names: nil, tool_approval: nil, hook_manager: nil, hook_context: nil)
955
+ ToolRegistry.new(
956
+ workspace: configured_workspace(workspace_root),
957
+ prompt: prompt,
958
+ allowed_tool_names: allowed_tool_names,
959
+ tool_approval: tool_approval,
960
+ hook_manager: hook_manager,
961
+ hook_context: hook_context
962
+ )
963
+ end
964
+
965
+ def lifecycle_hook_manager(workspace_root)
966
+ manager = Hooks::ConfigLoader.new(ConfigFiles.lifecycle_hooks_config(workspace_root)).manager
967
+ manager.on_result = method(:emit_lifecycle_hook_event)
968
+ plugin_registry.hook_handlers.each do |hook|
969
+ manager.register(hook.event, id: hook.id, source: hook.path, order: hook.order, match: hook.match, failure_policy: hook.failure_policy) do |event, context|
970
+ hook.handler.call(event, context)
971
+ end
972
+ end
973
+ manager
974
+ end
975
+
976
+ def emit_lifecycle_hook_event(event, result)
977
+ @server.notify("hook/event", {
978
+ event: {
979
+ id: event.id,
980
+ name: event.name,
981
+ phase: event.phase,
982
+ timestamp: event.timestamp.iso8601,
983
+ session: event.session,
984
+ turn: event.turn,
985
+ workspace: event.workspace,
986
+ frontend: event.frontend,
987
+ agent: event.agent,
988
+ payloadKeys: event.payload.keys.map(&:to_s).sort
989
+ },
990
+ result: {
991
+ decision: result.decision.decision,
992
+ warnings: result.warnings,
993
+ messages: result.messages,
994
+ decisionCount: result.decisions.length
995
+ }
996
+ })
997
+ end
998
+
999
+ def lifecycle_hook_context(conversation:, session:, workspace_root:)
1000
+ PluginRegistry::Context.new(
1001
+ conversation: conversation,
1002
+ args: "",
1003
+ session: session,
1004
+ workspace_root: workspace_root,
1005
+ say_callback: lambda { |message| @server.notify("hook/message", { message: message.to_s }) }
1006
+ )
805
1007
  end
806
1008
 
807
1009
  def configured_workspace(root)
@@ -924,6 +1126,8 @@ module Kward
924
1126
  # turns. Keep event translation near this boundary so CLI rendering and RPC
925
1127
  # protocol details do not leak into `Agent`.
926
1128
  def run_turn(rpc_session, turn)
1129
+ previous_turn_id = Thread.current[:kward_rpc_turn_id]
1130
+ Thread.current[:kward_rpc_turn_id] = turn.id
927
1131
  rpc_session.running_turn_id = turn.id
928
1132
  turn.steering = build_steering(turn) if supports_in_flight_steer? && !turn.plugin_command_name
929
1133
  turn.status = "running"
@@ -940,13 +1144,14 @@ module Kward
940
1144
  else
941
1145
  auto_name_session(rpc_session, turn.display_input || turn.input)
942
1146
  prepare_memory_context(rpc_session.conversation, turn.input)
943
- rpc_session.agent.ask(turn.input, display_input: turn_display_input(turn), cancellation: turn.cancellation, steering: turn.steering) do |event|
1147
+ rpc_session.agent.ask(turn.input, display_input: turn_display_input(turn), cancellation: turn.cancellation, steering: turn.steering, options: turn.options || {}, tool_registry: turn.tool_registry) do |event|
944
1148
  next if turn.cancel_requested
945
1149
 
946
1150
  notify_plugin_transcript_event(rpc_session, event)
947
1151
  handle_agent_event(turn, event)
948
1152
  end
949
1153
  persist_memory_state(rpc_session)
1154
+ auto_summarize_memory(rpc_session) unless turn.cancel_requested
950
1155
  finish_turn(turn, turn.cancel_requested ? "canceled" : "completed")
951
1156
  end
952
1157
  rescue Cancellation::CancelledError
@@ -956,6 +1161,7 @@ module Kward
956
1161
  emit_turn_event(turn, "error", turn.error)
957
1162
  finish_turn(turn, "failed")
958
1163
  ensure
1164
+ Thread.current[:kward_rpc_turn_id] = previous_turn_id
959
1165
  turn.steering = nil
960
1166
  rpc_session.running_turn_id = nil
961
1167
  end
@@ -983,11 +1189,7 @@ module Kward
983
1189
  end
984
1190
 
985
1191
  def prepare_memory_context(conversation, input)
986
- manager = memory_manager
987
- retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
988
- conversation.last_memory_retrieval = retrieval
989
- conversation.memory_context = manager.memory_block(retrieval)
990
- conversation.refresh_system_message!
1192
+ Memory::TurnContext.apply(conversation: conversation, input: input, manager: memory_manager)
991
1193
  rescue StandardError => e
992
1194
  @server.log_error(e)
993
1195
  nil
@@ -999,6 +1201,17 @@ module Kward
999
1201
  nil
1000
1202
  end
1001
1203
 
1204
+ def auto_summarize_memory(rpc_session)
1205
+ manager = memory_manager
1206
+ return unless manager.enabled? && manager.auto_summary_enabled?
1207
+
1208
+ manager.summarize_conversation(rpc_session.conversation, client: @client)
1209
+ persist_memory_state(rpc_session)
1210
+ rescue StandardError => e
1211
+ @server.log_error(e)
1212
+ nil
1213
+ end
1214
+
1002
1215
  def steer_running_turn(rpc_session, input)
1003
1216
  turn_id = rpc_session.running_turn_id
1004
1217
  turn = turn_id && fetch_turn(turn_id)
@@ -1055,6 +1268,8 @@ module Kward
1055
1268
  emit_turn_event(turn, "steeringApplied", { count: event.count })
1056
1269
  when Events::ToolCall
1057
1270
  emit_turn_event(turn, "toolCall", normalized_tool_event_payload(event.tool_call))
1271
+ when Events::ToolUpdate
1272
+ emit_turn_event(turn, "toolUpdate", normalized_tool_update_event_payload(event.tool_call, event.content, elapsed_ms: event.elapsed_ms))
1058
1273
  when Events::ToolResult
1059
1274
  emit_turn_event(turn, "toolResult", normalized_tool_result_event_payload(event.tool_call, event.content))
1060
1275
  when Events::Answer
@@ -1184,11 +1399,21 @@ module Kward
1184
1399
  end
1185
1400
 
1186
1401
  def normalized_tool_event_payload(tool_call)
1187
- ToolEventNormalizer.new(tool_call).call_payload
1402
+ ToolEventNormalizer.new(tool_call, tool_registry: current_turn_tool_registry).call_payload
1403
+ end
1404
+
1405
+ def normalized_tool_update_event_payload(tool_call, content, elapsed_ms: nil)
1406
+ ToolEventNormalizer.new(tool_call, content: content, tool_registry: current_turn_tool_registry).update_payload(elapsed_ms: elapsed_ms)
1188
1407
  end
1189
1408
 
1190
1409
  def normalized_tool_result_event_payload(tool_call, content)
1191
- ToolEventNormalizer.new(tool_call, content: content).result_payload
1410
+ ToolEventNormalizer.new(tool_call, content: content, tool_registry: current_turn_tool_registry).result_payload
1411
+ end
1412
+
1413
+ def current_turn_tool_registry
1414
+ return unless Thread.current[:kward_rpc_turn_id]
1415
+
1416
+ @mutex.synchronize { @turns[Thread.current[:kward_rpc_turn_id]]&.tool_registry }
1192
1417
  end
1193
1418
 
1194
1419
  def turn_error_payload(error)
@@ -34,7 +34,7 @@ module Kward
34
34
 
35
35
  def context_usage(rpc_session, model, client:)
36
36
  context_parts = if client.respond_to?(:current_context_parts)
37
- client.current_context_parts(rpc_session.conversation.context_messages, rpc_session.tool_registry.schemas)
37
+ client.current_context_parts(rpc_session.conversation.context_messages, rpc_session.tool_registry.schemas, provider: model[:provider], model: model[:id])
38
38
  else
39
39
  {
40
40
  provider: model[:provider],
@@ -13,7 +13,7 @@ module Kward
13
13
  # this class owns only the mechanics of flattening tree nodes into the row
14
14
  # fields sent over JSON-RPC. Keeping row presentation here prevents the RPC
15
15
  # session coordinator from accumulating rendering details while preserving the
16
- # exact Tauren-compatible payload shape.
16
+ # exact RPC payload shape.
17
17
  class SessionTreeRows
18
18
  # @param roots [Array<Hash>] tree roots returned by `SessionStore#session_tree`
19
19
  # @param current_leaf [String, nil] active persisted tree leaf id
@@ -29,54 +29,28 @@ module Kward
29
29
  # @return [Array<Hash>] rows for the `session/tree` RPC method
30
30
  def rows
31
31
  tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf)
32
- active_path = tree_nodes.active_path
33
32
  tool_calls_by_id = tree_nodes.tool_calls
34
- visible_roots = tree_nodes.visible_roots
35
- multiple_roots = visible_roots.length > 1
36
- result = []
37
33
 
38
- stack = visible_roots.sort_by { |root| tree_nodes.contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
39
- [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
40
- end.reverse
41
-
42
- until stack.empty?
43
- node, indent, just_branched, show_connector, is_last, gutters, virtual_root_child = stack.pop
44
- entry = node[:source]["entry"] || {}
34
+ tree_nodes.layout_rows.map do |row|
35
+ entry = row[:entry]
45
36
  entry_id = entry["id"].to_s
46
37
  formatted = tree_entry_display(entry, tool_calls_by_id)
47
- display_indent = multiple_roots ? [indent - 1, 0].max : indent
48
- result << {
38
+ {
49
39
  entryId: entry_id,
50
40
  parentId: entry["parentId"],
51
41
  role: formatted[:role],
52
42
  text: formatted[:text],
53
43
  current: !@current_leaf.to_s.empty? && entry_id == @current_leaf.to_s,
54
- depth: display_indent,
55
- isLast: is_last,
56
- ancestorContinues: gutters.map { |gutter| gutter[:show] },
57
- activePath: active_path.include?(entry_id),
44
+ depth: row[:depth],
45
+ isLast: row[:is_last],
46
+ ancestorContinues: row[:gutters].map { |gutter| gutter[:show] },
47
+ activePath: row[:active_path],
58
48
  selectable: @selectable.call(entry),
59
- label: node[:source]["label"] || entry["resolvedLabel"],
60
- labelTimestamp: node[:source]["labelTimestamp"],
61
- prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
49
+ label: row[:source]["label"] || entry["resolvedLabel"],
50
+ labelTimestamp: row[:source]["labelTimestamp"],
51
+ prefix: row[:prefix]
62
52
  }.compact
63
-
64
- children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
65
- multiple_children = children.length > 1
66
- child_indent = if multiple_children
67
- indent + 1
68
- elsif just_branched && indent.positive?
69
- indent + 1
70
- else
71
- indent
72
- end
73
- connector_position = [display_indent - 1, 0].max
74
- child_gutters = show_connector && !virtual_root_child ? gutters + [{ position: connector_position, show: !is_last }] : gutters
75
- children.each_with_index.reverse_each do |child, index|
76
- stack << [child, child_indent, multiple_children, multiple_children, index == children.length - 1, child_gutters, false]
77
- end
78
53
  end
79
- result
80
54
  end
81
55
 
82
56
  private
@@ -97,31 +71,6 @@ module Kward
97
71
  { role: entry["type"].to_s.empty? ? "entry" : entry["type"].to_s, text: entry["type"].to_s }
98
72
  end
99
73
 
100
- def tree_prefix(display_indent, gutters, show_connector, is_last, foldable)
101
- return "" if display_indent.to_i <= 0
102
-
103
- connector_position = show_connector ? display_indent - 1 : -1
104
- (0...(display_indent * 3)).map do |index|
105
- level = index / 3
106
- position = index % 3
107
- gutter = gutters.find { |candidate| candidate[:position] == level }
108
-
109
- if gutter
110
- position.zero? && gutter[:show] ? "│" : " "
111
- elsif show_connector && level == connector_position
112
- if position.zero?
113
- is_last ? "└" : "├"
114
- elsif position == 1
115
- foldable ? "⊟" : "─"
116
- else
117
- " "
118
- end
119
- else
120
- " "
121
- end
122
- end.join
123
- end
124
-
125
74
  def format_tool_result(message, tool_calls_by_id)
126
75
  tool_call = tool_calls_by_id[MessageAccess.tool_call_id(message).to_s]
127
76
  return SessionTreeToolDisplay.label(tool_call) if tool_call
@@ -132,11 +81,11 @@ module Kward
132
81
  end
133
82
 
134
83
  def display_message_text(message)
135
- SessionTreeNodes.truncate_text(MessageText.full_text(message))
84
+ MessageText.preview(message)
136
85
  end
137
86
 
138
87
  def truncate_tree_text(text)
139
- SessionTreeNodes.truncate_text(text)
88
+ MessageText.truncate_preview(text)
140
89
  end
141
90
  end
142
91
  end
@@ -7,9 +7,10 @@ module Kward
7
7
  module RPC
8
8
  # Converts tool calls and results into RPC event payloads.
9
9
  class ToolEventNormalizer
10
- def initialize(tool_call, content: nil)
10
+ def initialize(tool_call, content: nil, tool_registry: nil)
11
11
  @tool_call = tool_call
12
12
  @content = content
13
+ @tool_registry = tool_registry
13
14
  @fields = ToolMetadata.normalized_tool_fields(@tool_call)
14
15
  end
15
16
 
@@ -17,10 +18,17 @@ module Kward
17
18
  {
18
19
  toolCallId: @fields[:toolCallId],
19
20
  toolName: @fields[:toolName],
20
- args: @fields[:args]
21
+ args: @fields[:args],
22
+ metadata: metadata
21
23
  }.compact
22
24
  end
23
25
 
26
+ def update_payload(elapsed_ms: nil)
27
+ payload = call_payload.merge(delta: { content: @content.to_s })
28
+ payload[:elapsedMs] = elapsed_ms if elapsed_ms
29
+ payload
30
+ end
31
+
24
32
  def result_payload
25
33
  call_payload.merge(
26
34
  content: @content,
@@ -43,6 +51,12 @@ module Kward
43
51
 
44
52
  private
45
53
 
54
+ def metadata
55
+ return unless @tool_registry&.respond_to?(:metadata_for)
56
+
57
+ @tool_registry.metadata_for(@fields[:toolName])
58
+ end
59
+
46
60
  def normalized_result
47
61
  text = @content.to_s
48
62
  is_error = ToolMetadata.error_result?(text)