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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/Gemfile.lock +2 -2
- data/README.md +4 -2
- data/Rakefile +18 -0
- data/doc/configuration.md +70 -4
- data/doc/extensibility.md +6 -19
- data/doc/getting-started.md +1 -1
- data/doc/lifecycle-hooks.md +424 -0
- data/doc/mcp.md +72 -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 -15
- data/doc/session-management.md +9 -9
- data/doc/skills.md +199 -0
- data/doc/usage.md +14 -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 +12 -2
- data/lib/kward/cli/compaction.rb +7 -0
- data/lib/kward/cli/doctor.rb +18 -10
- data/lib/kward/cli/git.rb +30 -3
- data/lib/kward/cli/hook_commands.rb +258 -0
- data/lib/kward/cli/interactive_turn.rb +9 -3
- 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 +32 -15
- data/lib/kward/cli.rb +64 -4
- 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 +152 -27
- 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 +56 -0
- data/lib/kward/mcp/server_config.rb +59 -0
- data/lib/kward/mcp/stdio_transport.rb +112 -0
- 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 +124 -4
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +4 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +10 -4
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +448 -20
- data/lib/kward/prompt_interface/editor/renderer.rb +27 -0
- data/lib/kward/prompt_interface/editor/search.rb +70 -13
- data/lib/kward/prompt_interface/editor/state.rb +72 -13
- data/lib/kward/prompt_interface/editor/vibe_state.rb +12 -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 -13
- 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 +122 -0
- data/lib/kward/tools/read_skill.rb +9 -4
- data/lib/kward/tools/registry.rb +297 -9
- 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 +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 "
|
|
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
|
|
@@ -488,18 +508,15 @@ module Kward
|
|
|
488
508
|
end
|
|
489
509
|
|
|
490
510
|
def handle_tab_busy_input(tab, input)
|
|
491
|
-
|
|
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
|
|
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:
|
|
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(
|
|
297
|
-
|
|
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
|