kward 0.73.1 → 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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -2
  5. data/Rakefile +18 -0
  6. data/doc/configuration.md +70 -4
  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/mcp.md +72 -0
  11. data/doc/memory.md +1 -1
  12. data/doc/personas.md +2 -2
  13. data/doc/plugins.md +1 -1
  14. data/doc/releasing.md +7 -15
  15. data/doc/rpc.md +108 -15
  16. data/doc/session-management.md +9 -9
  17. data/doc/skills.md +199 -0
  18. data/doc/usage.md +14 -7
  19. data/lib/kward/agent.rb +72 -22
  20. data/lib/kward/auth/anthropic_oauth.rb +5 -87
  21. data/lib/kward/auth/oauth_helpers.rb +105 -0
  22. data/lib/kward/auth/openai_oauth.rb +4 -86
  23. data/lib/kward/cli/commands.rb +12 -2
  24. data/lib/kward/cli/compaction.rb +7 -0
  25. data/lib/kward/cli/doctor.rb +18 -10
  26. data/lib/kward/cli/git.rb +30 -3
  27. data/lib/kward/cli/hook_commands.rb +258 -0
  28. data/lib/kward/cli/interactive_turn.rb +9 -3
  29. data/lib/kward/cli/memory_commands.rb +1 -5
  30. data/lib/kward/cli/plugins.rb +41 -1
  31. data/lib/kward/cli/prompt_interface.rb +32 -3
  32. data/lib/kward/cli/rendering.rb +1 -1
  33. data/lib/kward/cli/runtime_helpers.rb +44 -5
  34. data/lib/kward/cli/sessions.rb +13 -0
  35. data/lib/kward/cli/settings.rb +226 -152
  36. data/lib/kward/cli/slash_commands.rb +81 -42
  37. data/lib/kward/cli/tabs.rb +32 -15
  38. data/lib/kward/cli.rb +64 -4
  39. data/lib/kward/cli_transcript_formatter.rb +0 -7
  40. data/lib/kward/compaction/token_estimator.rb +112 -0
  41. data/lib/kward/compactor.rb +42 -116
  42. data/lib/kward/config_files.rb +152 -27
  43. data/lib/kward/deep_copy.rb +42 -0
  44. data/lib/kward/diff_view_mode.rb +36 -0
  45. data/lib/kward/events.rb +1 -0
  46. data/lib/kward/export_path.rb +2 -6
  47. data/lib/kward/frontmatter.rb +43 -0
  48. data/lib/kward/hooks/audit_log.rb +121 -0
  49. data/lib/kward/hooks/catalog.rb +110 -0
  50. data/lib/kward/hooks/command_handler.rb +66 -0
  51. data/lib/kward/hooks/config_loader.rb +104 -0
  52. data/lib/kward/hooks/decision.rb +102 -0
  53. data/lib/kward/hooks/event.rb +60 -0
  54. data/lib/kward/hooks/http_handler.rb +72 -0
  55. data/lib/kward/hooks/manager.rb +158 -0
  56. data/lib/kward/hooks/matcher.rb +75 -0
  57. data/lib/kward/hooks.rb +16 -0
  58. data/lib/kward/markdown_transcript.rb +4 -3
  59. data/lib/kward/mcp/client.rb +56 -0
  60. data/lib/kward/mcp/server_config.rb +59 -0
  61. data/lib/kward/mcp/stdio_transport.rb +112 -0
  62. data/lib/kward/memory/turn_context.rb +19 -0
  63. data/lib/kward/message_access.rb +2 -2
  64. data/lib/kward/message_text.rb +19 -0
  65. data/lib/kward/model/client.rb +13 -45
  66. data/lib/kward/model/context_usage.rb +10 -4
  67. data/lib/kward/model/copilot_models.rb +44 -0
  68. data/lib/kward/model/model_info.rb +24 -0
  69. data/lib/kward/model/payloads.rb +15 -17
  70. data/lib/kward/model/stream_parser.rb +178 -13
  71. data/lib/kward/pan/server.rb +51 -2
  72. data/lib/kward/path_guard.rb +13 -0
  73. data/lib/kward/plugin_registry.rb +86 -25
  74. data/lib/kward/prompt_interface/editor/controller.rb +124 -4
  75. data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
  76. data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
  77. data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
  78. data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
  79. data/lib/kward/prompt_interface/editor/search.rb +70 -13
  80. data/lib/kward/prompt_interface/editor/state.rb +72 -13
  81. data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -1
  82. data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
  83. data/lib/kward/prompt_interface.rb +4 -1
  84. data/lib/kward/prompts/commands.rb +3 -3
  85. data/lib/kward/prompts.rb +7 -7
  86. data/lib/kward/rpc/mcp_status.rb +50 -0
  87. data/lib/kward/rpc/prompt_bridge.rb +38 -4
  88. data/lib/kward/rpc/runtime_payloads.rb +1 -1
  89. data/lib/kward/rpc/server.rb +94 -13
  90. data/lib/kward/rpc/session_manager.rb +258 -33
  91. data/lib/kward/rpc/session_metrics.rb +1 -1
  92. data/lib/kward/rpc/session_tree_rows.rb +13 -64
  93. data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
  94. data/lib/kward/rpc/transcript_normalizer.rb +9 -10
  95. data/lib/kward/rpc/turn_context.rb +109 -0
  96. data/lib/kward/session_store.rb +6 -20
  97. data/lib/kward/session_tree_nodes.rb +98 -18
  98. data/lib/kward/session_tree_renderer.rb +6 -61
  99. data/lib/kward/skills/registry.rb +109 -17
  100. data/lib/kward/tools/mcp_tool.rb +122 -0
  101. data/lib/kward/tools/read_skill.rb +9 -4
  102. data/lib/kward/tools/registry.rb +297 -9
  103. data/lib/kward/tools/search/code.rb +4 -22
  104. data/lib/kward/tools/search/web.rb +28 -2
  105. data/lib/kward/update_check.rb +117 -0
  106. data/lib/kward/version.rb +1 -1
  107. data/lib/kward/workers/manager.rb +43 -3
  108. data/lib/kward/workspace.rb +3 -1
  109. data/templates/default/fulldoc/html/css/kward.css +22 -0
  110. data/templates/default/fulldoc/html/js/kward.js +2 -0
  111. data/templates/default/kward_navigation.rb +4 -2
  112. data/templates/default/layout/html/setup.rb +2 -0
  113. metadata +30 -1
