kward 0.70.0 → 0.72.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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
data/lib/kward/prompts.rb CHANGED
@@ -32,9 +32,11 @@ module Kward
32
32
 
33
33
  def base_prompt
34
34
  <<~PROMPT.strip
35
- You are Kward, a concise practical CLI coding agent. You are allowed to use the tools. Help users understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
35
+ You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
36
36
 
37
- For web research, use web_search to discover sources, then fetch_content for important human-readable pages before relying on them. Use fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources when practical, and cite or mention the URLs you relied on.
37
+ For web research, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on.
38
+
39
+ Manage code context deliberately. Prefer context_for_task, summarize_file_structure, and read_file mode="outline"/"preview" before broad reads. Escalate to read_file mode="range" for exact lines, and use mode="full" only when focused context is insufficient. Use context_budget_stats when asked about context savings.
38
40
  PROMPT
39
41
  end
40
42
 
@@ -5,6 +5,7 @@ require_relative "../memory/manager"
5
5
  require_relative "../plugin_registry"
6
6
  require_relative "../prompts/commands"
7
7
  require_relative "../tools/registry"
8
+ require_relative "../workers"
8
9
  require_relative "../workspace"
9
10
  require_relative "../telemetry/logger"
10
11
  require_relative "../telemetry/stats"
@@ -49,7 +50,7 @@ module Kward
49
50
  "sessions/tree", "sessions/tree/setLabel", "sessions/tree/navigate",
50
51
  "sessions/export", "sessions/delete", "sessions/close", "sessions/transcript"
51
52
  ].freeze
52
- MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set", "openrouter/catalog"].freeze
53
+ MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
53
54
  AUTH_METHODS = [
54
55
  "auth/status", "auth/providers", "auth/loginWithApiKey", "auth/logoutProvider",
55
56
  "auth/loginWithOAuth", "auth/startOpenAILogin", "auth/submitOpenAICode", "auth/loginStatus"
@@ -60,14 +61,18 @@ module Kward
60
61
  "memory/forget", "memory/promote", "memory/relax", "memory/inspect",
61
62
  "memory/why", "memory/summarize"
62
63
  ].freeze
64
+ WORKER_METHODS = ["workers/list", "workers/show"].freeze
63
65
 
64
66
  # Creates the RPC server and its stateful managers.
65
- def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new)
67
+ def initialize(input: $stdin, output: $stdout, error_output: $stderr, client: Client.new, experimental_workers: false)
66
68
  @transport = Transport.new(input: input, output: output)
67
69
  @error_output = error_output
70
+ @client = client
68
71
  @config_manager = ConfigManager.new
69
72
  @session_manager = SessionManager.new(server: self, client: client, config_manager: @config_manager)
70
73
  @auth_manager = AuthManager.new(server: self, config_manager: @config_manager)
74
+ @worker_store = Workers::Store.new
75
+ @experimental_workers = experimental_workers
71
76
  @shutdown = false
72
77
  end
73
78
 
@@ -88,7 +93,7 @@ module Kward
88
93
  end
89
94
  end
90
95
  ensure
91
- @session_manager.cleanup_unused_sessions
96
+ @session_manager.shutdown_sessions
92
97
  end
93
98
 
94
99
  # Sends a redacted JSON-RPC notification to the client.
@@ -166,8 +171,6 @@ module Kward
166
171
  prompts_expand(params)
167
172
  when "models/list"
168
173
  models_list
169
- when "openrouter/catalog"
170
- openrouter_catalog
171
174
  when "models/current"
172
175
  models_current
173
176
  when "models/set"
@@ -224,6 +227,12 @@ module Kward
224
227
  @session_manager.memory_why(session_id: params["sessionId"])
225
228
  when "memory/summarize"
226
229
  @session_manager.memory_summarize(session_id: params.fetch("sessionId"))
230
+ when "workers/list"
231
+ require_experimental_workers!
232
+ workers_list(params)
233
+ when "workers/show"
234
+ require_experimental_workers!
235
+ workers_show(params)
227
236
  when "auth/status"
228
237
  @auth_manager.status
229
238
  when "auth/providers"
@@ -345,7 +354,7 @@ module Kward
345
354
  reasoning: { start: false, delta: true, end: false },
346
355
  modelRetry: { supported: true, event: "modelRetry" },
