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
@@ -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
@@ -598,7 +618,7 @@ module Kward
598
618
 
599
619
  def tab_label_color(tab)
600
620
  return :green if tab.status.to_s == "waiting_for_question"
601
- return :yellow if tab.running?
621
+ return :yellow if tab.running? || tab.local_busy?
602
622
  return :red if %w[failed cancelled].include?(tab.status.to_s)
603
623
  return :green if tab.unread
604
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
@@ -209,6 +213,16 @@ module Kward
209
213
  return
210
214
  end
211
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
+
212
226
  if @argv.first == "edit"
213
227
  if help_option_arguments?(@argv[1..] || [])
214
228
  print_command_help("edit")
@@ -328,10 +342,19 @@ module Kward
328
342
  markdown_chunks = []
329
343
  conversation = new_conversation
330
344
  apply_filter_system_prompt(conversation) if filter
345
+ hook_manager = lifecycle_hook_manager(conversation)
346
+ hook_context = lifecycle_hook_context(conversation)
331
347
  agent = Agent.new(
332
348
  client: @client,
333
- tool_registry: ToolRegistry.new(workspace: configured_workspace, prompt: @prompt),
334
- 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
335
358
  )
336
359
  answer = if filter
337
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
@@ -1,6 +1,7 @@
1
1
  require "json"
2
2
  require_relative "model/chat_invocation"
3
3
  require_relative "compaction/file_operation_tracker"
4
+ require_relative "compaction/token_estimator"
4
5
  require_relative "config_files"
5
6
  require_relative "message_access"
6
7
  require_relative "prompts"
@@ -74,111 +75,6 @@ module Kward
74
75
  end
75
76
  end
76
77
 
77
- # Compaction support object used by conversation summarization.
78
- class TokenEstimator
79
- def estimate_tokens(text)
80
- (text.to_s.length / 4.0).ceil
81
- end
82
-
83
- def messages_tokens(messages)
84
- Array(messages).sum { |message| message_tokens(message) }
85
- end
86
-
87
- def context_tokens(messages)
88
- messages = Array(messages)
89
- usage_info = last_assistant_usage_info(messages)
90
- return messages_tokens(messages) unless usage_info
91
-
92
- usage_tokens = usage_tokens(usage_info[:usage])
93
- trailing_tokens = messages[(usage_info[:index] + 1)..].to_a.sum { |message| message_tokens(message) }
94
- usage_tokens + trailing_tokens
95
- end
96
-
97
- def message_tokens(message)
98
- role = value(message, :role)
99
- parts = [role]
100
- if role.to_s == "compactionSummary"
101
- parts << value(message, :summary)
102
- else
103
- parts << content_text(value(message, :content))
104
- end
105
- parts << value(message, :reasoning_summary)
106
- tool_calls(message).each do |tool_call|
107
- parts << tool_call_name(tool_call)
108
- parts << tool_call_arguments(tool_call)
109
- end
110
- parts << value(message, :tool_call_id)
111
- parts << value(message, :name)
112
- estimate_tokens(parts.compact.join("\n"))
113
- end
114
-
115
- private
116
-
117
- def content_text(content)
118
- return content.to_s unless content.is_a?(Array)
119
-
120
- content.filter_map do |part|
121
- type = value(part, :type)
122
- if type == "text"
123
- value(part, :text)
124
- elsif type == "image"
125
- path = value(part, :path)
126
- media_type = value(part, :media_type) || value(part, :mimeType) || "image"
127
- "[#{media_type}#{path ? ": #{path}" : ""}]"
128
- end
129
- end.join("\n")
130
- end
131
-
132
- def tool_calls(message)
133
- MessageAccess.tool_calls(message)
134
- end
135
-
136
- def tool_call_name(tool_call)
137
- function = value(tool_call, :function) || {}
138
- value(function, :name)
139
- end
140
-
141
- def tool_call_arguments(tool_call)
142
- function = value(tool_call, :function) || {}
143
- arguments = value(function, :arguments)
144
- arguments.is_a?(Hash) ? JSON.dump(arguments) : arguments.to_s
145
- end
146
-
147
- def last_assistant_usage_info(messages)
148
- messages.each_with_index.reverse_each do |message, index|
149
- next unless value(message, :role).to_s == "assistant"
150
-
151
- usage = value(message, :usage)
152
- tokens = usage_tokens(usage)
153
- return { usage: usage, index: index } if tokens.positive?
154
- end
155
- nil
156
- end
157
-
158
- def usage_tokens(usage)
159
- return 0 unless usage.respond_to?(:key?)
160
-
161
- total = usage_value(usage, :total_tokens, "totalTokens")
162
- return total if total.positive?
163
-
164
- usage_value(usage, :input_tokens, "input", "prompt_tokens") +
165
- usage_value(usage, :output_tokens, "output", "completion_tokens") +
166
- usage_value(usage, :cache_read_tokens, "cacheRead", "cacheReadTokens", "cache_read", "cached_tokens") +
167
- usage_value(usage, :cache_write_tokens, "cacheWrite", "cacheWriteTokens", "cache_write")
168
- end
169
-
170
- def usage_value(usage, *keys)
171
- key = keys.find { |candidate| usage.key?(candidate) || usage.key?(candidate.to_s) }
172
- return 0 unless key
173
-
174
- (usage[key] || usage[key.to_s]).to_i
175
- end
176
-
177
- def value(object, key)
178
- ToolCall.value(object, key)
179
- end
180
- end
181
-
182
78
  # Compaction support object used by conversation summarization.