@@ -19,6 +19,12 @@ module Kward
19
19
  activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
20
20
  run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
21
21
  [true, nil]
22
+ when "hooks"
23
+ run_busy_local_command_and_requeue { handle_hooks_command(argument) }
24
+ [true, nil]
25
+ when "skill"
26
+ run_busy_local_command_and_requeue { activate_skill_command(argument, agent) }
27
+ [true, nil]
22
28
  when "redraw"
23
29
  run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
24
30
  [true, nil]
@@ -74,38 +80,16 @@ module Kward
74
80
  [true, nil]
75
81
  when "new"
76
82
  [true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
77
- when "sessions", "resume"
78
- unless session_store
79
- say_sessions_unavailable
80
- return [true, nil]
81
- end
82
-
83
- path = argument.to_s.strip
84
- if path.empty?
85
- sessions = run_busy_local_command_and_requeue { session_store.recent_tree(limit: nil) }
86
- path = select_session_path_from_sessions(sessions, session_store: session_store)
87
- end
88
- replacement_agent = nil
89
- selection = path
90
- loop do
91
- replacement_agent = if selection.respond_to?(:conversation)
92
- selection
93
- elsif selection.is_a?(Hash) && selection[:action] == :clone
94
- run_busy_local_command_and_requeue(activity: "cloning") { clone_session_from_path(session_store, selection[:path]) }
95
- elsif selection.is_a?(Hash) && selection[:action] == :fork
96
- selection = reopen_sessions_after_fork(session_store, selection[:path], selection[:choice_label])
97
- next
98
- elsif selection.to_s.empty?
99
- nil
100
- else
101
- run_busy_local_command_and_requeue { resume_session(session_store, selection) }
102
- end
103
- break
83
+ when "session"
84
+ session_command, session_argument = argument.to_s.split(/\s+/, 2)
85
+ if session_command == "name"
86
+ rename_session(session_argument)
87
+ [true, nil]
88
+ else
89
+ [true, open_or_resume_session(session_store, argument)]
104
90
  end
105
- [true, replacement_agent]
106
- when "name"
107
- rename_session(argument)
108
- [true, nil]
91
+ when "resume"
92
+ [true, open_or_resume_session(session_store, argument)]
109
93
  when "rename"
110
94
  rename_session(argument, require_name: true)
111
95
  [true, nil]
@@ -127,7 +111,10 @@ module Kward
127
111
  run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
128
112
  [true, nil]
129
113
  else
130
- if interactive_command_for(name) && prompt_interface? && @prompt.respond_to?(:start_interactive)
114
+ if name.to_s.start_with?("skill:")
115
+ run_busy_local_command_and_requeue { activate_skill_command(name.to_s.delete_prefix("skill:"), agent) }
116
+ [true, nil]
117
+ elsif interactive_command_for(name) && prompt_interface? && @prompt.respond_to?(:start_interactive)
131
118
  run_interactive_command(name, argument, agent)
132
119
  elsif plugin_command_for(name)
133
120
  run_busy_local_command_and_requeue(activity: "running") { run_plugin_command(name, argument, agent) }
@@ -141,6 +128,56 @@ module Kward
141
128
  PromptCommands.parse(command) || [nil, ""]
142
129
  end
143
130
 
131
+ def open_or_resume_session(session_store, argument)
132
+ unless session_store
133
+ say_sessions_unavailable
134
+ return nil
135
+ end
136
+
137
+ path = argument.to_s.strip
138
+ if path.empty?
139
+ sessions = run_busy_local_command_and_requeue { session_store.recent_tree(limit: nil) }
140
+ path = select_session_path_from_sessions(sessions, session_store: session_store)
141
+ end
142
+
143
+ selection = path
144
+ loop do
145
+ replacement_agent = if selection.respond_to?(:conversation)
146
+ selection
147
+ elsif selection.is_a?(Hash) && selection[:action] == :clone
148
+ run_busy_local_command_and_requeue(activity: "cloning") { clone_session_from_path(session_store, selection[:path]) }
149
+ elsif selection.is_a?(Hash) && selection[:action] == :fork
150
+ selection = reopen_sessions_after_fork(session_store, selection[:path], selection[:choice_label])
151
+ next
152
+ elsif selection.to_s.empty?
153
+ nil
154
+ else
155
+ run_busy_local_command_and_requeue { resume_session(session_store, selection) }
156
+ end
157
+ return replacement_agent
158
+ end
159
+ end
160
+
161
+ def activate_skill_command(name, agent)
162
+ skill_name = name.to_s.strip
163
+ if skill_name.empty?
164
+ runtime_output("Usage: /skill <name>")
165
+ return
166
+ end
167
+
168
+ tool_call = {
169
+ "id" => "skill_#{skill_name.gsub(/[^a-zA-Z0-9_-]/, "_")}",
170
+ "type" => "function",
171
+ "function" => {
172
+ "name" => "read_skill",
173
+ "arguments" => JSON.dump({ name: skill_name })
174
+ }
175
+ }
176
+ agent.conversation.append_assistant("role" => "assistant", "content" => nil, "tool_calls" => [tool_call])
177
+ result = agent.tool_registry.dispatch(tool_call, agent.conversation)
178
+ runtime_output(result.start_with?("Error:") ? result : "Activated skill: #{skill_name}")
179
+ end
180
+
144
181
  def open_session_diff
145
182
  unless @active_session&.path
146
183
  runtime_output("No active persisted session.")
@@ -413,7 +450,9 @@ module Kward
413
450
  reasoning_effort: current_reasoning_effort,
414
451
  write_lock: (@worker_write_lock ||= Workers::WriteLock.new),
415
452
  worker_store: worker_store,
416
- write_lane_available: -> { !@foreground_turn_active }
453
+ write_lane_available: -> { !@foreground_turn_active },
454
+ hook_manager: lifecycle_hook_manager(agent.conversation),
455
+ hook_context: lifecycle_hook_context(agent.conversation)
417
456
  )