347
356
  steering: { supported: @session_manager.in_flight_steer_supported?, event: "turnSteered", mode: @session_manager.in_flight_steer_supported? ? "native" : "unsupported" },
348
- tools: { call: true, update: false, result: true, normalizedMetadata: true, diffs: true, changedFiles: false, workspaceGuardrails: workspace_guardrails_enabled? },
357
+ tools: { call: true, update: false, result: true, normalizedMetadata: true, diffs: true, changedFiles: false, workspaceGuardrails: workspace_guardrails_enabled?, focusedContext: true, contextBudgetStats: true },
349
358
  errors: true,
350
359
  sessionUpdates: false
351
360
  },
@@ -362,7 +371,8 @@ module Kward
362
371
  supported: true,
363
372
  methods: MODEL_METHODS,
364
373
  fields: ["provider", "id", "name", "reasoning", "reasoningEffort", "contextWindow"],
365
- scopedModels: false
374
+ scopedModels: false,
375
+ openRouterRefresh: { supported: false, reason: "cliOnlyCacheRefresh" }
366
376
  },
367
377
  runtime: {
368
378
  supported: true,
@@ -385,9 +395,11 @@ module Kward
385
395
  logout: true
386
396
  },
387
397
  memory: { supported: true, optIn: true, defaultEnabled: false, autoSummaryDefaultEnabled: false, promptInjection: "interactive", storage: { core: "json", soft: "jsonl", events: "jsonl" }, methods: MEMORY_METHODS },
398
+ workers: workers_capability,
388
399
  commands: { supported: true, methods: ["commands/list", "commands/run"], method: "commands/list", runMethod: "commands/run", sources: ["builtin", "prompt", "skill", "plugin"], executableSources: ["builtin", "plugin"] },
389
400
  startupResources: { supported: true, method: "resources/startup" },
390
401
  starterPack: { supported: false, reason: "cliOnlyInstallCommand" },
402
+ shell: { supported: false, reason: "interactiveTuiOnly" },
391
403
  extensionUi: {
392
404
  question: { supported: true, notification: "ui/question", method: "ui/answerQuestion", maxQuestions: 4, multiSelect: false, preview: false },
393
405
  select: false,
@@ -427,6 +439,18 @@ module Kward
427
439
  }
428
440
  end
429
441
 
442
+ def workers_capability
443
+ return { supported: false, reason: "experimentalWorkersFlagRequired", flag: "--experimental-workers" } unless @experimental_workers
444
+
445
+ { supported: true, methods: WORKER_METHODS, roles: ["implementation", "request"], statuses: Workers::Worker::STATUSES, transcriptStorage: "sessions", metadataStorage: "json" }
446
+ end
447
+
448
+ def require_experimental_workers!
449
+ return if @experimental_workers
450
+
451
+ raise NoMethodError, "workers require --experimental-workers"
452
+ end
453
+
430
454
  def workspace_info(root)
431
455
  root = @session_manager.validate_workspace_root(root)
432
456
  { root: root, basename: File.basename(root), writable: File.writable?(root) }
@@ -458,10 +482,6 @@ module Kward
458
482
  { models: @session_manager.available_models }
459
483
  end
460
484
 
461
- def openrouter_catalog
462
- { models: @session_manager.openrouter_catalog }
463
- end
464
-
465
485
  def config_update(params)
466
486
  config = @config_manager.update(params.fetch("values"))
467
487
  @session_manager.refresh_client_config
@@ -576,6 +596,20 @@ module Kward
576
596
  { sections: sections }
577
597
  end
578
598
 
599
+ def workers_list(params)
600
+ include_archived = params["includeArchived"] == true
601
+ workers = @worker_store.list(include_archived: include_archived)
602
+ { workers: workers }
603
+ end
604
+
605
+ def workers_show(params)
606
+ id = params.fetch("id").to_s.delete_prefix("#")
607
+ worker = @worker_store.find(id)
608
+ return { worker: worker } if worker
609
+
610
+ raise ArgumentError, "Unknown worker: #{id}"
611
+ end
612
+
579
613
  def auth_login_with_api_key(params)
580
614
  result = @auth_manager.login_with_api_key(provider_id: params.fetch("providerId"), api_key: params.fetch("apiKey"))
581
615
  @session_manager.refresh_client_config