183
79
  class ConversationSerializer
184
80
  TOOL_RESULT_LIMIT = 2_000
@@ -324,17 +220,15 @@ module Kward
324
220
  end
325
221
 
326
222
  def tool_call_id(tool_call)
327
- tool_call["id"] || tool_call[:id]
223
+ ToolCall.id(tool_call)
328
224
  end
329
225
 
330
226
  def tool_call_name(tool_call)
331
- function = tool_call["function"] || tool_call[:function] || {}
332
- function["name"] || function[:name] || "unknown_tool"
227
+ ToolCall.name(tool_call) || "unknown_tool"
333
228
  end
334
229
 
335
230
  def tool_call_args(tool_call)
336
- function = tool_call["function"] || tool_call[:function] || {}
337
- ToolCall.parse_arguments(function["arguments"] || function[:arguments])
231
+ ToolCall.arguments(tool_call)
338
232
  end
339
233
 
340
234
  def tool_command(tool_call)
@@ -449,7 +343,7 @@ module Kward
449
343
  def call
450
344
  branch_entries = entry_messages(@conversation.messages)
451
345
  raise NothingToCompact, "Nothing to compact" if branch_entries.empty?
452
- raise AlreadyCompacted, "Already compacted" if compaction_entry?(branch_entries.last) || already_compacted?
346
+ raise AlreadyCompacted, "Already compacted" if compaction_entry?(branch_entries.last)
453
347
 
454
348
  previous_index = latest_previous_compaction_index(branch_entries)
455
349
  previous_entry = previous_index ? branch_entries[previous_index] : nil
@@ -464,7 +358,8 @@ module Kward
464
358
  first_kept_entry_id = entry_id(branch_entries[first_kept_index], first_kept_index)
465
359
  summarized_for_file_ops = cut.messages_to_summarize + cut.turn_prefix_messages
466
360
  file_ops = @file_operation_tracker.call(summarized_for_file_ops, previous_details: compaction_details(previous_entry))
467
- kept_messages = Array(cut.preserved_messages) + (branch_entries[cut.first_kept_index..] || [])
361
+ preserved_skill_messages = activated_skill_messages(summarized_for_file_ops)
362
+ kept_messages = preserved_skill_messages + Array(cut.preserved_messages) + (branch_entries[cut.first_kept_index..] || [])
468
363
 
469
364
  PreparationResult.new(
470
365
  first_kept_entry_id: first_kept_entry_id,
@@ -485,10 +380,6 @@ module Kward
485
380
  Array(messages).reject { |message| message_role(message) == "system" }
486
381
  end
487
382
 
488
- def already_compacted?
489
- @conversation.respond_to?(:last_entry_compaction?) && @conversation.last_entry_compaction?
490
- end
491
-
492
383
  def latest_previous_compaction_index(entries)
493
384
  (0...entries.length).to_a.reverse.find { |index| compaction_entry?(entries[index]) }
494
385
  end
@@ -503,6 +394,41 @@ module Kward
503
394
  previous_index + 1
504
395
  end
505
396
 
397
+ def activated_skill_messages(messages)
398
+ Array(messages).each_with_index.flat_map do |message, index|
399
+ next [] unless read_skill_tool_message?(message)
400
+
401
+ tool_call_id = MessageAccess.tool_call_id(message).to_s
402
+ tool_call_id = "preserved_read_skill_#{index}" if tool_call_id.empty?
403
+ skill_name = skill_name_from_content(MessageAccess.content(message)) || "unknown"
404
+ [
405
+ {
406
+ "role" => "assistant",
407
+ "content" => nil,
408
+ "tool_calls" => [
409
+ {
410
+ "id" => tool_call_id,
411
+ "type" => "function",
412
+ "function" => {
413
+ "name" => "read_skill",
414
+ "arguments" => JSON.generate(name: skill_name)
415
+ }
416
+ }
417
+ ]
418
+ },
419
+ message
420
+ ]
421
+ end
422
+ end
423
+
424
+ def read_skill_tool_message?(message)
425
+ message_role(message) == "tool" && MessageAccess.tool_name(message) == "read_skill" && MessageAccess.content(message).to_s.include?("<skill_content")
426
+ end
427
+
428
+ def skill_name_from_content(content)
429
+ content.to_s[/<skill_content name="([^"]+)"/, 1]
430
+ end
431
+
506
432
  def compaction_entry?(message)
507
433
  message_role(message) == "compactionSummary"
508
434
  end