418
457
  end
419
458
 
@@ -452,7 +491,7 @@ module Kward
452
491
  def open_worker_list(agent, session_store, title: "Workers", empty_message: "No workers in the pipeline.")
453
492
  return runtime_output(empty_message) unless @prompt.respond_to?(:select)
454
493
 
455
- jobs = worker_jobs(agent)
494
+ jobs = worker_jobs
456
495
  if jobs.empty?
457
496
  runtime_output(empty_message)
458
497
  return
@@ -466,15 +505,15 @@ module Kward
466
505
  open_worker_actions(selected, agent, session_store) if selected
467
506
  end
468
507
 
469
- def worker_jobs(agent)
508
+ def worker_jobs
470
509
  runtime_worker_ids = @worker_manager ? @worker_manager.list.map(&:id) : []
471
510
  persisted_workers = worker_store.list.reject { |job| runtime_worker_ids.include?(job["id"]) }
472
511
  live_workers = @worker_manager ? @worker_manager.list.map(&:to_h) : []
473
- [implementation_worker_job(agent)].compact + persisted_workers + live_workers
512
+ [implementation_worker_job].compact + persisted_workers + live_workers
474
513
  end
475
514
 
476
- def implementation_worker_job(agent)
477
- remember_implementation_worker(agent) if implementation_agent?(agent)
515
+ def implementation_worker_job
516
+ remember_implementation_worker if implementation_agent?
478
517
  path = @implementation_worker_session_path || @active_session&.path
