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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +56 -3
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/memory.md +1 -1
- data/doc/personas.md +2 -2
- data/doc/plugins.md +1 -1
- data/doc/releasing.md +7 -15
- data/doc/rpc.md +108 -16
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +7 -7
- data/lib/kward/agent.rb +72 -22
- data/lib/kward/auth/anthropic_oauth.rb +5 -87
- data/lib/kward/auth/oauth_helpers.rb +105 -0
- data/lib/kward/auth/openai_oauth.rb +4 -86
- data/lib/kward/cli/commands.rb +9 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/memory_commands.rb +1 -5
- data/lib/kward/cli/plugins.rb +41 -1
- data/lib/kward/cli/prompt_interface.rb +32 -3
- data/lib/kward/cli/rendering.rb +1 -1
- data/lib/kward/cli/runtime_helpers.rb +44 -5
- data/lib/kward/cli/sessions.rb +13 -0
- data/lib/kward/cli/settings.rb +226 -152
- data/lib/kward/cli/slash_commands.rb +81 -42
- data/lib/kward/cli/tabs.rb +26 -6
- data/lib/kward/cli.rb +26 -3
- data/lib/kward/cli_transcript_formatter.rb +0 -7
- data/lib/kward/compaction/token_estimator.rb +112 -0
- data/lib/kward/compactor.rb +42 -116
- data/lib/kward/config_files.rb +117 -25
- data/lib/kward/deep_copy.rb +42 -0
- data/lib/kward/diff_view_mode.rb +36 -0
- data/lib/kward/events.rb +1 -0
- data/lib/kward/export_path.rb +2 -6
- data/lib/kward/frontmatter.rb +43 -0
- data/lib/kward/hooks/audit_log.rb +121 -0
- data/lib/kward/hooks/catalog.rb +110 -0
- data/lib/kward/hooks/command_handler.rb +66 -0
- data/lib/kward/hooks/config_loader.rb +104 -0
- data/lib/kward/hooks/decision.rb +102 -0
- data/lib/kward/hooks/event.rb +60 -0
- data/lib/kward/hooks/http_handler.rb +72 -0
- data/lib/kward/hooks/manager.rb +158 -0
- data/lib/kward/hooks/matcher.rb +75 -0
- data/lib/kward/hooks.rb +16 -0
- data/lib/kward/markdown_transcript.rb +4 -3
- data/lib/kward/mcp/client.rb +1 -1
- data/lib/kward/mcp/server_config.rb +7 -3
- data/lib/kward/mcp/stdio_transport.rb +7 -1
- data/lib/kward/memory/turn_context.rb +19 -0
- data/lib/kward/message_access.rb +2 -2
- data/lib/kward/message_text.rb +19 -0
- data/lib/kward/model/client.rb +13 -45
- data/lib/kward/model/context_usage.rb +10 -4
- data/lib/kward/model/copilot_models.rb +44 -0
- data/lib/kward/model/model_info.rb +24 -0
- data/lib/kward/model/payloads.rb +15 -17
- data/lib/kward/model/stream_parser.rb +178 -13
- data/lib/kward/pan/server.rb +51 -2
- data/lib/kward/path_guard.rb +13 -0
- data/lib/kward/plugin_registry.rb +86 -25
- data/lib/kward/prompt_interface/editor/controller.rb +60 -1
- data/lib/kward/prompt_interface/editor/renderer.rb +11 -0
- data/lib/kward/prompt_interface/editor/state.rb +5 -1
- data/lib/kward/prompt_interface/selection_prompt.rb +26 -6
- data/lib/kward/prompt_interface.rb +4 -1
- data/lib/kward/prompts/commands.rb +3 -3
- data/lib/kward/prompts.rb +7 -7
- data/lib/kward/rpc/mcp_status.rb +50 -0
- data/lib/kward/rpc/prompt_bridge.rb +38 -4
- data/lib/kward/rpc/runtime_payloads.rb +1 -1
- data/lib/kward/rpc/server.rb +94 -14
- data/lib/kward/rpc/session_manager.rb +258 -33
- data/lib/kward/rpc/session_metrics.rb +1 -1
- data/lib/kward/rpc/session_tree_rows.rb +13 -64
- data/lib/kward/rpc/tool_event_normalizer.rb +16 -2
- data/lib/kward/rpc/transcript_normalizer.rb +9 -10
- data/lib/kward/rpc/turn_context.rb +109 -0
- data/lib/kward/session_store.rb +6 -20
- data/lib/kward/session_tree_nodes.rb +98 -18
- data/lib/kward/session_tree_renderer.rb +6 -61
- data/lib/kward/skills/registry.rb +109 -17
- data/lib/kward/tools/mcp_tool.rb +4 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +266 -8
- data/lib/kward/tools/search/code.rb +4 -22
- data/lib/kward/tools/search/web.rb +28 -2
- data/lib/kward/update_check.rb +117 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/manager.rb +43 -3
- data/lib/kward/workspace.rb +3 -1
- data/templates/default/fulldoc/html/css/kward.css +22 -0
- data/templates/default/fulldoc/html/js/kward.js +2 -0
- data/templates/default/kward_navigation.rb +4 -2
- data/templates/default/layout/html/setup.rb +2 -0
- 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 "
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
512
|
+
[implementation_worker_job].compact + persisted_workers + live_workers
|
|
474
513
|
end
|
|
475
514
|
|
|
476
|
-
def implementation_worker_job
|
|
477
|
-
remember_implementation_worker
|
|
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?
|
|
524
|
+
"status" => implementation_agent? ? "active" : "idle",
|
|
486
525
|
"session_path" => path
|
|
487
526
|
}
|
|
488
527
|
end
|
|
489
528
|
|
|
490
|
-
def implementation_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
|
|
533
|
+
def remember_implementation_worker
|
|
495
534
|
return unless @active_session&.path
|
|
496
|
-
return unless implementation_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"
|
data/lib/kward/cli/tabs.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
334
|
-
|
|
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
|
data/lib/kward/compactor.rb
CHANGED
|
@@ -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
|
-
|
|
223
|
+
ToolCall.id(tool_call)
|
|
328
224
|
end
|
|
329
225
|
|
|
330
226
|
def tool_call_name(tool_call)
|
|
331
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|