kward 0.67.0 → 0.68.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 +26 -0
- data/Gemfile.lock +2 -2
- data/README.md +5 -5
- data/doc/authentication.md +24 -1
- data/doc/configuration.md +9 -2
- data/doc/extensibility.md +1 -1
- data/doc/getting-started.md +4 -6
- data/doc/plugins.md +0 -2
- data/doc/releasing.md +7 -8
- data/doc/rpc.md +6 -6
- data/doc/usage.md +5 -2
- data/doc/web-search.md +2 -2
- data/kward.gemspec +4 -0
- data/lib/kward/agent.rb +29 -2
- data/lib/kward/ansi.rb +3 -0
- data/lib/kward/auth/anthropic_oauth.rb +291 -0
- data/lib/kward/auth/file.rb +2 -0
- data/lib/kward/auth/github_oauth.rb +3 -0
- data/lib/kward/auth/openai_oauth.rb +4 -0
- data/lib/kward/auth/openrouter_api_key.rb +2 -0
- data/lib/kward/cancellation.rb +3 -0
- data/lib/kward/cli/auth_commands.rb +82 -0
- data/lib/kward/cli/commands.rb +222 -0
- data/lib/kward/cli/compaction.rb +25 -0
- data/lib/kward/cli/doctor.rb +121 -0
- data/lib/kward/cli/interactive_turn.rb +225 -0
- data/lib/kward/cli/memory_commands.rb +133 -0
- data/lib/kward/cli/plugins.rb +112 -0
- data/lib/kward/cli/prompt_interface.rb +132 -0
- data/lib/kward/cli/rendering.rb +389 -0
- data/lib/kward/cli/runtime_helpers.rb +159 -0
- data/lib/kward/cli/sessions.rb +376 -0
- data/lib/kward/cli/settings.rb +663 -0
- data/lib/kward/cli/slash_commands.rb +112 -0
- data/lib/kward/cli/stats.rb +64 -0
- data/lib/kward/cli/tool_summaries.rb +153 -0
- data/lib/kward/cli.rb +38 -2790
- data/lib/kward/cli_transcript_formatter.rb +4 -7
- data/lib/kward/clipboard.rb +1 -0
- data/lib/kward/compaction/file_operation_tracker.rb +3 -0
- data/lib/kward/compactor.rb +29 -7
- data/lib/kward/config_files.rb +33 -24
- data/lib/kward/conversation.rb +70 -5
- data/lib/kward/events.rb +2 -0
- data/lib/kward/export_path.rb +2 -0
- data/lib/kward/image_attachments.rb +2 -0
- data/lib/kward/markdown_transcript.rb +2 -0
- data/lib/kward/memory/manager.rb +13 -0
- data/lib/kward/message_access.rb +23 -2
- data/lib/kward/message_text.rb +45 -0
- data/lib/kward/model/chat_invocation.rb +2 -0
- data/lib/kward/model/client.rb +295 -77
- data/lib/kward/model/context_overflow.rb +2 -0
- data/lib/kward/model/context_usage.rb +3 -0
- data/lib/kward/model/model_info.rb +143 -4
- data/lib/kward/model/payloads.rb +166 -13
- data/lib/kward/model/retry_message.rb +2 -0
- data/lib/kward/model/stream_parser.rb +129 -0
- data/lib/kward/pan/server.rb +3 -1
- data/lib/kward/plugin_registry.rb +12 -0
- data/lib/kward/private_file.rb +2 -0
- data/lib/kward/prompt_interface/banner.rb +3 -0
- data/lib/kward/prompt_interface/composer_controller.rb +262 -0
- data/lib/kward/prompt_interface/composer_renderer.rb +172 -0
- data/lib/kward/prompt_interface/composer_state.rb +221 -0
- data/lib/kward/prompt_interface/key_handler.rb +365 -0
- data/lib/kward/prompt_interface/layout.rb +31 -0
- data/lib/kward/prompt_interface/overlay_renderer.rb +111 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +91 -0
- data/lib/kward/prompt_interface/question_prompt.rb +328 -0
- data/lib/kward/prompt_interface/runtime_state.rb +59 -0
- data/lib/kward/prompt_interface/screen.rb +186 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +242 -0
- data/lib/kward/prompt_interface/slash_overlay.rb +102 -0
- data/lib/kward/prompt_interface/stream_state.rb +65 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +85 -0
- data/lib/kward/prompt_interface/transcript_renderer.rb +142 -0
- data/lib/kward/prompt_interface.rb +69 -1832
- data/lib/kward/prompts/commands.rb +2 -0
- data/lib/kward/prompts/templates.rb +3 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/question_contract.rb +66 -0
- data/lib/kward/resources/avatar_kward_logo.rb +2 -0
- data/lib/kward/resources/pixel_logo.rb +2 -0
- data/lib/kward/rpc/attachment_normalizer.rb +60 -0
- data/lib/kward/rpc/auth_manager.rb +65 -11
- data/lib/kward/rpc/config_manager.rb +11 -0
- data/lib/kward/rpc/prompt_bridge.rb +5 -26
- data/lib/kward/rpc/redactor.rb +3 -0
- data/lib/kward/rpc/runtime_payloads.rb +4 -1
- data/lib/kward/rpc/server.rb +37 -10
- data/lib/kward/rpc/session_manager.rb +123 -347
- data/lib/kward/rpc/session_metrics.rb +68 -0
- data/lib/kward/rpc/session_tree.rb +48 -0
- data/lib/kward/rpc/session_tree_rows.rb +208 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +3 -0
- data/lib/kward/rpc/tool_metadata.rb +3 -0
- data/lib/kward/rpc/transcript_normalizer.rb +3 -0
- data/lib/kward/rpc/transport.rb +3 -0
- data/lib/kward/session_diff.rb +2 -0
- data/lib/kward/session_store.rb +125 -31
- data/lib/kward/session_trash.rb +1 -0
- data/lib/kward/session_tree_renderer.rb +8 -41
- data/lib/kward/session_tree_tool_display.rb +56 -0
- data/lib/kward/skills/registry.rb +3 -0
- data/lib/kward/starter_pack_installer.rb +1 -0
- data/lib/kward/steering.rb +2 -0
- data/lib/kward/telemetry/logger.rb +3 -0
- data/lib/kward/telemetry/stats.rb +3 -0
- data/lib/kward/tools/ask_user_question.rb +20 -32
- data/lib/kward/tools/base.rb +8 -0
- data/lib/kward/tools/code_search.rb +5 -0
- data/lib/kward/tools/edit_file.rb +5 -0
- data/lib/kward/tools/list_directory.rb +5 -0
- data/lib/kward/tools/read_file.rb +5 -0
- data/lib/kward/tools/read_skill.rb +5 -0
- data/lib/kward/tools/registry.rb +33 -2
- data/lib/kward/tools/run_shell_command.rb +5 -0
- data/lib/kward/tools/search/code.rb +7 -0
- data/lib/kward/tools/search/web.rb +17 -14
- data/lib/kward/tools/tool_call.rb +25 -5
- data/lib/kward/tools/web_search.rb +7 -1
- data/lib/kward/tools/write_file.rb +5 -0
- data/lib/kward/transcript_export.rb +2 -0
- data/lib/kward/version.rb +2 -1
- data/lib/kward/workspace.rb +45 -5
- metadata +43 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# CLI slash-command helpers for manual context compaction.
|
|
6
|
+
module CompactionCommands
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def compact_context(agent, argument)
|
|
10
|
+
result = Compactor.new(
|
|
11
|
+
conversation: agent.conversation,
|
|
12
|
+
client: @client,
|
|
13
|
+
tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
|
|
14
|
+
).compact(custom_instructions: argument)
|
|
15
|
+
@prompt.say("\nCompacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.\n")
|
|
16
|
+
render_transcript_block("Assistant", result.summary)
|
|
17
|
+
rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e
|
|
18
|
+
@prompt.say("\n#{e.message}\n")
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
@prompt.say("\nCompaction error: #{e.message}\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Environment and configuration diagnostics for the `doctor` command.
|
|
6
|
+
module Doctor
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Writes the doctor output for the terminal CLI flow.
|
|
10
|
+
def print_doctor
|
|
11
|
+
lines = ["#{colored("Kward Doctor", :green, :bold)}", ""]
|
|
12
|
+
doctor_checks.each do |check|
|
|
13
|
+
lines << "#{doctor_mark(check.fetch(:status))} #{check.fetch(:label)}: #{check.fetch(:message)}"
|
|
14
|
+
end
|
|
15
|
+
@prompt.say lines.join("\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def doctor_checks
|
|
19
|
+
config = safely_read_config
|
|
20
|
+
[
|
|
21
|
+
doctor_config_check,
|
|
22
|
+
doctor_config_json_check(config),
|
|
23
|
+
doctor_directory_check("Config directory", ConfigFiles.config_dir),
|
|
24
|
+
doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
|
|
25
|
+
doctor_workspace_check,
|
|
26
|
+
doctor_model_check,
|
|
27
|
+
doctor_auth_check(config),
|
|
28
|
+
doctor_pan_check(config),
|
|
29
|
+
{ status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
|
|
30
|
+
]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def safely_read_config
|
|
34
|
+
ConfigFiles.read_config
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def doctor_config_check
|
|
40
|
+
path = ConfigFiles.config_path
|
|
41
|
+
if File.exist?(path)
|
|
42
|
+
readable = File.readable?(path)
|
|
43
|
+
return { status: readable ? :ok : :error, label: "Config", message: readable ? path : "not readable: #{path}" }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
{ status: :warning, label: "Config", message: "not found: #{path}" }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def doctor_config_json_check(config)
|
|
50
|
+
return { status: :error, label: "Config JSON", message: "invalid or unreadable" } unless config.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
{ status: :ok, label: "Config JSON", message: "valid" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def doctor_directory_check(label, path, create: false)
|
|
56
|
+
FileUtils.mkdir_p(path, mode: 0o700) if create
|
|
57
|
+
if Dir.exist?(path) && File.writable?(path)
|
|
58
|
+
{ status: :ok, label: label, message: "writable: #{path}" }
|
|
59
|
+
elsif Dir.exist?(path)
|
|
60
|
+
{ status: :error, label: label, message: "not writable: #{path}" }
|
|
61
|
+
else
|
|
62
|
+
{ status: :error, label: label, message: "missing: #{path}" }
|
|
63
|
+
end
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
{ status: :error, label: label, message: e.message }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def doctor_workspace_check
|
|
69
|
+
root = current_workspace_root
|
|
70
|
+
return { status: :ok, label: "Workspace", message: root } if Dir.exist?(root) && File.directory?(root)
|
|
71
|
+
|
|
72
|
+
{ status: :error, label: "Workspace", message: "not a directory: #{root}" }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def doctor_model_check
|
|
76
|
+
provider = @client.current_provider if @client.respond_to?(:current_provider)
|
|
77
|
+
model = @client.current_model if @client.respond_to?(:current_model)
|
|
78
|
+
parts = [provider, model].compact.map(&:to_s).reject(&:empty?)
|
|
79
|
+
return { status: :ok, label: "Model", message: parts.join(" / ") } if parts.any?
|
|
80
|
+
|
|
81
|
+
{ status: :warning, label: "Model", message: "not configured" }
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
{ status: :warning, label: "Model", message: e.message }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def doctor_auth_check(config)
|
|
87
|
+
openai_auth = OpenAIOAuth.default_auth_path
|
|
88
|
+
github_auth = GithubOAuth.default_auth_path
|
|
89
|
+
has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
|
|
90
|
+
paths = []
|
|
91
|
+
paths << "OpenAI OAuth" if File.exist?(openai_auth)
|
|
92
|
+
paths << "GitHub OAuth" if File.exist?(github_auth)
|
|
93
|
+
paths << "OpenRouter API key" if has_openrouter
|
|
94
|
+
return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
|
|
95
|
+
|
|
96
|
+
{ status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def doctor_pan_check(config)
|
|
100
|
+
pan = config.to_h["pan_mode"] || {}
|
|
101
|
+
if !pan["username"].to_s.empty? && !pan["password"].to_s.empty?
|
|
102
|
+
{ status: :ok, label: "Pan mode", message: "credentials configured" }
|
|
103
|
+
else
|
|
104
|
+
{ status: :warning, label: "Pan mode", message: "username/password not configured" }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def doctor_mark(status)
|
|
109
|
+
case status
|
|
110
|
+
when :ok
|
|
111
|
+
colored("✓", :green, :bold)
|
|
112
|
+
when :warning
|
|
113
|
+
colored("!", :yellow, :bold)
|
|
114
|
+
else
|
|
115
|
+
colored("✗", :red, :bold)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Interactive turn loop helpers for streaming, cancellation, and queued user input.
|
|
6
|
+
module InteractiveTurn
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def run_interactive_turn(agent, input, display_input: nil)
|
|
10
|
+
prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
|
|
11
|
+
print_user_transcript(input, display_input: display_input) if prompt_interface?
|
|
12
|
+
return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
|
|
13
|
+
|
|
14
|
+
queued_inputs = []
|
|
15
|
+
cancellation = Cancellation.new
|
|
16
|
+
cancelled = false
|
|
17
|
+
steering = steering_supported? ? Steering.new : nil
|
|
18
|
+
event_queue = Queue.new
|
|
19
|
+
stream_state = {
|
|
20
|
+
streamed: false,
|
|
21
|
+
last_flush: monotonic_now,
|
|
22
|
+
stream_block_open: false,
|
|
23
|
+
markdown_streams: {},
|
|
24
|
+
defer_assistant_streaming: defer_assistant_streaming?(agent)
|
|
25
|
+
}
|
|
26
|
+
markdown_chunks = []
|
|
27
|
+
answer = nil
|
|
28
|
+
error = nil
|
|
29
|
+
@prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
|
|
30
|
+
|
|
31
|
+
worker = Thread.new do
|
|
32
|
+
options = agent_display_options(display_input)
|
|
33
|
+
options[:cancellation] = cancellation
|
|
34
|
+
options[:steering] = steering if steering
|
|
35
|
+
answer = agent.ask(input, **options) do |event|
|
|
36
|
+
event_queue << event
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
error = e
|
|
40
|
+
end
|
|
41
|
+
worker.report_on_exception = false
|
|
42
|
+
|
|
43
|
+
while worker.alive?
|
|
44
|
+
begin
|
|
45
|
+
poll_result = collect_busy_input(queued_inputs, steering)
|
|
46
|
+
sleep 0.01
|
|
47
|
+
rescue Interrupt
|
|
48
|
+
poll_result = PromptInterface::CANCEL_INPUT
|
|
49
|
+
end
|
|
50
|
+
if poll_result == PromptInterface::CANCEL_INPUT && !cancelled
|
|
51
|
+
cancelled = true
|
|
52
|
+
cancellation.cancel!
|
|
53
|
+
worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
|
|
54
|
+
end
|
|
55
|
+
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
|
|
56
|
+
end
|
|
57
|
+
begin
|
|
58
|
+
worker.join
|
|
59
|
+
rescue Cancellation::CancelledError => e
|
|
60
|
+
error ||= e
|
|
61
|
+
end
|
|
62
|
+
drain_busy_input(queued_inputs, nil) unless cancelled
|
|
63
|
+
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
|
|
64
|
+
raise error if error && !error.is_a?(Cancellation::CancelledError)
|
|
65
|
+
|
|
66
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
|
|
67
|
+
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
|
|
68
|
+
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
|
|
69
|
+
queued_inputs
|
|
70
|
+
ensure
|
|
71
|
+
@prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
|
|
75
|
+
drained = 0
|
|
76
|
+
loop do
|
|
77
|
+
break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
|
|
78
|
+
|
|
79
|
+
event = event_queue.pop(true)
|
|
80
|
+
drained += 1
|
|
81
|
+
notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
|
|
82
|
+
handle_interactive_event(event, markdown_chunks, stream_state)
|
|
83
|
+
rescue ThreadError
|
|
84
|
+
break
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_interactive_event(event, markdown_chunks, stream_state)
|
|
91
|
+
case event
|
|
92
|
+
when Events::ReasoningDelta
|
|
93
|
+
stream_state[:streamed] = true
|
|
94
|
+
append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
|
|
95
|
+
when Events::AssistantDelta
|
|
96
|
+
stream_state[:streamed] = true
|
|
97
|
+
append_markdown_delta(markdown_chunks, "Assistant", event.delta)
|
|
98
|
+
when Events::SteeringApplied
|
|
99
|
+
@prompt.clear_steered_count if @prompt.respond_to?(:clear_steered_count)
|
|
100
|
+
when Events::Retry
|
|
101
|
+
stream_state[:streamed] = true
|
|
102
|
+
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
103
|
+
print_retry(event)
|
|
104
|
+
when Events::ToolCall
|
|
105
|
+
stream_state[:streamed] = true
|
|
106
|
+
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
107
|
+
print_tool_call(event.tool_call)
|
|
108
|
+
when Events::ToolResult
|
|
109
|
+
stream_state[:streamed] = true
|
|
110
|
+
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
111
|
+
update_session_diff(event.content, tool_call: event.tool_call)
|
|
112
|
+
print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
|
|
117
|
+
if force
|
|
118
|
+
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
return if markdown_chunks.empty?
|
|
122
|
+
return unless monotonic_now - stream_state[:last_flush] >= STREAM_RENDER_INTERVAL
|
|
123
|
+
|
|
124
|
+
chunks_to_flush = markdown_chunks
|
|
125
|
+
if stream_state[:defer_assistant_streaming]
|
|
126
|
+
chunks_to_flush, delayed_chunks = split_deferred_assistant_entries(markdown_chunks)
|
|
127
|
+
return if chunks_to_flush.empty?
|
|
128
|
+
|
|
129
|
+
markdown_chunks.replace(delayed_chunks)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
stream_state[:stream_block_open] = true if flush_markdown_deltas(chunks_to_flush, finish: false, streams: stream_state[:markdown_streams])
|
|
133
|
+
stream_state[:last_flush] = monotonic_now
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
137
|
+
wrote = flush_markdown_deltas(markdown_chunks, streams: stream_state[:markdown_streams])
|
|
138
|
+
finish_stream_block if stream_state[:stream_block_open] && !wrote
|
|
139
|
+
stream_state[:stream_block_open] = false
|
|
140
|
+
stream_state[:last_flush] = monotonic_now
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def split_deferred_assistant_entries(markdown_chunks)
|
|
144
|
+
markdown_chunks.partition { |label, _content| label != "Assistant" }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def monotonic_now
|
|
148
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def collect_queued_input(queued_inputs)
|
|
152
|
+
collect_busy_input(queued_inputs, nil)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def collect_busy_input(queued_inputs, steering)
|
|
156
|
+
return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
|
|
157
|
+
|
|
158
|
+
poll_result = @prompt.poll_input
|
|
159
|
+
case poll_result
|
|
160
|
+
when String
|
|
161
|
+
if steering && !poll_result.strip.empty?
|
|
162
|
+
begin
|
|
163
|
+
steering.submit(poll_result)
|
|
164
|
+
@prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
|
|
165
|
+
rescue StandardError
|
|
166
|
+
queued_inputs << poll_result
|
|
167
|
+
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
168
|
+
end
|
|
169
|
+
else
|
|
170
|
+
queued_inputs << poll_result unless poll_result.strip.empty?
|
|
171
|
+
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
172
|
+
end
|
|
173
|
+
when PromptInterface::EXIT_INPUT
|
|
174
|
+
queued_inputs << "/exit"
|
|
175
|
+
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
176
|
+
end
|
|
177
|
+
poll_result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def drain_queued_input(queued_inputs)
|
|
181
|
+
drain_busy_input(queued_inputs, nil)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def drain_busy_input(queued_inputs, steering)
|
|
185
|
+
deadline = Time.now + 0.15
|
|
186
|
+
loop do
|
|
187
|
+
poll_result = collect_busy_input(queued_inputs, steering)
|
|
188
|
+
break if Time.now > deadline && poll_result.nil?
|
|
189
|
+
|
|
190
|
+
sleep 0.01
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def steering_supported?
|
|
195
|
+
@client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def defer_assistant_streaming?(agent)
|
|
199
|
+
return false unless agent.respond_to?(:conversation)
|
|
200
|
+
|
|
201
|
+
conversation = agent.conversation
|
|
202
|
+
model = conversation.respond_to?(:model) && conversation.model ? conversation.model : current_model_id
|
|
203
|
+
ModelInfo.reasoning_supported?(current_model_provider, model)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def run_blocking_interactive_turn(agent, input, display_input: nil)
|
|
207
|
+
streamed = false
|
|
208
|
+
markdown_chunks = []
|
|
209
|
+
answer = agent.ask(input, **agent_display_options(display_input)) do |event|
|
|
210
|
+
streamed = true if render_blocking_turn_event(event, markdown_chunks, tool_line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
211
|
+
end
|
|
212
|
+
flush_markdown_deltas(markdown_chunks) if streamed
|
|
213
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
|
|
214
|
+
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
|
|
215
|
+
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
|
|
216
|
+
[]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def agent_display_options(display_input)
|
|
220
|
+
display_input.nil? ? {} : { display_input: display_input }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Interactive memory management commands mixed into the CLI frontend.
|
|
6
|
+
module MemoryCommands
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def memory_summarize_command?(argument)
|
|
10
|
+
subcommand, = argument.to_s.strip.split(/\s+/, 2)
|
|
11
|
+
["summarize", "learn"].include?(subcommand)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def handle_memory_command(argument, agent)
|
|
15
|
+
subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
|
|
16
|
+
manager = Memory::Manager.new
|
|
17
|
+
case subcommand
|
|
18
|
+
when "enable"
|
|
19
|
+
manager.enable
|
|
20
|
+
agent.conversation.refresh_system_message!
|
|
21
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory enabled.\n")
|
|
22
|
+
when "disable"
|
|
23
|
+
manager.disable
|
|
24
|
+
agent.conversation.memory_context = nil
|
|
25
|
+
agent.conversation.refresh_system_message!
|
|
26
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory disabled.\n")
|
|
27
|
+
when "auto-summary"
|
|
28
|
+
case rest.to_s.strip
|
|
29
|
+
when "enable", "on"
|
|
30
|
+
manager.auto_summary_enable
|
|
31
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary enabled.\n")
|
|
32
|
+
when "disable", "off"
|
|
33
|
+
manager.auto_summary_disable
|
|
34
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary disabled.\n")
|
|
35
|
+
else
|
|
36
|
+
@prompt.say("\nUsage: /memory auto-summary enable|disable\n")
|
|
37
|
+
end
|
|
38
|
+
when "core"
|
|
39
|
+
record = manager.add_core(unquote_argument(rest))
|
|
40
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added core memory #{record["id"]}.\n")
|
|
41
|
+
when "add"
|
|
42
|
+
record = manager.add_soft(unquote_argument(rest), scope: "workspace:#{agent.conversation.workspace_root}")
|
|
43
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added soft memory #{record["id"]}.\n")
|
|
44
|
+
when "list"
|
|
45
|
+
@prompt.say("\n#{format_memory_list(manager.hierarchy(workspace_root: agent.conversation.workspace_root))}\n")
|
|
46
|
+
when "forget"
|
|
47
|
+
forgotten = manager.forget_memory(rest.to_s.strip)
|
|
48
|
+
@prompt.say("\n#{forgotten ? "Forgot #{rest.to_s.strip}." : "No memory found for #{rest.to_s.strip}."}\n")
|
|
49
|
+
when "promote"
|
|
50
|
+
record = manager.promote_memory(rest.to_s.strip)
|
|
51
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Promoted memory #{record["id"]}.\n")
|
|
52
|
+
when "relax"
|
|
53
|
+
record = manager.relax_core(rest.to_s.strip, workspace_root: agent.conversation.workspace_root)
|
|
54
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Relaxed memory #{record["id"]}.\n")
|
|
55
|
+
when "inspect"
|
|
56
|
+
@prompt.say("\n#{JSON.pretty_generate(manager.inspect_memory)}\n")
|
|
57
|
+
when "why"
|
|
58
|
+
explanation = agent.conversation.last_memory_retrieval || manager.explain_retrieval
|
|
59
|
+
@prompt.say("\n#{format_memory_why(explanation)}\n")
|
|
60
|
+
when "summarize", "learn"
|
|
61
|
+
records = summarize_memory(agent.conversation, manager: manager)
|
|
62
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Learned #{records.length} soft #{records.length == 1 ? "memory" : "memories"}.\n")
|
|
63
|
+
else
|
|
64
|
+
@prompt.say("\nUsage: /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize\n")
|
|
65
|
+
end
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
@prompt.say("\nMemory command failed: #{e.message}\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def summarize_memory(conversation, manager: Memory::Manager.new)
|
|
71
|
+
records = manager.summarize_conversation(conversation, client: @client)
|
|
72
|
+
@active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
|
|
73
|
+
records
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def unquote_argument(text)
|
|
77
|
+
value = text.to_s.strip
|
|
78
|
+
value = value[1...-1] if value.length >= 2 && ((value.start_with?("\"") && value.end_with?("\"")) || (value.start_with?("'") && value.end_with?("'")))
|
|
79
|
+
value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def format_memory_list(memories)
|
|
83
|
+
sections = [
|
|
84
|
+
["Global Core Memories:", Array(memories["global_core"])],
|
|
85
|
+
["Workspace Core Memories:", Array(memories["workspace_core"])],
|
|
86
|
+
["Workspace Soft Memories:", Array(memories["workspace_soft"])]
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
sections.flat_map do |heading, records|
|
|
90
|
+
lines = [heading]
|
|
91
|
+
records.each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
|
|
92
|
+
lines << "- none" if records.empty?
|
|
93
|
+
lines
|
|
94
|
+
end.join("\n")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def format_memory_why(explanation)
|
|
98
|
+
reasons = Array(explanation["reasons"])
|
|
99
|
+
return explanation["message"] || "No memories were retrieved." if reasons.empty?
|
|
100
|
+
|
|
101
|
+
(["Memory retrieval reasons:"] + reasons.map { |item| "- #{item["id"]} (#{item["layer"]}, score #{item["score"]}): #{Array(item["reasons"]).join("; ")}" }).join("\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def prepare_memory_context(conversation, input)
|
|
105
|
+
manager = Memory::Manager.new
|
|
106
|
+
retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
|
|
107
|
+
conversation.last_memory_retrieval = retrieval
|
|
108
|
+
conversation.memory_context = manager.memory_block(retrieval)
|
|
109
|
+
conversation.refresh_system_message!
|
|
110
|
+
rescue StandardError => e
|
|
111
|
+
warn "Memory retrieval failed: #{e.message}"
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def persist_memory_state(conversation)
|
|
116
|
+
@active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
|
|
117
|
+
rescue StandardError
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def auto_summarize_memory(conversation)
|
|
122
|
+
manager = Memory::Manager.new
|
|
123
|
+
return unless manager.enabled? && manager.auto_summary_enabled?
|
|
124
|
+
|
|
125
|
+
summarize_memory(conversation, manager: manager)
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
warn "Memory auto-summary failed: #{e.message}"
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
|
|
4
|
+
class CLI
|
|
5
|
+
# Plugin command loading and execution helpers mixed into the CLI frontend.
|
|
6
|
+
module Plugins
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def prompt_templates
|
|
10
|
+
@prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def plugin_registry
|
|
14
|
+
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_slash_command_names)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def plugin_commands
|
|
18
|
+
plugin_registry.commands
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def plugin_command_for(command)
|
|
22
|
+
plugin_registry.command_for(command)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reload_plugins(conversation)
|
|
26
|
+
@plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
|
|
27
|
+
conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
|
|
28
|
+
conversation.refresh_system_message! if conversation.respond_to?(:refresh_system_message!)
|
|
29
|
+
@prompt.say("\nPlugins reloaded.\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reserved_slash_command_names
|
|
33
|
+
BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def slash_command_entries
|
|
37
|
+
prompt_entries = prompt_templates.map do |template|
|
|
38
|
+
{
|
|
39
|
+
name: template.command,
|
|
40
|
+
description: template.description,
|
|
41
|
+
argument_hint: template.argument_hint
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
plugin_entries = plugin_commands.map(&:entry)
|
|
45
|
+
BUILTIN_SLASH_COMMANDS + prompt_entries + plugin_entries
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def prompt_template_for(command)
|
|
49
|
+
prompt_templates.find { |template| template.command == command }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def expand_prompt_template(input)
|
|
53
|
+
PromptCommands.expand(input, templates: prompt_templates, reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def run_plugin_command(name, argument, agent)
|
|
57
|
+
command = plugin_command_for(name)
|
|
58
|
+
return [false, nil] unless command
|
|
59
|
+
|
|
60
|
+
agent.conversation.plugin_registry ||= plugin_registry if agent.conversation.respond_to?(:plugin_registry)
|
|
61
|
+
context = plugin_context(agent.conversation, argument)
|
|
62
|
+
command.handler.call(argument, context)
|
|
63
|
+
[true, nil]
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
@prompt.say("\nPlugin command /#{name} error: #{e.message}\n")
|
|
66
|
+
[true, nil]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def plugin_context(conversation, args)
|
|
70
|
+
PluginRegistry::Context.new(
|
|
71
|
+
conversation: conversation,
|
|
72
|
+
args: args,
|
|
73
|
+
session: @active_session,
|
|
74
|
+
workspace_root: conversation.workspace_root,
|
|
75
|
+
say_callback: lambda { |message| @prompt.say("\n#{message}\n") }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def selected_slash_command_input(input)
|
|
80
|
+
return nil if prompt_interface?
|
|
81
|
+
return nil unless @prompt.respond_to?(:select)
|
|
82
|
+
return nil unless input.match?(%r{\A/[^\s/]*\z})
|
|
83
|
+
return nil if prompt_template_for(input.delete_prefix("/"))
|
|
84
|
+
|
|
85
|
+
prefix = input.delete_prefix("/").downcase
|
|
86
|
+
return nil if slash_command_entries.any? { |entry| entry[:name].downcase == prefix }
|
|
87
|
+
|
|
88
|
+
matches = slash_command_entries.select { |entry| entry[:name].downcase.start_with?(prefix) }
|
|
89
|
+
return nil if matches.empty?
|
|
90
|
+
|
|
91
|
+
labels = matches.map { |entry| slash_command_label(entry) }
|
|
92
|
+
choice = @prompt.select("Slash command>", labels)
|
|
93
|
+
entry = matches[labels.index(choice)]
|
|
94
|
+
entry ? "/#{entry[:name]}" : nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def slash_command_label(entry)
|
|
98
|
+
hint = entry[:argument_hint].to_s.empty? ? "" : " #{entry[:argument_hint]}"
|
|
99
|
+
description = entry[:description].to_s.empty? ? "" : " - #{entry[:description]}"
|
|
100
|
+
"/#{entry[:name]}#{hint}#{description}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def notify_plugin_transcript_event(event, conversation)
|
|
104
|
+
return unless conversation
|
|
105
|
+
return if plugin_registry.transcript_event_handlers.empty?
|
|
106
|
+
|
|
107
|
+
plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|