479
518
  return nil if path.to_s.empty?
480
519
 
@@ -482,18 +521,18 @@ module Kward
482
521
  "id" => "implementation",
483
522
  "title" => @implementation_worker_title || @active_session&.name || "Implementation",
484
523
  "role" => "implementation",
485
- "status" => implementation_agent?(agent) ? "active" : "idle",
524
+ "status" => implementation_agent? ? "active" : "idle",
486
525
  "session_path" => path
487
526
  }
488
527
  end
489
528
 
490
- def implementation_agent?(agent)
529
+ def implementation_agent?
491
530
  @active_worker_role.to_s.empty? || @active_worker_role == "implementation"
492
531
  end
493
532
 
494
- def remember_implementation_worker(agent)
533
+ def remember_implementation_worker
495
534
  return unless @active_session&.path
496
- return unless implementation_agent?(agent)
535
+ return unless implementation_agent?
497
536
 
498
537
  @implementation_worker_session_path = @active_session.path
499
538
  @implementation_worker_title = @active_session.name || "Implementation"
@@ -28,14 +28,19 @@ module Kward
28
28
  :pending_question,
29
29
  :shell,
30
30
  :error_reported,
31
+ :local_busy_activity,
31
32
  keyword_init: true
32
33
  ) do
33
34
  def running?
34
35
  %w[queued running waiting_for_question].include?(status.to_s)
35
36
  end
36
37
 
38
+ def local_busy?
39
+ !local_busy_activity.to_s.empty?
40
+ end
41
+
37
42
  def idle?
38
- !running?
43
+ !running? && !local_busy?
39
44
  end
40
45
 