@@ -295,12 +295,21 @@ module Kward
295
295
  rpc_sessions = @mutex.synchronize { @sessions.values.dup }
296
296
  rpc_sessions.reverse_each do |rpc_session|
297
297
  next unless session_idle?(rpc_session)
298
+ next unless rpc_session.session.respond_to?(:delete_if_unused)
299
+ next unless rpc_session.session.delete_if_unused
298
300
 
299
- close_rpc_session(rpc_session)
301
+ remove_live_session(rpc_session)
300
302
  end
301
303
  { closed: true }
302
304
  end
303
305
 
306
+ # Stops all live RPC session workers during server shutdown.
307
+ def shutdown_sessions
308
+ rpc_sessions = @mutex.synchronize { @sessions.values.dup }
309
+ rpc_sessions.reverse_each { |rpc_session| close_rpc_session(rpc_session) if session_idle?(rpc_session) }
310
+ { closed: true }
311
+ end
312
+
304
313
  # Returns the normalized transcript for the active RPC session.
305
314
  def transcript(session_id:)
306
315
  rpc_session = fetch_session(session_id)
@@ -321,10 +330,7 @@ module Kward
321
330
  content = plugin_command ? input.to_s : user_turn_content(expand_prompt_input(input), normalized_attachments)
322
331
  streaming_behavior = validate_streaming_behavior(default_streaming_behavior(rpc_session, streaming_behavior), rpc_session: rpc_session)
323
332
  if streaming_behavior == "steer"
324
- steered_turn = steer_running_turn(rpc_session, content)
325
- return steered_turn if steered_turn
326
-
327
- streaming_behavior = "followUp"
333
+ return steer_running_turn(rpc_session, content)
328
334
  end