41
46
  def record_event(event)
@@ -121,10 +126,23 @@ module Kward
121
126
  conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
122
127
  workspace = configured_workspace(root: conversation.workspace_root)
123
128
  prompt = TabQuestionPrompt.new(self)
124
- tool_registry = ToolRegistry.new(workspace: workspace, prompt: prompt)
129
+ hook_manager = lifecycle_hook_manager(conversation)
130
+ hook_context = lifecycle_hook_context(conversation)
131
+ tool_registry = ToolRegistry.new(
132
+ workspace: workspace,
133
+ prompt: prompt,
134
+ hook_manager: hook_manager,
135
+ hook_context: hook_context
136
+ )
125
137
  @footer_conversation = conversation
126
138
  @footer_tool_registry = tool_registry
127
- agent = Agent.new(client: @client, tool_registry: tool_registry, conversation: conversation)
139
+ agent = Agent.new(
140
+ client: @client,
141
+ tool_registry: tool_registry,
142
+ conversation: conversation,
143
+ hook_manager: hook_manager,
144
+ hook_context: hook_context
145
+ )
128
146
  agent.instance_variable_set(:@tab_question_prompt, prompt)
129
147
  agent
130
148
  end
@@ -150,7 +168,8 @@ module Kward
150
168
  unread: false,
151
169
  pending_question: nil,
152
170
  shell: nil,
153
- error_reported: false
171
+ error_reported: false,
172
+ local_busy_activity: nil
154
173
  ).tap { |tab| assign_tab_question_prompt(agent, tab) }
155
174
  end
156
175
 
@@ -231,7 +250,7 @@ module Kward
231
250
 
232
251
  def close_active_tab
233
252
  tab = active_tab
234
- if tab&.running?
253
+ if tab&.running? || tab&.local_busy?
235
254
  runtime_output("Tab #{active_tab_number} is running and cannot be closed yet.")
236
255
  return nil
237
256
  end
@@ -312,6 +331,7 @@ module Kward
312
331
  update_prompt_tabs
313
332
  render_tab(tab) if render
314
333
  start_tab_live_view(tab) if tab.running?
334
+ @prompt.begin_busy_input("You>", activity: tab.local_busy_activity) if tab.local_busy? && @prompt.respond_to?(:begin_busy_input)
315
335
  persist_tabs
316
336
  service_active_tab_question
317
337
  tab.agent
@@ -488,18 +508,15 @@ module Kward
488
508
  end
489
509
 
490
510
  def handle_tab_busy_input(tab, input)
491
- command = input.to_s.strip
492
- if command == "/workers" || command.start_with?("/workers ") || command == "/tab" || command.start_with?("/tab ")
493
- _handled, replacement_agent = handle_local_slash_command(command, tab.agent, @session_store)
494
- tab.agent = replacement_agent if replacement_agent?(replacement_agent)
495
- restore_busy_input_prompt
496
- return
497
- end
498
-
499
- if slash_command_input?(input)
511
+ if busy_queued_command?(input)
500
512
  tab.queued_inputs << input
501
513
  @prompt.set_queued_count(tab.queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
502
514
  return
515
+ elsif slash_command_input?(input)
516
+ # Slash commands are local control actions. Running or queuing them
517
+ # from the busy composer is surprising because the state they act on
518
+ # may have changed by the time the active turn finishes.
519
+ return
503
520
  end
504
521
 
505
522
  if tab.steering && !input.to_s.strip.empty?
@@ -601,7 +618,7 @@ module Kward
601
618
 
602
619
  def tab_label_color(tab)
603
620
  return :green if tab.status.to_s == "waiting_for_question"
604
- return :yellow if tab.running?
621
+ return :yellow if tab.running? || tab.local_busy?
605
622
  return :red if %w[failed cancelled].include?(tab.status.to_s)
606
623
  return :green if tab.unread
607
624
 
data/lib/kward/cli.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "auth/github_oauth"
21
21
  require_relative "auth/openrouter_api_key"
22
22
  require_relative "image_attachments"
23
23
  require_relative "memory/manager"
24
+ require_relative "memory/turn_context"
24
25
  require_relative "transcript_export"
25
26
  require_relative "message_access"
26
27
  require_relative "model/model_info"
@@ -38,6 +39,7 @@ require_relative "session_trash"
38
39
  require_relative "session_tree_renderer"
39
40
  require_relative "starter_pack_installer"
40
41
  require_relative "steering"
42
+ require_relative "update_check"
41
43
  require_relative "workers"
42
44
  require_relative "tools/tool_call"
43
45
  require_relative "tools/registry"
@@ -52,6 +54,7 @@ require_relative "cli/openrouter_commands"
52
54
  require_relative "cli/runtime_helpers"
53
55
  require_relative "cli/slash_commands"
54
56
  require_relative "cli/memory_commands"
57
+ require_relative "cli/hook_commands"
55
58
  require_relative "cli/settings"
56
59
  require_relative "cli/sessions"
57
60
  require_relative "cli/tabs"
@@ -66,7 +69,7 @@ require_relative "cli/tool_summaries"
66
69
  # Namespace for the Kward CLI agent runtime.
67
70
  module Kward
68
71
  # Command-line interface for interactive chat, one-shot prompts, login,
69
- # telemetry export, Pan server mode, and the experimental JSON-RPC backend.
72
+ # telemetry export, Pan server mode, and the JSON-RPC backend.
70
73
  class CLI
71
74
  RESTORED_TOOL_OUTPUT_LIMIT = 2_000
72
75
  INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
@@ -84,6 +87,7 @@ module Kward
84
87
  include CLI::RuntimeHelpers
85
88
  include CLI::SlashCommands
86
89
  include CLI::MemoryCommands
90
+ include CLI::HookCommands
87
91
  include CLI::Settings
88
92
  include CLI::Sessions
89
93
  include CLI::Tabs
@@ -95,7 +99,7 @@ module Kward
95
99
  include CLI::InteractiveTurn
96
100
  include CLI::ToolSummaries
97
101
 
98
- def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: Client.new, session_store: nil, context_usage: ContextUsage.new)
102
+ def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: nil, session_store: nil, context_usage: ContextUsage.new)
99
103
  @argv = argv
100
104
  @stdin = stdin
101
105
  @prompt = prompt
@@ -109,6 +113,7 @@ module Kward
109
113
  @working_directory = nil
110
114
  @prompt_delimited = false
111
115
  @requested_mode = "auto"
116
+ @skip_config = false
112
117
  @experimental_workers = false
113
118
  @foreground_turn_active = false
114
119
  @pending_reasoning_config = nil
@@ -122,11 +127,41 @@ module Kward
122
127
  # @return [void]
123
128
  def run
124
129
  @argv = extract_global_options(@argv)
130
+ ConfigFiles.skip_config = @skip_config
125
131
  with_working_directory { dispatch }
132
+ rescue ConfigFiles::ConfigError => e
133
+ warn config_error_message(e)
134
+ exit 1
126
135
  rescue ArgumentError => e
127
136
  warn e.message
128
137
  warn "Run `kward help` for available commands."
129
138
  exit 1
139
+ ensure
140
+ ConfigFiles.skip_config = false
141
+ end
142
+
143
+ def ensure_client!
144
+ @client ||= Client.new
145
+ end
146
+
147
+ def config_error_message(error)
148
+ <<~MESSAGE.rstrip
149
+ Invalid Kward config #{error.format}.
150
+
151
+ File:
152
+ #{error.path}
153
+
154
+ Parser error:
155
+ #{error.detail}
156
+
157
+ Kward cannot safely continue with this config.
158
+
159
+ Repair it with:
160
+ kward edit #{error.path}
161
+
162
+ Emergency fallback:
163
+ kward --skip-config doctor
164
+ MESSAGE
130
165
  end