329
335
  turn = Turn.new(
330
336
  id: SecureRandom.uuid,
@@ -461,13 +467,7 @@ module Kward
461
467
  rpc_session = fetch_session(session_id)
462
468
  command = plugin_registry.command_for(command.to_s.delete_prefix("/")) || raise(ArgumentError, "Unknown plugin command: #{command}")
463
469
  output = []
464
- context = PluginRegistry::Context.new(
465
- conversation: rpc_session.conversation,
466
- args: arguments.to_s,
467
- session: rpc_session.session,
468
- workspace_root: rpc_session.workspace_root,
469
- say_callback: lambda { |message| output << message.to_s }
470
- )
470
+ context = plugin_context(rpc_session, args: arguments.to_s, say_callback: lambda { |message| output << message.to_s })
471
471
  result = command.handler.call(arguments.to_s, context)
472
472
  output = rpc_session.plugin_output.shift(rpc_session.plugin_output.length) + output
473
473
  { command: command.name, output: output, result: result.nil? ? nil : result.to_s }
@@ -485,11 +485,6 @@ module Kward
485
485
  normalized
486
486
  end
487
487
 
488
- def openrouter_catalog
489
- models = @client.respond_to?(:openrouter_catalog) ? Array(@client.openrouter_catalog) : []
490
- models.map { |model| normalize_model(model) }
491
- end
492
-
493
488
  def current_model
494
489
  provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
495
490
  model = @client.respond_to?(:current_model) ? @client.current_model : nil
@@ -503,7 +498,7 @@ module Kward
503
498
  model = rpc_session.conversation.model || current[:id]
504
499
  reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
505
500
  reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
506
- context_window = current[:contextWindow] if provider == current[:provider] && model == current[:id]
501
+ context_window = context_window_for(provider, model)
507
502
  normalize_model(
508
503
  provider: provider,
509
504
  id: model,
@@ -666,6 +661,11 @@ module Kward
666
661
  end
667
662
 
668
663
  def normalize_model(model)
664
+ unless model.key?(:contextWindow) || model.key?("contextWindow")
665
+ provider = model[:provider] || model["provider"]
666
+ id = model[:id] || model["id"] || model[:model] || model["model"]
667
+ model = model.merge(contextWindow: context_window_for(provider, id))
668
+ end
669
669
  ModelInfo.normalize(
670
670
  model,
671
671
  current_provider: (@client.current_provider if @client.respond_to?(:current_provider)),
@@ -674,6 +674,17 @@ module Kward
674
674
  )
675
675
  end
676
676
 
677
+ def context_window_for(provider, model)
678
+ provider = ModelInfo.provider_label(provider)
679
+ return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
680
+
681
+ if @client.respond_to?(:current_context_window) && @client.respond_to?(:current_provider) && @client.respond_to?(:current_model)
682
+ return @client.current_context_window if provider == @client.current_provider && model == @client.current_model
683
+ end
684
+
685
+ ModelInfo.context_window(provider, model)
686
+ end
687
+
677
688
  def active_persona_label(rpc_session)
678
689
  ConfigFiles.active_persona_label(
679
690
  workspace_root: rpc_session.workspace_root,
@@ -698,10 +709,6 @@ module Kward
698
709
  pending_turn_count(rpc_session.id).zero?
699
710
  end
700
711
 
701
- def active_session_count(workspace_root)
702
- @mutex.synchronize { @sessions.values.count { |rpc_session| rpc_session.workspace_root == workspace_root } }
703
- end
704
-
705
712
  def tool_calls(message)
706
713
  MessageAccess.tool_calls(message)
707
714
  end
@@ -710,10 +717,6 @@ module Kward
710
717
  MessageAccess.role(message)
711
718
  end
712
719
 
713
- def message_content(message)
714
- MessageAccess.content(message)
715
- end
716
-
717
720
  def session_tree_helper(rpc_session)
718
721
  SessionTree.new(rpc_session)
719
722
  end
@@ -902,9 +905,7 @@ module Kward
902
905
  end
903
906
 
904
907
  def close_rpc_session(rpc_session, delete_unused: true)
905
- @mutex.synchronize { @sessions.delete(rpc_session.id) }
906
- stop_worker(rpc_session)
907
- stop_footer_worker(rpc_session)
908
+ remove_live_session(rpc_session)
908
909
  rpc_session.session.delete_if_unused if delete_unused && rpc_session.session.respond_to?(:delete_if_unused)
909
910
  end
910
911
 
@@ -917,12 +918,16 @@ module Kward
917
918
  next unless rpc_session.session.respond_to?(:delete_if_unused)
918
919
  next unless rpc_session.session.delete_if_unused
919
920
 
920
- @mutex.synchronize { @sessions.delete(rpc_session.id) }
921
- stop_worker(rpc_session)
922
- stop_footer_worker(rpc_session)
921
+ remove_live_session(rpc_session)
923
922
  end
924
923
  end
925
924
 
925
+ def remove_live_session(rpc_session)
926
+ @mutex.synchronize { @sessions.delete(rpc_session.id) }
927
+ stop_worker(rpc_session)
928
+ stop_footer_worker(rpc_session)
929
+ end
930
+
926
931
  def stop_worker(rpc_session)
927
932
  worker = rpc_session.worker
928
933
  return unless worker&.alive?
@@ -1065,21 +1070,23 @@ module Kward
1065
1070
 
1066
1071
  turn.steering.submit(input)
1067
1072
  turn_payload(turn)
1068
- rescue StandardError
1069
- nil
1070
1073
  end
1071
1074
 
1072
- def run_plugin_turn(rpc_session, turn)
1073
- turn.cancellation&.raise_if_cancelled!
1074
- command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
1075
- output = []
1076
- context = PluginRegistry::Context.new(
1075
+ def plugin_context(rpc_session, args: nil, say_callback:)
1076
+ PluginRegistry::Context.new(
1077
1077
  conversation: rpc_session.conversation,
1078
- args: turn.plugin_arguments.to_s,
1078
+ args: args,
1079
1079
  session: rpc_session.session,
1080
1080
  workspace_root: rpc_session.workspace_root,
1081
- say_callback: lambda { |message| output << message.to_s }
1081
+ say_callback: say_callback
1082
1082
  )
1083
+ end
1084
+
1085
+ def run_plugin_turn(rpc_session, turn)
1086
+ turn.cancellation&.raise_if_cancelled!
1087
+ command = plugin_registry.command_for(turn.plugin_command_name) || raise(ArgumentError, "Unknown plugin command: #{turn.plugin_command_name}")
1088
+ output = []
1089
+ context = plugin_context(rpc_session, args: turn.plugin_arguments.to_s, say_callback: lambda { |message| output << message.to_s })
1083
1090
  result = command.handler.call(turn.plugin_arguments.to_s, context)
1084
1091
  answer = (output + [result]).compact.map(&:to_s).reject(&:empty?).join("\n")
1085
1092
  unless answer.empty?
@@ -1092,12 +1099,7 @@ module Kward
1092
1099
  def notify_plugin_transcript_event(rpc_session, event)
1093
1100
  return if plugin_registry.transcript_event_handlers.empty?
1094
1101
 
1095
- context = PluginRegistry::Context.new(
1096
- conversation: rpc_session.conversation,
1097
- session: rpc_session.session,
1098
- workspace_root: rpc_session.workspace_root,
1099
- say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
1100
- )
1102
+ context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
1101
1103
  plugin_registry.notify_transcript_event(event, context)
1102
1104
  end
1103
1105
 
@@ -1113,6 +1115,8 @@ module Kward
1113
1115
  emit_turn_event(turn, "modelRetry", retry_event_payload(event))
1114
1116
  when Events::Steering
1115
1117
  emit_turn_event(turn, "turnSteered", { input: event.input, createdAt: event.created_at })
1118
+ when Events::SteeringApplied
1119
+ emit_turn_event(turn, "steeringApplied", { count: event.count })
1116
1120
  when Events::ToolCall
1117
1121
  emit_turn_event(turn, "toolCall", normalized_tool_event_payload(event.tool_call))
1118
1122
  when Events::ToolResult
@@ -1149,12 +1153,7 @@ module Kward
1149
1153
  return clear_footer_update(rpc_session) unless renderer
1150
1154
 
1151
1155
  text = begin
1152
- context = PluginRegistry::Context.new(
1153
- conversation: rpc_session.conversation,
1154
- session: rpc_session.session,
1155
- workspace_root: rpc_session.workspace_root,
1156
- say_callback: lambda { |message| rpc_session.plugin_output << message.to_s }
1157
- )
1156
+ context = plugin_context(rpc_session, say_callback: lambda { |message| rpc_session.plugin_output << message.to_s })
1158
1157
  renderer.call(context).to_s.gsub(/\s+/, " ").strip
1159
1158
  rescue StandardError => e
1160
1159
  warn "Warning: Kward plugin footer error: #{e.message}"
@@ -1,7 +1,7 @@
1
1
  require_relative "../message_access"
2
2
  require_relative "../message_text"
3
+ require_relative "../session_tree_nodes"
3
4
  require_relative "../session_tree_tool_display"
4
- require_relative "../tools/tool_call"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -28,13 +28,14 @@ module Kward
28
28
  #
29
29
  # @return [Array<Hash>] rows for the `session/tree` RPC method
30
30
  def rows
31
- active_path = tree_active_path(@roots, @current_leaf)
32
- tool_calls_by_id = tree_tool_calls(@roots)
33
- visible_roots = @roots.flat_map { |root| visible_tree_nodes(root) }
31
+ tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf)
32
+ active_path = tree_nodes.active_path
33
+ tool_calls_by_id = tree_nodes.tool_calls
34
+ visible_roots = tree_nodes.visible_roots
34
35
  multiple_roots = visible_roots.length > 1
35
36
  result = []
36
37
 
37
- stack = visible_roots.sort_by { |root| tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
38
+ stack = visible_roots.sort_by { |root| tree_nodes.contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
38
39
  [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
39
40
  end.reverse
40
41
 
@@ -60,7 +61,7 @@ module Kward
60
61
  prefix: tree_prefix(display_indent, gutters, show_connector && !virtual_root_child, is_last, !node[:children].empty?)
61
62
  }.compact
62
63
 
63
- children = node[:children].sort_by { |child| tree_contains_active_path?(child, active_path) ? 0 : 1 }
64
+ children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
64
65
  multiple_children = children.length > 1
65
66
  child_indent = if multiple_children
66
67
  indent + 1
@@ -80,112 +81,6 @@ module Kward
80
81
 
81
82
  private
82
83
 
83
- def tree_active_path(roots, leaf_id)
84
- by_id = tree_entries_by_id(roots)
85
- ids = []
86
- current = by_id[leaf_id.to_s]
87
- seen = {}
88
- while current && !seen[current["id"].to_s]
89
- seen[current["id"].to_s] = true
90
- ids << current["id"].to_s
91
- current = by_id[current["parentId"].to_s]
92
- end
93
- ids
94
- end
95
-
96
- def tree_entries_by_id(roots)
97
- roots.each_with_object({}) do |root, map|
98
- stack = [root]
99
- seen = {}
100
- until stack.empty?
101
- node = stack.pop
102
- next if seen[node.object_id]
103
-
104
- seen[node.object_id] = true
105
- entry = node["entry"] || {}
106
- map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
107
- stack.concat(Array(node["children"]))
108
- end
109
- end
110
- end
111
-
112
- def visible_tree_nodes(node)
113
- results = {}
114
- stack = [[node, false, {}]]
115
-
116
- until stack.empty?
117
- current, visited, seen = stack.pop
118
- node_key = current.object_id
119
- next if seen[node_key]
120
-
121
- if visited
122
- children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
123
- results[node_key] = if hidden_tree_entry?(current["entry"] || {})
124
- children
125
- else
126
- [{ source: current, children: children }]
127
- end
128
- else
129
- branch_seen = seen.merge(node_key => true)
130
- stack << [current, true, seen]
131
- Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
132
- end
133
- end
134
-
135
- results[node.object_id] || []
136
- end
137
-
138
- def hidden_tree_entry?(entry)
139
- return false if @current_leaf && entry["id"].to_s == @current_leaf.to_s
140
- return false unless entry["type"] == "message"
141
-
142
- message = entry["message"]
143
- return false unless message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
144
-
145
- content = MessageAccess.content(message)
146
- content_tool_calls = content.is_a?(Array) && content.any? { |part| ToolCall.value(part, :type) == "toolCall" }
147
- (content_tool_calls && !tree_text_content?(content)) || (!MessageAccess.tool_calls(message).empty? && MessageText.full_text(message).empty?)
148
- end
149
-
150
- def tree_text_content?(content)
151
- Array(content).any? { |part| ToolCall.value(part, :type) == "text" && ToolCall.value(part, :text).to_s.strip != "" }
152
- end
153
-
154
- def tree_contains_active_path?(node, active_path)
155
- stack = [node]
156
- seen = {}
157
- until stack.empty?
158
- current = stack.pop
159
- next if seen[current.object_id]
160
-
161
- seen[current.object_id] = true
162
- entry_id = (current[:source]["entry"] || {})["id"].to_s
163
- return true if active_path.include?(entry_id)
164
-
165
- stack.concat(current[:children])
166
- end
167
- false
168
- end
169
-
170
- def tree_tool_calls(roots)
171
- roots.each_with_object({}) do |root, tool_calls_by_id|
172
- stack = [root]
173
- seen = {}
174
- until stack.empty?
175
- node = stack.pop
176
- next if seen[node.object_id]
177
-
178
- seen[node.object_id] = true
179
- entry = node["entry"] || {}
180
- message = entry["message"]
181
- if entry["type"] == "message" && message.is_a?(Hash) && MessageAccess.role(message) == "assistant"
182
- MessageAccess.tool_calls(message).each { |tool_call| tool_calls_by_id[ToolCall.id(tool_call).to_s] = tool_call }
183
- end
184
- stack.concat(Array(node["children"]))
185
- end
186
- end
187
- end
188
-
189
84
  def tree_entry_display(entry, tool_calls_by_id = {})
190
85
  case entry["type"]
191
86
  when "message"
@@ -237,12 +132,11 @@ module Kward
237
132
  end
238
133
 
239
134
  def display_message_text(message)
240
- truncate_tree_text(MessageText.full_text(message))
135
+ SessionTreeNodes.truncate_text(MessageText.full_text(message))
241
136
  end
242
137
 
243
138
  def truncate_tree_text(text)
244
- normalized = text.to_s.gsub(/\s+/, " ").strip
245
- normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
139
+ SessionTreeNodes.truncate_text(text)
246
140
  end
247
141
  end
248
142
  end
@@ -67,7 +67,7 @@ module Kward
67
67
  end
68
68
 
69
69
  def mutation_tool?
70
- ["edit", "write"].include?(@fields[:toolName])
70
+ ToolCall.file_change_tool?(@fields[:toolName])
71
71
  end
72
72
  end
73
73
  end
@@ -178,6 +178,9 @@ module Kward
178
178
  # @return [String] workspace directory this store lists and creates sessions for
179
179
  attr_reader :cwd
180
180
 
181
+ # @return [String] configuration directory containing session and tab files
182
+ attr_reader :config_dir
183
+
181
184
  # Creates a new empty session file for the store's workspace directory.
182
185
  #
183
186
  # Parent fields record clone/fork ancestry; they do not imply live coupling
@@ -285,6 +288,7 @@ module Kward
285
288
  session_memories: memory_state["sessionMemories"],
286
289
  last_memory_retrieval: memory_state["lastRetrieval"]
287
290
  )
291
+ restore_tool_output_artifacts(records, conversation)
288
292
  conversation.mark_last_entry_compaction! if latest_record_type(records) == "compaction"
289
293
  session = Session.new(
290
294
  store: self,
@@ -496,9 +500,29 @@ module Kward
496
500
 
497
501
  def session_header(records, path)
498
502
  header = records.find { |record| record["type"] == "session" }
499
- raise "Invalid Kward session file: #{path}" unless header && header["id"].to_s != ""
503
+ return header if header && header["id"].to_s != ""
504
+
505
+ recovered = recovered_session_header(records, path)
506
+ return recovered if recovered
500
507
 
501
- header
508
+ raise "Invalid Kward session file: #{path}"
509
+ end
510
+
511
+ def recovered_session_header(records, path)
512
+ return nil unless records.any? { |record| ["message", "session_info", "system_prompt", "memory_state"].include?(record["type"]) }
513
+
514
+ basename = File.basename(path)
515
+ match = basename.match(/\A(?<timestamp>\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z)_(?<id>[0-9a-fA-F-]{36})\.jsonl\z/)
516
+ return nil unless match
517
+
518
+ timestamp = match[:timestamp].tr("-", ":").sub(/\A(\d{4}):(\d{2}):(\d{2})T/, "\\1-\\2-\\3T")
519
+ {
520
+ "type" => "session",
521
+ "version" => VERSION,
522
+ "id" => match[:id],
523
+ "timestamp" => timestamp,
524
+ "cwd" => @cwd
525
+ }
502
526
  end
503
527
 
504
528
  def session_named?(session)
@@ -541,6 +565,46 @@ module Kward
541
565
  records.reverse.find { |record| record["type"] == "memory_state" } || { "sessionMemories" => [], "lastRetrieval" => nil }
542
566
  end
543
567
 
568
+ def restore_tool_output_artifacts(records, conversation)
569
+ tool_names = tool_message_names_by_id(records)
570
+ records.each do |record|
571
+ next unless record["type"] == "tool_execution_end"
572
+
573
+ content = record.dig("result", "content")
574
+ next if content.nil?
575
+
576
+ tool_name = tool_names[record["toolCallId"].to_s] || raw_tool_name(record["toolName"])
577
+ next if tool_name.to_s.empty?
578
+
579
+ conversation.restore_tool_output_artifact(
580
+ tool_name: tool_name,
581
+ content: content,
582
+ created_at: parse_time(record["timestamp"])
583
+ )
584
+ end
585
+ end
586
+
587
+ def tool_message_names_by_id(records)
588
+ records.each_with_object({}) do |record, names|
589
+ next unless record["type"] == "message"
590
+
591
+ message = record["message"]
592
+ next unless message.is_a?(Hash) && message_role(message) == "tool"
593
+
594
+ tool_call_id = message_tool_call_id(message).to_s
595
+ names[tool_call_id] = message_name(message) unless tool_call_id.empty? || message_name(message).to_s.empty?
596
+ end
597
+ end
598
+
599
+ def raw_tool_name(name)
600
+ {
601
+ "bash" => "run_shell_command",
602
+ "edit" => "edit_file",
603
+ "read" => "read_file",
604
+ "write" => "write_file"
605
+ }.fetch(name.to_s, name.to_s)
606
+ end
607
+
544
608
  def session_runtime(records, header)
545
609
  result = {
546
610
  "provider" => header["provider"],
@@ -756,8 +820,7 @@ module Kward
756
820
 
757
821
  def session_info(path)
758
822
  records = records_from_file(path)
759
- header = records.find { |record| record["type"] == "session" }
760
- return nil unless header && header["id"].to_s != ""
823
+ header = session_header(records, path)
761
824
 
762
825
  messages = restored_messages(records)
763
826
  name = session_name(records)