131
166
 
132
167
  def dispatch
@@ -173,10 +208,21 @@ module Kward
173
208
  end
174
209
  raise ArgumentError, command_usage("doctor") unless @argv.length == 1
175
210
 
211
+ ensure_client!
176
212
  print_doctor
177
213
  return
178
214
  end
179
215
 
216
+ if @argv.first == "hooks"
217
+ if help_option_arguments?(@argv[1..] || [])
218
+ print_command_help("hooks")
219
+ return
220
+ end
221
+
222
+ handle_hooks_command((@argv[1..] || []).join(" "))
223
+ return
224
+ end
225
+
180
226
  if @argv.first == "edit"
181
227
  if help_option_arguments?(@argv[1..] || [])
182
228
  print_command_help("edit")
@@ -194,6 +240,7 @@ module Kward
194
240
  return
195
241
  end
196
242
 
243
+ ensure_client!
197
244
  print_sysprompt(@argv[1..] || [])
198
245
  return
199
246
  end
@@ -205,6 +252,7 @@ module Kward
205
252
  end
206
253
  raise ArgumentError, command_usage("rpc") unless @argv.length == 1
207
254
 
255
+ ensure_client!
208
256
  Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client, experimental_workers: @experimental_workers).run
209
257
  return
210
258
  end
@@ -226,6 +274,7 @@ module Kward
226
274
  return
227
275
  end
228
276
 
277
+ ensure_client!
229
278
  handle_openrouter_command(@argv[1..] || [])
230
279
  return
231
280
  end
@@ -237,6 +286,7 @@ module Kward
237
286
  end
238
287
  raise ArgumentError, command_usage("pan") unless @argv.length == 1
239
288
 
289
+ ensure_client!
240
290
  PanServer.new(client: @client, working_directory: current_workspace_root).run
241
291
  return
242
292
  end
@@ -267,6 +317,7 @@ module Kward
267
317
  end
268
318
 
269
319
  def run_prompt_or_interactive
320
+ ensure_client!
270
321
  stdin_input = read_stdin_input
271
322
  first_prompt = one_shot_prompt_argument
272
323
 
@@ -291,10 +342,19 @@ module Kward
291
342
  markdown_chunks = []
292
343
  conversation = new_conversation
293
344
  apply_filter_system_prompt(conversation) if filter
345
+ hook_manager = lifecycle_hook_manager(conversation)
346
+ hook_context = lifecycle_hook_context(conversation)
294
347
  agent = Agent.new(
295
348
  client: @client,
296
- tool_registry: ToolRegistry.new(workspace: configured_workspace, prompt: @prompt),
297
- conversation: conversation
349
+ tool_registry: ToolRegistry.new(
350
+ workspace: configured_workspace,
351
+ prompt: @prompt,
352
+ hook_manager: hook_manager,
353
+ hook_context: hook_context
354
+ ),
355
+ conversation: conversation,
356
+ hook_manager: hook_manager,
357
+ hook_context: hook_context
298
358
  )
299
359
  answer = if filter
300
360
  agent.ask(input)
@@ -52,13 +52,6 @@ module Kward
52
52
  end.join
53
53
  end
54
54
 
55
- def display_text(message)
56
- display_content = MessageAccess.display_content(message)
57
- return display_content.to_s unless display_content.nil?
58
-
59
- content_text(MessageAccess.content(message))
60
- end
61
-
62
55
  def user_display_text(message)
63
56
  display_content = MessageAccess.display_content(message)
64
57
  return display_content.to_s unless display_content.nil?
@@ -0,0 +1,112 @@
1
+ require "json"
2
+ require_relative "../message_access"
3
+ require_relative "../tools/tool_call"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Conversation compaction settings, planning, and summary generation.
8
+ module Compaction
9
+ # Estimates transcript token usage from messages and provider usage metadata.
10
+ class TokenEstimator
11
+ def estimate_tokens(text)
12
+ (text.to_s.length / 4.0).ceil
13
+ end
14
+
15
+ def messages_tokens(messages)
16
+ Array(messages).sum { |message| message_tokens(message) }
17
+ end
18
+
19
+ def context_tokens(messages)
20
+ messages = Array(messages)
21
+ usage_info = last_assistant_usage_info(messages)
22
+ return messages_tokens(messages) unless usage_info
23
+
24
+ usage_tokens = usage_tokens(usage_info[:usage])
25
+ trailing_tokens = messages[(usage_info[:index] + 1)..].to_a.sum { |message| message_tokens(message) }
26
+ usage_tokens + trailing_tokens
27
+ end
28
+
29
+ def message_tokens(message)
30
+ role = value(message, :role)
31
+ parts = [role]
32
+ if role.to_s == "compactionSummary"
33
+ parts << value(message, :summary)
34
+ else
35
+ parts << content_text(value(message, :content))
36
+ end
37
+ parts << value(message, :reasoning_summary)
38
+ tool_calls(message).each do |tool_call|
39
+ parts << tool_call_name(tool_call)
40
+ parts << tool_call_arguments(tool_call)
41
+ end
42
+ parts << value(message, :tool_call_id)
43
+ parts << value(message, :name)
44
+ estimate_tokens(parts.compact.join("\n"))
45
+ end
46
+
47
+ private
48
+
49
+ def content_text(content)
50
+ return content.to_s unless content.is_a?(Array)
51
+
52
+ content.filter_map do |part|
53
+ type = value(part, :type)
54
+ if type == "text"
55
+ value(part, :text)
56
+ elsif type == "image"
57
+ path = value(part, :path)
58
+ media_type = value(part, :media_type) || value(part, :mimeType) || "image"
59
+ "[#{media_type}#{path ? ": #{path}" : ""}]"
60
+ end
61
+ end.join("\n")
62
+ end
63
+
64
+ def tool_calls(message)
65
+ MessageAccess.tool_calls(message)
66
+ end
67
+
68
+ def tool_call_name(tool_call)
69
+ ToolCall.name(tool_call)
70
+ end
71
+
72
+ def tool_call_arguments(tool_call)
73
+ arguments = ToolCall.raw_arguments(tool_call)
74
+ arguments.is_a?(Hash) ? JSON.dump(arguments) : arguments.to_s
75
+ end
76
+
77
+ def last_assistant_usage_info(messages)
78
+ messages.each_with_index.reverse_each do |message, index|
79
+ next unless value(message, :role).to_s == "assistant"
80
+
81
+ usage = value(message, :usage)
82
+ tokens = usage_tokens(usage)
83
+ return { usage: usage, index: index } if tokens.positive?
84
+ end
85
+ nil
86
+ end
87
+
88
+ def usage_tokens(usage)
89
+ return 0 unless usage.respond_to?(:key?)
90
+
91
+ total = usage_value(usage, :total_tokens, "totalTokens")
92
+ return total if total.positive?
93
+
94
+ usage_value(usage, :input_tokens, "input", "prompt_tokens") +
95
+ usage_value(usage, :output_tokens, "output", "completion_tokens") +
96
+ usage_value(usage, :cache_read_tokens, "cacheRead", "cacheReadTokens", "cache_read", "cached_tokens") +
97
+ usage_value(usage, :cache_write_tokens, "cacheWrite", "cacheWriteTokens", "cache_write")
98
+ end
99
+
100
+ def usage_value(usage, *keys)
101
+ key = keys.find { |candidate| usage.key?(candidate) || usage.key?(candidate.to_s) }
102
+ return 0 unless key
103
+
104
+ (usage[key] || usage[key.to_s]).to_i
105
+ end
106
+
107
+ def value(object, key)
108
+ ToolCall.value(object, key)
109
+ end
110
+ end
111
+ end
112
+ end