kward 0.67.1 → 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 +20 -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 +36 -9
- data/lib/kward/rpc/session_manager.rb +121 -345
- 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 +114 -24
- 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
data/lib/kward/cli.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "cli_transcript_formatter"
|
|
|
13
13
|
require_relative "model/context_usage"
|
|
14
14
|
require_relative "events"
|
|
15
15
|
require_relative "export_path"
|
|
16
|
+
require_relative "auth/anthropic_oauth"
|
|
16
17
|
require_relative "auth/github_oauth"
|
|
17
18
|
require_relative "auth/openrouter_api_key"
|
|
18
19
|
require_relative "image_attachments"
|
|
@@ -35,7 +36,23 @@ require_relative "tools/tool_call"
|
|
|
35
36
|
require_relative "tools/registry"
|
|
36
37
|
require_relative "telemetry/stats"
|
|
37
38
|
require_relative "workspace"
|
|
38
|
-
|
|
39
|
+
require_relative "cli/commands"
|
|
40
|
+
require_relative "cli/auth_commands"
|
|
41
|
+
require_relative "cli/doctor"
|
|
42
|
+
require_relative "cli/stats"
|
|
43
|
+
require_relative "cli/runtime_helpers"
|
|
44
|
+
require_relative "cli/slash_commands"
|
|
45
|
+
require_relative "cli/memory_commands"
|
|
46
|
+
require_relative "cli/settings"
|
|
47
|
+
require_relative "cli/sessions"
|
|
48
|
+
require_relative "cli/compaction"
|
|
49
|
+
require_relative "cli/rendering"
|
|
50
|
+
require_relative "cli/prompt_interface"
|
|
51
|
+
require_relative "cli/plugins"
|
|
52
|
+
require_relative "cli/interactive_turn"
|
|
53
|
+
require_relative "cli/tool_summaries"
|
|
54
|
+
|
|
55
|
+
# Namespace for the Kward CLI agent runtime.
|
|
39
56
|
module Kward
|
|
40
57
|
# Command-line interface for interactive chat, one-shot prompts, login,
|
|
41
58
|
# telemetry export, Pan server mode, and the experimental JSON-RPC backend.
|
|
@@ -49,6 +66,22 @@ module Kward
|
|
|
49
66
|
BUILTIN_SLASH_COMMANDS = PromptCommands::BUILTIN_COMMANDS
|
|
50
67
|
BUILTIN_SLASH_COMMAND_NAMES = PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES
|
|
51
68
|
|
|
69
|
+
include CLI::Commands
|
|
70
|
+
include CLI::AuthCommands
|
|
71
|
+
include CLI::Doctor
|
|
72
|
+
include CLI::Stats
|
|
73
|
+
include CLI::RuntimeHelpers
|
|
74
|
+
include CLI::SlashCommands
|
|
75
|
+
include CLI::MemoryCommands
|
|
76
|
+
include CLI::Settings
|
|
77
|
+
include CLI::Sessions
|
|
78
|
+
include CLI::CompactionCommands
|
|
79
|
+
include CLI::Rendering
|
|
80
|
+
include CLI::PromptInterfaceSupport
|
|
81
|
+
include CLI::Plugins
|
|
82
|
+
include CLI::InteractiveTurn
|
|
83
|
+
include CLI::ToolSummaries
|
|
84
|
+
|
|
52
85
|
def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: Client.new, session_store: nil, context_usage: ContextUsage.new)
|
|
53
86
|
@argv = argv
|
|
54
87
|
@stdin = stdin
|
|
@@ -110,11 +143,6 @@ module Kward
|
|
|
110
143
|
return
|
|
111
144
|
end
|
|
112
145
|
|
|
113
|
-
if @argv == ["--install-starter-pack"]
|
|
114
|
-
install_starter_pack
|
|
115
|
-
return
|
|
116
|
-
end
|
|
117
|
-
|
|
118
146
|
if @argv.first == "auth"
|
|
119
147
|
handle_auth_command(@argv[1..] || [])
|
|
120
148
|
return
|
|
@@ -207,94 +235,18 @@ module Kward
|
|
|
207
235
|
conversation: conversation
|
|
208
236
|
)
|
|
209
237
|
answer = agent.ask(input) do |event|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
|
|
214
|
-
when Events::AssistantDelta
|
|
215
|
-
streamed = true
|
|
216
|
-
assistant_streamed = true
|
|
217
|
-
append_markdown_delta(markdown_chunks, "Assistant", event.delta)
|
|
218
|
-
when Events::Retry
|
|
219
|
-
streamed = true
|
|
220
|
-
flush_markdown_deltas(markdown_chunks)
|
|
221
|
-
print_retry(event)
|
|
222
|
-
when Events::ToolCall
|
|
223
|
-
streamed = true
|
|
224
|
-
flush_markdown_deltas(markdown_chunks)
|
|
225
|
-
print_tool_call(event.tool_call)
|
|
226
|
-
when Events::ToolResult
|
|
227
|
-
streamed = true
|
|
228
|
-
flush_markdown_deltas(markdown_chunks)
|
|
229
|
-
print_tool_result(event.tool_call, event.content)
|
|
230
|
-
end
|
|
238
|
+
result = render_blocking_turn_event(event, markdown_chunks)
|
|
239
|
+
streamed = true if result
|
|
240
|
+
assistant_streamed = true if result == :assistant_streamed
|
|
231
241
|
end
|
|
232
242
|
flush_markdown_deltas(markdown_chunks) if streamed
|
|
233
243
|
assistant_streamed ? "" : render_markdown_transcript(answer)
|
|
234
244
|
end
|
|
235
245
|
|
|
236
|
-
def handle_auth_command(arguments)
|
|
237
|
-
if help_option_arguments?(arguments)
|
|
238
|
-
print_command_help("auth")
|
|
239
|
-
return
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
case arguments
|
|
243
|
-
when ["status"]
|
|
244
|
-
print_auth_status
|
|
245
|
-
when ["logout"]
|
|
246
|
-
logout_auth
|
|
247
|
-
else
|
|
248
|
-
raise ArgumentError, command_usage("auth")
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def print_auth_status
|
|
253
|
-
config = safely_read_config.to_h
|
|
254
|
-
lines = ["#{colored("Auth Status", :green, :bold)}", ""]
|
|
255
|
-
lines << auth_status_line("OpenAI OAuth", File.exist?(OpenAIOAuth.default_auth_path), OpenAIOAuth.default_auth_path)
|
|
256
|
-
lines << auth_status_line("GitHub OAuth", File.exist?(GithubOAuth.default_auth_path), GithubOAuth.default_auth_path)
|
|
257
|
-
lines << auth_status_line("OpenRouter API key", !config["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?, ConfigFiles.config_path)
|
|
258
|
-
@prompt.say lines.join("\n")
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
def auth_status_line(label, configured, location)
|
|
262
|
-
status = configured ? :ok : :warning
|
|
263
|
-
message = configured ? "configured" : "not configured"
|
|
264
|
-
"#{doctor_mark(status)} #{label}: #{message} (#{location})"
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
def logout_auth
|
|
268
|
-
removed = []
|
|
269
|
-
[OpenAIOAuth.default_auth_path, GithubOAuth.default_auth_path].each do |path|
|
|
270
|
-
next unless File.exist?(path)
|
|
271
246
|
|
|
272
|
-
File.delete(path)
|
|
273
|
-
removed << path
|
|
274
|
-
end
|
|
275
|
-
removed << "OpenRouter API key" if OpenRouterAPIKey.new.logout
|
|
276
247
|
|
|
277
|
-
if removed.empty?
|
|
278
|
-
@prompt.say "No saved credentials found."
|
|
279
|
-
else
|
|
280
|
-
@prompt.say "Removed #{removed.length} saved credential#{removed.length == 1 ? "" : "s"}."
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
248
|
|
|
284
|
-
def login(provider: nil, oauth: nil)
|
|
285
|
-
provider = provider.to_s.downcase
|
|
286
|
-
if provider == "openrouter"
|
|
287
|
-
auth = oauth || OpenRouterAPIKey.new
|
|
288
|
-
path = auth.login(prompt: @prompt)
|
|
289
|
-
@prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
|
|
290
|
-
return
|
|
291
|
-
end
|
|
292
249
|
|
|
293
|
-
oauth ||= provider == "github" ? GithubOAuth.new : OpenAIOAuth.new
|
|
294
|
-
path = oauth.login(prompt: @prompt)
|
|
295
|
-
name = provider == "github" ? "GitHub" : "OpenAI"
|
|
296
|
-
@prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
|
|
297
|
-
end
|
|
298
250
|
|
|
299
251
|
def interactive_loop(agent: nil)
|
|
300
252
|
setup_interactive_prompt
|
|
@@ -303,7 +255,7 @@ module Kward
|
|
|
303
255
|
if session_store && agent.nil?
|
|
304
256
|
agent = resume_last_session(session_store) || build_new_session_agent(session_store)
|
|
305
257
|
elsif session_store
|
|
306
|
-
@active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
258
|
+
@active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
307
259
|
reset_session_diff
|
|
308
260
|
@active_session.attach(agent.conversation)
|
|
309
261
|
else
|
|
@@ -374,2709 +326,5 @@ module Kward
|
|
|
374
326
|
@stdin.read.strip
|
|
375
327
|
end
|
|
376
328
|
|
|
377
|
-
private
|
|
378
|
-
|
|
379
|
-
def help_command?
|
|
380
|
-
["help", "--help", "-h"].include?(@argv.first) && @argv.length <= 2
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
def version_command?
|
|
384
|
-
["version", "--version", "-v"].include?(@argv.first) && @argv.length == 1
|
|
385
|
-
end
|
|
386
|
-
|
|
387
|
-
def help_option_arguments?(arguments)
|
|
388
|
-
arguments.length == 1 && ["help", "--help", "-h"].include?(arguments.first)
|
|
389
|
-
end
|
|
390
|
-
|
|
391
|
-
def one_shot_prompt_argument
|
|
392
|
-
prompt = @argv.join(" ").strip
|
|
393
|
-
prompt.empty? ? nil : prompt
|
|
394
|
-
end
|
|
395
|
-
|
|
396
|
-
def print_command_help(command_name = nil)
|
|
397
|
-
if command_name.to_s.empty? || ["--help", "-h"].include?(command_name)
|
|
398
|
-
print_help
|
|
399
|
-
return
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
help = command_help[command_name]
|
|
403
|
-
raise ArgumentError, "Unknown command: #{command_name}" unless help
|
|
404
|
-
|
|
405
|
-
@prompt.say render_command_help(command_name, help)
|
|
406
|
-
end
|
|
407
|
-
|
|
408
|
-
def print_help
|
|
409
|
-
command = ->(text) { colored(text, :green, :bold) }
|
|
410
|
-
option = ->(text) { colored(text, :cyan) }
|
|
411
|
-
heading = ->(text) { colored(text, :blue, :bold) }
|
|
412
|
-
|
|
413
|
-
@prompt.say <<~HELP.rstrip
|
|
414
|
-
#{colored("Kward", :green, :bold)} - an extendable CLI coding agent
|
|
415
|
-
|
|
416
|
-
#{heading.call("Usage")}
|
|
417
|
-
#{command.call("kward")} Start an interactive chat
|
|
418
|
-
#{command.call("kward")} #{option.call('"Explain this project"')} Run a one-shot prompt
|
|
419
|
-
#{command.call("kward login")} Sign in or save provider credentials
|
|
420
|
-
#{command.call("kward auth status")} Show saved credential status
|
|
421
|
-
#{command.call("kward init")} Install starter prompts and AGENTS.md
|
|
422
|
-
#{command.call("kward doctor")} Check local Kward setup
|
|
423
|
-
#{command.call("kward pan")} Start Pan mode web UI
|
|
424
|
-
#{command.call("kward rpc")} Start the experimental JSON-RPC backend
|
|
425
|
-
|
|
426
|
-
#{heading.call("Commands")}
|
|
427
|
-
#{command.call("help")} Show this help
|
|
428
|
-
#{command.call("version")} Show the installed Kward version
|
|
429
|
-
#{command.call("login")} [openrouter|github] Sign in with OpenAI, OpenRouter, or GitHub
|
|
430
|
-
#{command.call("auth status|logout")} Show or clear saved credentials
|
|
431
|
-
#{command.call("init")} Install starter prompts and AGENTS.md
|
|
432
|
-
#{command.call("doctor")} Check local Kward setup
|
|
433
|
-
#{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
|
|
434
|
-
#{command.call("pan")} Start Pan mode web UI
|
|
435
|
-
#{command.call("rpc")} Run the JSON-RPC backend for UI clients
|
|
436
|
-
|
|
437
|
-
#{heading.call("Options")}
|
|
438
|
-
#{option.call("--working-directory=PATH")} Run Kward from PATH
|
|
439
|
-
#{option.call("--help")}, #{option.call("-h")} Show this help
|
|
440
|
-
#{option.call("--version")}, #{option.call("-v")} Show the installed version
|
|
441
|
-
|
|
442
|
-
#{heading.call("Examples")}
|
|
443
|
-
#{command.call("kward")}
|
|
444
|
-
#{command.call("kward")} #{option.call('"Review this diff"')}
|
|
445
|
-
#{command.call("git diff | kward")} #{option.call('"Review this diff"')}
|
|
446
|
-
#{command.call("kward login openrouter")}
|
|
447
|
-
#{command.call("kward stats tokens today --bucket hour")}
|
|
448
|
-
|
|
449
|
-
Command names take precedence. Anything else is sent as a one-shot prompt.
|
|
450
|
-
HELP
|
|
451
|
-
end
|
|
452
|
-
|
|
453
|
-
def command_help
|
|
454
|
-
{
|
|
455
|
-
"help" => {
|
|
456
|
-
usage: "kward help [command]",
|
|
457
|
-
description: "Show the top-level command overview or help for one command.",
|
|
458
|
-
examples: ["kward help", "kward help pan"]
|
|
459
|
-
},
|
|
460
|
-
"version" => {
|
|
461
|
-
usage: "kward version",
|
|
462
|
-
description: "Show the installed Kward version.",
|
|
463
|
-
examples: ["kward version", "kward --version"]
|
|
464
|
-
},
|
|
465
|
-
"login" => {
|
|
466
|
-
usage: "kward login [openrouter|github]",
|
|
467
|
-
description: "Sign in with OpenAI, OpenRouter, or GitHub.",
|
|
468
|
-
examples: ["kward login", "kward login openrouter", "kward login github"]
|
|
469
|
-
},
|
|
470
|
-
"auth" => {
|
|
471
|
-
usage: "kward auth status|logout",
|
|
472
|
-
description: "Show or clear saved provider credentials without printing secrets.",
|
|
473
|
-
examples: ["kward auth status", "kward auth logout"]
|
|
474
|
-
},
|
|
475
|
-
"init" => {
|
|
476
|
-
usage: "kward init",
|
|
477
|
-
description: "Install starter prompts and base AGENTS.md into your config directory.",
|
|
478
|
-
examples: ["kward init"]
|
|
479
|
-
},
|
|
480
|
-
"doctor" => {
|
|
481
|
-
usage: "kward doctor",
|
|
482
|
-
description: "Check local Kward configuration, workspace, auth hints, and writable directories.",
|
|
483
|
-
examples: ["kward doctor", "kward --working-directory ~/code/project doctor"]
|
|
484
|
-
},
|
|
485
|
-
"stats" => {
|
|
486
|
-
usage: "kward stats tokens [range] [--bucket second|minute|hour|day|week|month|year] [--output path]",
|
|
487
|
-
description: "Export local token telemetry as CSV.",
|
|
488
|
-
examples: ["kward stats tokens today", "kward stats tokens today --bucket hour", "kward stats tokens week --output tokens.csv"]
|
|
489
|
-
},
|
|
490
|
-
"pan" => {
|
|
491
|
-
usage: "kward pan",
|
|
492
|
-
description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
|
|
493
|
-
examples: ["kward pan", "kward --working-directory ~/code/project pan"]
|
|
494
|
-
},
|
|
495
|
-
"rpc" => {
|
|
496
|
-
usage: "kward rpc",
|
|
497
|
-
description: "Start the experimental JSON-RPC backend for UI clients.",
|
|
498
|
-
examples: ["kward rpc", "kward --working-directory ~/code/project rpc"]
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
end
|
|
502
|
-
|
|
503
|
-
def render_command_help(name, help)
|
|
504
|
-
heading = ->(text) { colored(text, :blue, :bold) }
|
|
505
|
-
command = ->(text) { colored(text, :green, :bold) }
|
|
506
|
-
|
|
507
|
-
lines = [
|
|
508
|
-
"#{command.call(name)} - #{help.fetch(:description)}",
|
|
509
|
-
"",
|
|
510
|
-
heading.call("Usage"),
|
|
511
|
-
" #{command.call(help.fetch(:usage))}"
|
|
512
|
-
]
|
|
513
|
-
examples = help.fetch(:examples, [])
|
|
514
|
-
if examples.any?
|
|
515
|
-
lines << ""
|
|
516
|
-
lines << heading.call("Examples")
|
|
517
|
-
examples.each { |example| lines << " #{command.call(example)}" }
|
|
518
|
-
end
|
|
519
|
-
lines.join("\n")
|
|
520
|
-
end
|
|
521
|
-
|
|
522
|
-
def command_usage(name)
|
|
523
|
-
"Usage: #{command_help.fetch(name).fetch(:usage)}"
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
def print_version
|
|
527
|
-
@prompt.say "kward #{VERSION}"
|
|
528
|
-
end
|
|
529
|
-
|
|
530
|
-
def print_doctor
|
|
531
|
-
lines = ["#{colored("Kward Doctor", :green, :bold)}", ""]
|
|
532
|
-
doctor_checks.each do |check|
|
|
533
|
-
lines << "#{doctor_mark(check.fetch(:status))} #{check.fetch(:label)}: #{check.fetch(:message)}"
|
|
534
|
-
end
|
|
535
|
-
@prompt.say lines.join("\n")
|
|
536
|
-
end
|
|
537
|
-
|
|
538
|
-
def doctor_checks
|
|
539
|
-
config = safely_read_config
|
|
540
|
-
[
|
|
541
|
-
doctor_config_check,
|
|
542
|
-
doctor_config_json_check(config),
|
|
543
|
-
doctor_directory_check("Config directory", ConfigFiles.config_dir),
|
|
544
|
-
doctor_directory_check("Session directory", SessionStore.new(cwd: current_workspace_root).session_dir, create: true),
|
|
545
|
-
doctor_workspace_check,
|
|
546
|
-
doctor_model_check,
|
|
547
|
-
doctor_auth_check(config),
|
|
548
|
-
doctor_pan_check(config),
|
|
549
|
-
{ status: :ok, label: "Color", message: @color_enabled ? "enabled" : "disabled" }
|
|
550
|
-
]
|
|
551
|
-
end
|
|
552
|
-
|
|
553
|
-
def safely_read_config
|
|
554
|
-
ConfigFiles.read_config
|
|
555
|
-
rescue StandardError
|
|
556
|
-
nil
|
|
557
|
-
end
|
|
558
|
-
|
|
559
|
-
def doctor_config_check
|
|
560
|
-
path = ConfigFiles.config_path
|
|
561
|
-
if File.exist?(path)
|
|
562
|
-
readable = File.readable?(path)
|
|
563
|
-
return { status: readable ? :ok : :error, label: "Config", message: readable ? path : "not readable: #{path}" }
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
{ status: :warning, label: "Config", message: "not found: #{path}" }
|
|
567
|
-
end
|
|
568
|
-
|
|
569
|
-
def doctor_config_json_check(config)
|
|
570
|
-
return { status: :error, label: "Config JSON", message: "invalid or unreadable" } unless config.is_a?(Hash)
|
|
571
|
-
|
|
572
|
-
{ status: :ok, label: "Config JSON", message: "valid" }
|
|
573
|
-
end
|
|
574
|
-
|
|
575
|
-
def doctor_directory_check(label, path, create: false)
|
|
576
|
-
FileUtils.mkdir_p(path, mode: 0o700) if create
|
|
577
|
-
if Dir.exist?(path) && File.writable?(path)
|
|
578
|
-
{ status: :ok, label: label, message: "writable: #{path}" }
|
|
579
|
-
elsif Dir.exist?(path)
|
|
580
|
-
{ status: :error, label: label, message: "not writable: #{path}" }
|
|
581
|
-
else
|
|
582
|
-
{ status: :error, label: label, message: "missing: #{path}" }
|
|
583
|
-
end
|
|
584
|
-
rescue StandardError => e
|
|
585
|
-
{ status: :error, label: label, message: e.message }
|
|
586
|
-
end
|
|
587
|
-
|
|
588
|
-
def doctor_workspace_check
|
|
589
|
-
root = current_workspace_root
|
|
590
|
-
return { status: :ok, label: "Workspace", message: root } if Dir.exist?(root) && File.directory?(root)
|
|
591
|
-
|
|
592
|
-
{ status: :error, label: "Workspace", message: "not a directory: #{root}" }
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
def doctor_model_check
|
|
596
|
-
provider = @client.current_provider if @client.respond_to?(:current_provider)
|
|
597
|
-
model = @client.current_model if @client.respond_to?(:current_model)
|
|
598
|
-
parts = [provider, model].compact.map(&:to_s).reject(&:empty?)
|
|
599
|
-
return { status: :ok, label: "Model", message: parts.join(" / ") } if parts.any?
|
|
600
|
-
|
|
601
|
-
{ status: :warning, label: "Model", message: "not configured" }
|
|
602
|
-
rescue StandardError => e
|
|
603
|
-
{ status: :warning, label: "Model", message: e.message }
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
def doctor_auth_check(config)
|
|
607
|
-
openai_auth = OpenAIOAuth.default_auth_path
|
|
608
|
-
github_auth = GithubOAuth.default_auth_path
|
|
609
|
-
has_openrouter = !config.to_h["openrouter_api_key"].to_s.empty? || !ENV["OPENROUTER_API_KEY"].to_s.empty?
|
|
610
|
-
paths = []
|
|
611
|
-
paths << "OpenAI OAuth" if File.exist?(openai_auth)
|
|
612
|
-
paths << "GitHub OAuth" if File.exist?(github_auth)
|
|
613
|
-
paths << "OpenRouter API key" if has_openrouter
|
|
614
|
-
return { status: :ok, label: "Auth", message: paths.join(", ") } if paths.any?
|
|
615
|
-
|
|
616
|
-
{ status: :warning, label: "Auth", message: "no saved credentials found; run `kward login`" }
|
|
617
|
-
end
|
|
618
|
-
|
|
619
|
-
def doctor_pan_check(config)
|
|
620
|
-
pan = config.to_h["pan_mode"] || config.to_h["panMode"] || {}
|
|
621
|
-
if !pan["username"].to_s.empty? && !pan["password"].to_s.empty?
|
|
622
|
-
{ status: :ok, label: "Pan mode", message: "credentials configured" }
|
|
623
|
-
else
|
|
624
|
-
{ status: :warning, label: "Pan mode", message: "username/password not configured" }
|
|
625
|
-
end
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
def doctor_mark(status)
|
|
629
|
-
case status
|
|
630
|
-
when :ok
|
|
631
|
-
colored("✓", :green, :bold)
|
|
632
|
-
when :warning
|
|
633
|
-
colored("!", :yellow, :bold)
|
|
634
|
-
else
|
|
635
|
-
colored("✗", :red, :bold)
|
|
636
|
-
end
|
|
637
|
-
end
|
|
638
|
-
|
|
639
|
-
def install_starter_pack
|
|
640
|
-
result = StarterPackInstaller.install
|
|
641
|
-
installed_count = result.installed.length
|
|
642
|
-
skipped_count = result.skipped.length
|
|
643
|
-
@prompt.say("Installed #{installed_count} starter pack file#{installed_count == 1 ? "" : "s"}.")
|
|
644
|
-
@prompt.say("Skipped #{skipped_count} existing starter pack file#{skipped_count == 1 ? "" : "s"}.") if skipped_count.positive?
|
|
645
|
-
rescue StandardError => e
|
|
646
|
-
warn "Failed to install starter pack: #{e.message}"
|
|
647
|
-
exit 1
|
|
648
|
-
end
|
|
649
|
-
|
|
650
|
-
def pan_mode?
|
|
651
|
-
["pan", "--pan-mode"].include?(@argv.first)
|
|
652
|
-
end
|
|
653
|
-
|
|
654
|
-
def export_token_stats(arguments)
|
|
655
|
-
options = parse_token_stats_options(arguments)
|
|
656
|
-
csv = TelemetryStats.new.token_usage_csv(options[:range], bucket: options[:bucket])
|
|
657
|
-
if options[:output]
|
|
658
|
-
File.write(options[:output], csv)
|
|
659
|
-
else
|
|
660
|
-
$stdout.write(csv)
|
|
661
|
-
end
|
|
662
|
-
rescue ArgumentError => e
|
|
663
|
-
warn e.message
|
|
664
|
-
warn "Usage: kward stats tokens [range] [--bucket second|minute|hour|day|week|month|year] [--output path]"
|
|
665
|
-
exit 1
|
|
666
|
-
end
|
|
667
|
-
|
|
668
|
-
def parse_token_stats_options(arguments)
|
|
669
|
-
remaining = []
|
|
670
|
-
bucket = nil
|
|
671
|
-
output = nil
|
|
672
|
-
index = 0
|
|
673
|
-
while index < arguments.length
|
|
674
|
-
argument = arguments[index]
|
|
675
|
-
case argument
|
|
676
|
-
when "--bucket"
|
|
677
|
-
index += 1
|
|
678
|
-
raise ArgumentError, "Missing value for --bucket" if index >= arguments.length
|
|
679
|
-
|
|
680
|
-
bucket = arguments[index]
|
|
681
|
-
when /\A--bucket=(.+)\z/
|
|
682
|
-
bucket = Regexp.last_match(1)
|
|
683
|
-
when "--output"
|
|
684
|
-
index += 1
|
|
685
|
-
raise ArgumentError, "Missing value for --output" if index >= arguments.length
|
|
686
|
-
|
|
687
|
-
output = arguments[index]
|
|
688
|
-
when /\A--output=(.+)\z/
|
|
689
|
-
output = Regexp.last_match(1)
|
|
690
|
-
else
|
|
691
|
-
remaining << argument
|
|
692
|
-
end
|
|
693
|
-
index += 1
|
|
694
|
-
end
|
|
695
|
-
{ range: remaining.join(" "), bucket: bucket, output: output }
|
|
696
|
-
end
|
|
697
|
-
|
|
698
|
-
def extract_global_options(arguments)
|
|
699
|
-
remaining = []
|
|
700
|
-
index = 0
|
|
701
|
-
while index < arguments.length
|
|
702
|
-
argument = arguments[index]
|
|
703
|
-
case argument
|
|
704
|
-
when "--"
|
|
705
|
-
@prompt_delimited = true
|
|
706
|
-
remaining.concat(arguments[(index + 1)..] || [])
|
|
707
|
-
break
|
|
708
|
-
when "--working-directory"
|
|
709
|
-
index += 1
|
|
710
|
-
raise ArgumentError, "Missing value for --working-directory" if index >= arguments.length
|
|
711
|
-
|
|
712
|
-
@working_directory = expanded_working_directory(arguments[index])
|
|
713
|
-
when /\A--working-directory=(.*)\z/
|
|
714
|
-
@working_directory = expanded_working_directory(Regexp.last_match(1))
|
|
715
|
-
else
|
|
716
|
-
remaining << argument
|
|
717
|
-
end
|
|
718
|
-
index += 1
|
|
719
|
-
end
|
|
720
|
-
remaining
|
|
721
|
-
end
|
|
722
|
-
|
|
723
|
-
def expanded_working_directory(path)
|
|
724
|
-
value = path.to_s.strip
|
|
725
|
-
raise ArgumentError, "Missing value for --working-directory" if value.empty?
|
|
726
|
-
|
|
727
|
-
expanded = File.expand_path(value)
|
|
728
|
-
raise ArgumentError, "Working directory does not exist: #{expanded}" unless Dir.exist?(expanded)
|
|
729
|
-
raise ArgumentError, "Working directory is not a directory: #{expanded}" unless File.directory?(expanded)
|
|
730
|
-
|
|
731
|
-
expanded
|
|
732
|
-
end
|
|
733
|
-
|
|
734
|
-
def with_working_directory
|
|
735
|
-
return yield unless @working_directory
|
|
736
|
-
|
|
737
|
-
Dir.chdir(@working_directory) { yield }
|
|
738
|
-
end
|
|
739
|
-
|
|
740
|
-
def interactive_session_store(agent)
|
|
741
|
-
return @session_store if @session_store
|
|
742
|
-
return nil if agent
|
|
743
|
-
|
|
744
|
-
SessionStore.new
|
|
745
|
-
end
|
|
746
|
-
|
|
747
|
-
def resume_last_session(session_store)
|
|
748
|
-
return nil unless session_auto_resume_enabled?
|
|
749
|
-
|
|
750
|
-
path = session_store.remembered_last_session_path if session_store.respond_to?(:remembered_last_session_path)
|
|
751
|
-
return nil if path.to_s.empty?
|
|
752
|
-
|
|
753
|
-
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
754
|
-
reset_session_diff(@active_session.path)
|
|
755
|
-
track_session(@active_session)
|
|
756
|
-
@resumed_last_session = true
|
|
757
|
-
build_interactive_agent(conversation)
|
|
758
|
-
rescue StandardError
|
|
759
|
-
nil
|
|
760
|
-
end
|
|
761
|
-
|
|
762
|
-
def render_resumed_last_session_transcript(conversation)
|
|
763
|
-
restore_prompt_transcript do
|
|
764
|
-
@prompt.say("\nResumed session: #{@active_session.path}\n")
|
|
765
|
-
render_conversation_transcript(conversation)
|
|
766
|
-
end
|
|
767
|
-
end
|
|
768
|
-
|
|
769
|
-
def remember_active_session(session_store)
|
|
770
|
-
return unless session_store&.respond_to?(:remember_last_session)
|
|
771
|
-
return unless @active_session&.path && File.file?(@active_session.path)
|
|
772
|
-
|
|
773
|
-
session_store.remember_last_session(@active_session)
|
|
774
|
-
end
|
|
775
|
-
|
|
776
|
-
def build_new_session_agent(session_store)
|
|
777
|
-
@active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
778
|
-
reset_session_diff
|
|
779
|
-
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
780
|
-
@active_session.attach(conversation)
|
|
781
|
-
build_interactive_agent(conversation)
|
|
782
|
-
end
|
|
783
|
-
|
|
784
|
-
def track_session(session)
|
|
785
|
-
@cleanup_sessions << session if session
|
|
786
|
-
session
|
|
787
|
-
end
|
|
788
|
-
|
|
789
|
-
def reset_session_diff(path = nil)
|
|
790
|
-
@session_diff = path ? SessionDiff.from_session_file(path) : SessionDiff.new
|
|
791
|
-
end
|
|
792
|
-
|
|
793
|
-
def update_session_diff(content, tool_call: nil)
|
|
794
|
-
return unless mutation_tool_call?(tool_call)
|
|
795
|
-
return unless @session_diff&.add_tool_result(content)
|
|
796
|
-
|
|
797
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
798
|
-
end
|
|
799
|
-
|
|
800
|
-
def mutation_tool_call?(tool_call)
|
|
801
|
-
["edit_file", "write_file", "edit", "write"].include?(ToolCall.name(tool_call).to_s)
|
|
802
|
-
end
|
|
803
|
-
|
|
804
|
-
def cleanup_unused_sessions
|
|
805
|
-
@cleanup_sessions.reverse_each do |session|
|
|
806
|
-
session.delete_if_unused if session.respond_to?(:delete_if_unused)
|
|
807
|
-
end
|
|
808
|
-
@cleanup_sessions.clear
|
|
809
|
-
end
|
|
810
|
-
|
|
811
|
-
def cleanup_replaced_session(previous_session)
|
|
812
|
-
return unless previous_session
|
|
813
|
-
return if @active_session && File.expand_path(previous_session.path) == File.expand_path(@active_session.path)
|
|
814
|
-
|
|
815
|
-
previous_session.delete_if_unused if previous_session.respond_to?(:delete_if_unused)
|
|
816
|
-
end
|
|
817
|
-
|
|
818
|
-
def new_conversation(workspace_root: current_workspace_root)
|
|
819
|
-
Conversation.new(workspace_root: workspace_root, model: current_model_id, reasoning_effort: current_reasoning_effort, plugin_registry: plugin_registry)
|
|
820
|
-
end
|
|
821
|
-
|
|
822
|
-
def update_assistant_prompt(conversation)
|
|
823
|
-
@assistant_prompt = assistant_prompt_label(conversation)
|
|
824
|
-
@prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
|
|
825
|
-
@assistant_prompt
|
|
826
|
-
end
|
|
827
|
-
|
|
828
|
-
def assistant_prompt_label(conversation)
|
|
829
|
-
label = ConfigFiles.active_persona_label(workspace_root: conversation.workspace_root, model: conversation.model)
|
|
830
|
-
"#{label || "Assistant"}>"
|
|
831
|
-
rescue StandardError
|
|
832
|
-
"Assistant>"
|
|
833
|
-
end
|
|
834
|
-
|
|
835
|
-
def assistant_prompt_name
|
|
836
|
-
assistant_output_prompt.delete_suffix(">")
|
|
837
|
-
end
|
|
838
|
-
|
|
839
|
-
def assistant_output_prompt
|
|
840
|
-
@assistant_prompt || "Assistant>"
|
|
841
|
-
end
|
|
842
|
-
|
|
843
|
-
def build_interactive_agent(conversation)
|
|
844
|
-
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
845
|
-
workspace = configured_workspace(root: conversation.workspace_root)
|
|
846
|
-
tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
|
|
847
|
-
@footer_conversation = conversation
|
|
848
|
-
@footer_tool_registry = tool_registry
|
|
849
|
-
Agent.new(
|
|
850
|
-
client: @client,
|
|
851
|
-
tool_registry: tool_registry,
|
|
852
|
-
conversation: conversation
|
|
853
|
-
)
|
|
854
|
-
end
|
|
855
|
-
|
|
856
|
-
def handle_interactive_shell_command(input, agent)
|
|
857
|
-
command = input.to_s.sub(/\A!\s*/, "")
|
|
858
|
-
if command.strip.empty?
|
|
859
|
-
@prompt.say("\nShell command is required after !\n")
|
|
860
|
-
return true
|
|
861
|
-
end
|
|
862
|
-
|
|
863
|
-
run_busy_local_command_and_requeue(activity: "running") do
|
|
864
|
-
result = configured_workspace(root: interactive_workspace_root(agent)).run_shell_command(command)
|
|
865
|
-
@prompt.say("\n#{colored("Shell>", :green, :bold)} #{command}\n#{result}\n")
|
|
866
|
-
end
|
|
867
|
-
true
|
|
868
|
-
end
|
|
869
|
-
|
|
870
|
-
def shell_command_input?(input)
|
|
871
|
-
input.to_s.start_with?("!")
|
|
872
|
-
end
|
|
873
|
-
|
|
874
|
-
def configured_workspace(root: current_workspace_root)
|
|
875
|
-
Workspace.new(root: root, guardrails: workspace_guardrails_enabled?)
|
|
876
|
-
end
|
|
877
|
-
|
|
878
|
-
def workspace_guardrails_enabled?
|
|
879
|
-
ConfigFiles.workspace_guardrails_enabled?(safely_read_config.to_h)
|
|
880
|
-
end
|
|
881
|
-
|
|
882
|
-
def interactive_workspace_root(agent)
|
|
883
|
-
conversation = agent.conversation if agent.respond_to?(:conversation)
|
|
884
|
-
return conversation.workspace_root if conversation&.respond_to?(:workspace_root)
|
|
885
|
-
|
|
886
|
-
current_workspace_root
|
|
887
|
-
end
|
|
888
|
-
|
|
889
|
-
def handle_local_slash_command(command, agent, session_store)
|
|
890
|
-
name, argument = parse_slash_command(command)
|
|
891
|
-
case name
|
|
892
|
-
when "status"
|
|
893
|
-
run_busy_local_command_and_requeue { print_status }
|
|
894
|
-
[true, nil]
|
|
895
|
-
when "stats"
|
|
896
|
-
run_busy_local_command_and_requeue { print_stats(argument) }
|
|
897
|
-
[true, nil]
|
|
898
|
-
when "memory"
|
|
899
|
-
activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
|
|
900
|
-
run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
|
|
901
|
-
[true, nil]
|
|
902
|
-
when "redraw"
|
|
903
|
-
run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
|
|
904
|
-
[true, nil]
|
|
905
|
-
when "settings"
|
|
906
|
-
configure_settings(agent.conversation)
|
|
907
|
-
[true, nil]
|
|
908
|
-
when "login"
|
|
909
|
-
login_interactively
|
|
910
|
-
[true, nil]
|
|
911
|
-
when "model"
|
|
912
|
-
models = run_busy_local_command_and_requeue { normalized_available_models }
|
|
913
|
-
configure_model(agent.conversation, models: models)
|
|
914
|
-
[true, nil]
|
|
915
|
-
when "openrouter/catalog"
|
|
916
|
-
run_busy_local_command_and_requeue { print_openrouter_catalog }
|
|
917
|
-
[true, nil]
|
|
918
|
-
when "reasoning"
|
|
919
|
-
configure_reasoning(agent.conversation)
|
|
920
|
-
[true, nil]
|
|
921
|
-
when "reload"
|
|
922
|
-
run_busy_local_command_and_requeue { reload_plugins(agent.conversation) }
|
|
923
|
-
[true, nil]
|
|
924
|
-
when "new"
|
|
925
|
-
[true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
|
|
926
|
-
when "resume"
|
|
927
|
-
[true, run_busy_local_command_and_requeue do
|
|
928
|
-
path = argument.to_s.strip
|
|
929
|
-
path = select_session_path(session_store) if session_store && path.empty?
|
|
930
|
-
resume_session(session_store, path)
|
|
931
|
-
end]
|
|
932
|
-
when "name"
|
|
933
|
-
run_busy_local_command_and_requeue { rename_session(argument) }
|
|
934
|
-
[true, nil]
|
|
935
|
-
when "clone"
|
|
936
|
-
[true, run_busy_local_command_and_requeue { clone_session(session_store, agent) }]
|
|
937
|
-
when "tree"
|
|
938
|
-
[true, run_busy_local_command_and_requeue { navigate_session_tree(session_store) }]
|
|
939
|
-
when "copy"
|
|
940
|
-
run_busy_local_command_and_requeue { copy_session_text(agent.conversation, argument) }
|
|
941
|
-
[true, nil]
|
|
942
|
-
when "export"
|
|
943
|
-
run_busy_local_command_and_requeue { export_session(agent.conversation, argument) }
|
|
944
|
-
[true, nil]
|
|
945
|
-
when "compact"
|
|
946
|
-
run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
|
|
947
|
-
[true, nil]
|
|
948
|
-
else
|
|
949
|
-
return run_plugin_command(name, argument, agent) if plugin_command_for(name)
|
|
950
|
-
|
|
951
|
-
[false, nil]
|
|
952
|
-
end
|
|
953
|
-
end
|
|
954
|
-
|
|
955
|
-
def parse_slash_command(command)
|
|
956
|
-
PromptCommands.parse(command) || [nil, ""]
|
|
957
|
-
end
|
|
958
|
-
|
|
959
|
-
def memory_summarize_command?(argument)
|
|
960
|
-
subcommand, = argument.to_s.strip.split(/\s+/, 2)
|
|
961
|
-
["summarize", "learn"].include?(subcommand)
|
|
962
|
-
end
|
|
963
|
-
|
|
964
|
-
def print_status
|
|
965
|
-
lines = [STATUS_MESSAGE]
|
|
966
|
-
lines << ""
|
|
967
|
-
lines << auto_compaction_status_line
|
|
968
|
-
if @active_session
|
|
969
|
-
lines << "Session: #{@active_session.name || @active_session.id}"
|
|
970
|
-
lines << "File: #{@active_session.path}"
|
|
971
|
-
end
|
|
972
|
-
lines.compact!
|
|
973
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{lines.join("\n")}\n")
|
|
974
|
-
end
|
|
975
|
-
|
|
976
|
-
def auto_compaction_status_line
|
|
977
|
-
settings = Kward::Compaction::Settings.from_config
|
|
978
|
-
return "Auto-compaction: disabled" unless settings.enabled
|
|
979
|
-
|
|
980
|
-
context_window = composer_context_window
|
|
981
|
-
return "Auto-compaction: enabled, unknown context window" unless context_window.to_i.positive?
|
|
982
|
-
|
|
983
|
-
reserve_tokens = Kward::Compactor.auto_compaction_reserve_tokens(
|
|
984
|
-
context_window: context_window,
|
|
985
|
-
configured_reserve_tokens: settings.reserve_tokens
|
|
986
|
-
)
|
|
987
|
-
percent = ((reserve_tokens.to_f / context_window.to_i) * 100).round(1)
|
|
988
|
-
"Auto-compaction reserve: #{reserve_tokens} tokens (#{percent}% of #{context_window})"
|
|
989
|
-
rescue StandardError => e
|
|
990
|
-
warn "Auto-compaction status unavailable: #{e.message}"
|
|
991
|
-
nil
|
|
992
|
-
end
|
|
993
|
-
|
|
994
|
-
def print_stats(argument)
|
|
995
|
-
result = TelemetryStats.new.collect(argument)
|
|
996
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{TelemetryStats.format(result)}\n")
|
|
997
|
-
rescue ArgumentError => e
|
|
998
|
-
message = e.message == TelemetryStats::USAGE ? e.message : "#{e.message}\n#{TelemetryStats::USAGE}"
|
|
999
|
-
@prompt.say("\n#{message}\n")
|
|
1000
|
-
end
|
|
1001
|
-
|
|
1002
|
-
def handle_memory_command(argument, agent)
|
|
1003
|
-
subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
|
|
1004
|
-
manager = Memory::Manager.new
|
|
1005
|
-
case subcommand
|
|
1006
|
-
when "enable"
|
|
1007
|
-
manager.enable
|
|
1008
|
-
agent.conversation.refresh_system_message!
|
|
1009
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory enabled.\n")
|
|
1010
|
-
when "disable"
|
|
1011
|
-
manager.disable
|
|
1012
|
-
agent.conversation.memory_context = nil
|
|
1013
|
-
agent.conversation.refresh_system_message!
|
|
1014
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory disabled.\n")
|
|
1015
|
-
when "auto-summary"
|
|
1016
|
-
case rest.to_s.strip
|
|
1017
|
-
when "enable", "on"
|
|
1018
|
-
manager.auto_summary_enable
|
|
1019
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary enabled.\n")
|
|
1020
|
-
when "disable", "off"
|
|
1021
|
-
manager.auto_summary_disable
|
|
1022
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary disabled.\n")
|
|
1023
|
-
else
|
|
1024
|
-
@prompt.say("\nUsage: /memory auto-summary enable|disable\n")
|
|
1025
|
-
end
|
|
1026
|
-
when "core"
|
|
1027
|
-
record = manager.add_core(unquote_argument(rest))
|
|
1028
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added core memory #{record["id"]}.\n")
|
|
1029
|
-
when "add"
|
|
1030
|
-
record = manager.add_soft(unquote_argument(rest), scope: "workspace:#{agent.conversation.workspace_root}")
|
|
1031
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added soft memory #{record["id"]}.\n")
|
|
1032
|
-
when "list"
|
|
1033
|
-
@prompt.say("\n#{format_memory_list(manager.hierarchy(workspace_root: agent.conversation.workspace_root))}\n")
|
|
1034
|
-
when "forget"
|
|
1035
|
-
forgotten = manager.forget_memory(rest.to_s.strip)
|
|
1036
|
-
@prompt.say("\n#{forgotten ? "Forgot #{rest.to_s.strip}." : "No memory found for #{rest.to_s.strip}."}\n")
|
|
1037
|
-
when "promote"
|
|
1038
|
-
record = manager.promote_memory(rest.to_s.strip)
|
|
1039
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Promoted memory #{record["id"]}.\n")
|
|
1040
|
-
when "relax"
|
|
1041
|
-
record = manager.relax_core(rest.to_s.strip, workspace_root: agent.conversation.workspace_root)
|
|
1042
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Relaxed memory #{record["id"]}.\n")
|
|
1043
|
-
when "inspect"
|
|
1044
|
-
@prompt.say("\n#{JSON.pretty_generate(manager.inspect_memory)}\n")
|
|
1045
|
-
when "why"
|
|
1046
|
-
explanation = agent.conversation.last_memory_retrieval || manager.explain_retrieval
|
|
1047
|
-
@prompt.say("\n#{format_memory_why(explanation)}\n")
|
|
1048
|
-
when "summarize", "learn"
|
|
1049
|
-
records = summarize_memory(agent.conversation, manager: manager)
|
|
1050
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Learned #{records.length} soft #{records.length == 1 ? "memory" : "memories"}.\n")
|
|
1051
|
-
else
|
|
1052
|
-
@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")
|
|
1053
|
-
end
|
|
1054
|
-
rescue StandardError => e
|
|
1055
|
-
@prompt.say("\nMemory command failed: #{e.message}\n")
|
|
1056
|
-
end
|
|
1057
|
-
|
|
1058
|
-
def summarize_memory(conversation, manager: Memory::Manager.new)
|
|
1059
|
-
records = manager.summarize_conversation(conversation, client: @client)
|
|
1060
|
-
@active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
|
|
1061
|
-
records
|
|
1062
|
-
end
|
|
1063
|
-
|
|
1064
|
-
def unquote_argument(text)
|
|
1065
|
-
value = text.to_s.strip
|
|
1066
|
-
value = value[1...-1] if value.length >= 2 && ((value.start_with?("\"") && value.end_with?("\"")) || (value.start_with?("'") && value.end_with?("'")))
|
|
1067
|
-
value
|
|
1068
|
-
end
|
|
1069
|
-
|
|
1070
|
-
def format_memory_list(memories)
|
|
1071
|
-
sections = [
|
|
1072
|
-
["Global Core Memories:", Array(memories["global_core"])],
|
|
1073
|
-
["Workspace Core Memories:", Array(memories["workspace_core"])],
|
|
1074
|
-
["Workspace Soft Memories:", Array(memories["workspace_soft"])]
|
|
1075
|
-
]
|
|
1076
|
-
|
|
1077
|
-
sections.flat_map do |heading, records|
|
|
1078
|
-
lines = [heading]
|
|
1079
|
-
records.each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
|
|
1080
|
-
lines << "- none" if records.empty?
|
|
1081
|
-
lines
|
|
1082
|
-
end.join("\n")
|
|
1083
|
-
end
|
|
1084
|
-
|
|
1085
|
-
def format_memory_why(explanation)
|
|
1086
|
-
reasons = Array(explanation["reasons"])
|
|
1087
|
-
return explanation["message"] || "No memories were retrieved." if reasons.empty?
|
|
1088
|
-
|
|
1089
|
-
(["Memory retrieval reasons:"] + reasons.map { |item| "- #{item["id"]} (#{item["layer"]}, score #{item["score"]}): #{Array(item["reasons"]).join("; ")}" }).join("\n")
|
|
1090
|
-
end
|
|
1091
|
-
|
|
1092
|
-
def run_busy_local_command(activity: "loading")
|
|
1093
|
-
return yield unless prompt_interface?
|
|
1094
|
-
|
|
1095
|
-
queued_inputs = []
|
|
1096
|
-
result = nil
|
|
1097
|
-
error = nil
|
|
1098
|
-
@prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
|
|
1099
|
-
|
|
1100
|
-
worker = Thread.new do
|
|
1101
|
-
result = yield
|
|
1102
|
-
rescue StandardError => e
|
|
1103
|
-
error = e
|
|
1104
|
-
end
|
|
1105
|
-
|
|
1106
|
-
while worker.alive?
|
|
1107
|
-
collect_queued_input(queued_inputs)
|
|
1108
|
-
sleep 0.02
|
|
1109
|
-
end
|
|
1110
|
-
worker.join
|
|
1111
|
-
drain_queued_input(queued_inputs)
|
|
1112
|
-
raise error if error
|
|
1113
|
-
|
|
1114
|
-
[result, queued_inputs]
|
|
1115
|
-
ensure
|
|
1116
|
-
@prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input)
|
|
1117
|
-
end
|
|
1118
|
-
|
|
1119
|
-
def run_busy_local_command_and_requeue(activity: "loading")
|
|
1120
|
-
return yield unless prompt_interface?
|
|
1121
|
-
|
|
1122
|
-
result, queued_inputs = run_busy_local_command(activity: activity) { yield }
|
|
1123
|
-
queued_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
|
|
1124
|
-
result
|
|
1125
|
-
end
|
|
1126
|
-
|
|
1127
|
-
def current_workspace_root
|
|
1128
|
-
return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
|
|
1129
|
-
return @working_directory if @working_directory
|
|
1130
|
-
|
|
1131
|
-
Dir.pwd
|
|
1132
|
-
end
|
|
1133
|
-
|
|
1134
|
-
def configure_settings(conversation = nil)
|
|
1135
|
-
unless settings_overlay_available?
|
|
1136
|
-
@prompt.say("\nSettings overlay is unavailable in this prompt.\n")
|
|
1137
|
-
return
|
|
1138
|
-
end
|
|
1139
|
-
|
|
1140
|
-
loop do
|
|
1141
|
-
selected = @prompt.select("Settings category", settings_category_choices, title: "Settings")
|
|
1142
|
-
category = selected_settings_category(selected)
|
|
1143
|
-
break unless category
|
|
1144
|
-
|
|
1145
|
-
break if category == "done"
|
|
1146
|
-
|
|
1147
|
-
handle_settings_category(category, conversation)
|
|
1148
|
-
end
|
|
1149
|
-
rescue StandardError => e
|
|
1150
|
-
@prompt.say("\nSettings error: #{e.message}\n")
|
|
1151
|
-
end
|
|
1152
|
-
|
|
1153
|
-
def settings_category_choices
|
|
1154
|
-
[
|
|
1155
|
-
"Model & Reasoning",
|
|
1156
|
-
"Accounts",
|
|
1157
|
-
"Memory",
|
|
1158
|
-
"Interface",
|
|
1159
|
-
"Tools & Search",
|
|
1160
|
-
"Context & Compaction",
|
|
1161
|
-
"Personalization",
|
|
1162
|
-
"Logging",
|
|
1163
|
-
"Advanced",
|
|
1164
|
-
"Done"
|
|
1165
|
-
]
|
|
1166
|
-
end
|
|
1167
|
-
|
|
1168
|
-
def selected_settings_category(selected)
|
|
1169
|
-
text = selected.to_s.downcase
|
|
1170
|
-
return nil if text.empty?
|
|
1171
|
-
return "done" if text.start_with?("done")
|
|
1172
|
-
return "model" if text.start_with?("model")
|
|
1173
|
-
return "accounts" if text.start_with?("accounts")
|
|
1174
|
-
return "memory" if text.start_with?("memory")
|
|
1175
|
-
return "interface" if text.start_with?("interface")
|
|
1176
|
-
return "tools" if text.start_with?("tools")
|
|
1177
|
-
return "context" if text.start_with?("context")
|
|
1178
|
-
return "personalization" if text.start_with?("personalization")
|
|
1179
|
-
return "logging" if text.start_with?("logging")
|
|
1180
|
-
return "advanced" if text.start_with?("advanced")
|
|
1181
|
-
|
|
1182
|
-
nil
|
|
1183
|
-
end
|
|
1184
|
-
|
|
1185
|
-
def handle_settings_category(category, conversation)
|
|
1186
|
-
case category
|
|
1187
|
-
when "model"
|
|
1188
|
-
configure_model_settings(conversation)
|
|
1189
|
-
when "accounts"
|
|
1190
|
-
configure_account_settings
|
|
1191
|
-
when "memory"
|
|
1192
|
-
configure_memory_settings(conversation)
|
|
1193
|
-
when "interface"
|
|
1194
|
-
configure_interface_settings
|
|
1195
|
-
when "tools"
|
|
1196
|
-
configure_tools_settings
|
|
1197
|
-
when "context"
|
|
1198
|
-
configure_context_settings
|
|
1199
|
-
when "personalization"
|
|
1200
|
-
configure_personalization_settings(conversation)
|
|
1201
|
-
when "logging"
|
|
1202
|
-
configure_logging_settings
|
|
1203
|
-
when "advanced"
|
|
1204
|
-
show_advanced_settings
|
|
1205
|
-
end
|
|
1206
|
-
end
|
|
1207
|
-
|
|
1208
|
-
def configure_model_settings(conversation)
|
|
1209
|
-
selected = @prompt.select("Model & Reasoning", ["Provider", "Default model", "Reasoning effort", "Back"], title: "Settings")
|
|
1210
|
-
case selected.to_s.downcase
|
|
1211
|
-
when /\Aprovider/
|
|
1212
|
-
configure_provider(conversation)
|
|
1213
|
-
when /\Adefault model/
|
|
1214
|
-
configure_model(conversation)
|
|
1215
|
-
when /\Areasoning effort/
|
|
1216
|
-
configure_reasoning(conversation)
|
|
1217
|
-
end
|
|
1218
|
-
end
|
|
1219
|
-
|
|
1220
|
-
def configure_provider(conversation)
|
|
1221
|
-
selected = @prompt.select("Provider", provider_choices, title: "Settings")
|
|
1222
|
-
provider = selected_provider(selected)
|
|
1223
|
-
return unless provider
|
|
1224
|
-
|
|
1225
|
-
ConfigFiles.update_config("provider" => ModelInfo.config_provider_for_provider(provider))
|
|
1226
|
-
reload_client_config
|
|
1227
|
-
refresh_conversation_runtime(conversation)
|
|
1228
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
1229
|
-
end
|
|
1230
|
-
|
|
1231
|
-
def provider_choices
|
|
1232
|
-
current = current_model_provider
|
|
1233
|
-
["Codex", "OpenRouter", "Copilot"].map do |provider|
|
|
1234
|
-
label = provider.dup
|
|
1235
|
-
label += " (current)" if provider == current
|
|
1236
|
-
label
|
|
1237
|
-
end
|
|
1238
|
-
end
|
|
1239
|
-
|
|
1240
|
-
def selected_provider(selected)
|
|
1241
|
-
text = selected.to_s.downcase
|
|
1242
|
-
return "Codex" if text.start_with?("codex")
|
|
1243
|
-
return "OpenRouter" if text.start_with?("openrouter")
|
|
1244
|
-
return "Copilot" if text.start_with?("copilot")
|
|
1245
|
-
|
|
1246
|
-
nil
|
|
1247
|
-
end
|
|
1248
|
-
|
|
1249
|
-
def configure_account_settings
|
|
1250
|
-
selected = @prompt.select("Accounts", account_setting_choices, title: "Settings")
|
|
1251
|
-
case selected.to_s.downcase
|
|
1252
|
-
when /\Aopenai/
|
|
1253
|
-
login(provider: "openai")
|
|
1254
|
-
reload_client_config
|
|
1255
|
-
when /\Agithub/
|
|
1256
|
-
login(provider: "github")
|
|
1257
|
-
reload_client_config
|
|
1258
|
-
when /\Aopenrouter/
|
|
1259
|
-
login(provider: "openrouter")
|
|
1260
|
-
reload_client_config
|
|
1261
|
-
when /\Astatus/
|
|
1262
|
-
print_auth_status
|
|
1263
|
-
end
|
|
1264
|
-
end
|
|
1265
|
-
|
|
1266
|
-
def account_setting_choices
|
|
1267
|
-
config = safely_read_config.to_h
|
|
1268
|
-
[
|
|
1269
|
-
"OpenAI login (#{File.exist?(OpenAIOAuth.default_auth_path) ? "configured" : "not configured"})",
|
|
1270
|
-
"GitHub login (#{File.exist?(GithubOAuth.default_auth_path) ? "configured" : "not configured"})",
|
|
1271
|
-
"OpenRouter API key (#{openrouter_key_status(config)})",
|
|
1272
|
-
"Status",
|
|
1273
|
-
"Back"
|
|
1274
|
-
]
|
|
1275
|
-
end
|
|
1276
|
-
|
|
1277
|
-
def openrouter_key_status(config)
|
|
1278
|
-
return "configured via environment" unless ENV["OPENROUTER_API_KEY"].to_s.empty?
|
|
1279
|
-
|
|
1280
|
-
config["openrouter_api_key"].to_s.empty? ? "not configured" : "configured"
|
|
1281
|
-
end
|
|
1282
|
-
|
|
1283
|
-
def configure_memory_settings(conversation)
|
|
1284
|
-
selected = @prompt.select("Memory", memory_setting_choices, title: "Settings")
|
|
1285
|
-
case selected.to_s.downcase
|
|
1286
|
-
when /\Aenable memory/, /\Adisable memory/
|
|
1287
|
-
set_memory_enabled(!memory_enabled?)
|
|
1288
|
-
conversation&.refresh_system_message!
|
|
1289
|
-
@prompt.say("\nMemory #{memory_enabled? ? "enabled" : "disabled"}.\n")
|
|
1290
|
-
when /\Aenable auto-summary/, /\Adisable auto-summary/
|
|
1291
|
-
set_memory_auto_summary_enabled(!memory_auto_summary_enabled?)
|
|
1292
|
-
@prompt.say("\nMemory auto-summary #{memory_auto_summary_enabled? ? "enabled" : "disabled"}.\n")
|
|
1293
|
-
when /\Amanage/
|
|
1294
|
-
@prompt.say("\nUse /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|relax <id>|inspect|why|summarize.\n")
|
|
1295
|
-
end
|
|
1296
|
-
end
|
|
1297
|
-
|
|
1298
|
-
def memory_setting_choices
|
|
1299
|
-
[
|
|
1300
|
-
"#{memory_enabled? ? "Disable" : "Enable"} memory (currently #{on_off(memory_enabled?)})",
|
|
1301
|
-
"#{memory_auto_summary_enabled? ? "Disable" : "Enable"} auto-summary (currently #{on_off(memory_auto_summary_enabled?)})",
|
|
1302
|
-
"Manage memories with /memory",
|
|
1303
|
-
"Back"
|
|
1304
|
-
]
|
|
1305
|
-
end
|
|
1306
|
-
|
|
1307
|
-
def memory_enabled?
|
|
1308
|
-
memory = safely_read_config.to_h["memory"]
|
|
1309
|
-
memory.is_a?(Hash) && memory["enabled"] == true
|
|
1310
|
-
end
|
|
1311
|
-
|
|
1312
|
-
def memory_auto_summary_enabled?
|
|
1313
|
-
memory = safely_read_config.to_h["memory"]
|
|
1314
|
-
memory.is_a?(Hash) && memory["auto_summary"] == true
|
|
1315
|
-
end
|
|
1316
|
-
|
|
1317
|
-
def set_memory_enabled(enabled)
|
|
1318
|
-
update_nested_config("memory", "enabled" => enabled)
|
|
1319
|
-
end
|
|
1320
|
-
|
|
1321
|
-
def set_memory_auto_summary_enabled(enabled)
|
|
1322
|
-
update_nested_config("memory", "auto_summary" => enabled)
|
|
1323
|
-
end
|
|
1324
|
-
|
|
1325
|
-
def configure_interface_settings
|
|
1326
|
-
selected = @prompt.select("Interface", interface_setting_choices, title: "Settings")
|
|
1327
|
-
case selected.to_s.downcase
|
|
1328
|
-
when /\Aoverlay alignment/
|
|
1329
|
-
settings = ConfigFiles.overlay_settings
|
|
1330
|
-
alignment = choose_overlay_setting("Overlay alignment", overlay_alignment_choices(settings), ConfigFiles::OVERLAY_ALIGNMENTS)
|
|
1331
|
-
return unless alignment
|
|
1332
|
-
|
|
1333
|
-
@prompt.update_overlay_settings(ConfigFiles.update_overlay_settings("alignment" => alignment))
|
|
1334
|
-
when /\Aoverlay width/
|
|
1335
|
-
settings = ConfigFiles.overlay_settings
|
|
1336
|
-
width = choose_overlay_setting("Overlay width", overlay_width_choices(settings), ConfigFiles::OVERLAY_WIDTHS)
|
|
1337
|
-
return unless width
|
|
1338
|
-
|
|
1339
|
-
@prompt.update_overlay_settings(ConfigFiles.update_overlay_settings("width" => width))
|
|
1340
|
-
when /\Ashow busy help/, /\Ahide busy help/
|
|
1341
|
-
set_composer_busy_help(!composer_busy_help?)
|
|
1342
|
-
@prompt.say("\nBusy help #{composer_busy_help? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.\n")
|
|
1343
|
-
when /\Ashow startup banner/, /\Ahide startup banner/
|
|
1344
|
-
set_banner_enabled(!banner_enabled?)
|
|
1345
|
-
@prompt.say("\nStartup banner #{banner_enabled? ? "enabled" : "disabled"}. Restart the TUI to apply this setting.\n")
|
|
1346
|
-
when /\Aenable session auto-resume/, /\Adisable session auto-resume/
|
|
1347
|
-
set_session_auto_resume_enabled(!session_auto_resume_enabled?)
|
|
1348
|
-
@prompt.say("\nSession auto-resume #{session_auto_resume_enabled? ? "enabled" : "disabled"}.\n")
|
|
1349
|
-
end
|
|
1350
|
-
end
|
|
1351
|
-
|
|
1352
|
-
def interface_setting_choices
|
|
1353
|
-
settings = ConfigFiles.overlay_settings
|
|
1354
|
-
[
|
|
1355
|
-
"Overlay alignment (#{settings["alignment"]})",
|
|
1356
|
-
"Overlay width (#{settings["width"]})",
|
|
1357
|
-
"#{composer_busy_help? ? "Hide" : "Show"} busy help (currently #{on_off(composer_busy_help?)})",
|
|
1358
|
-
"#{banner_enabled? ? "Hide" : "Show"} startup banner (currently #{on_off(banner_enabled?)})",
|
|
1359
|
-
"#{session_auto_resume_enabled? ? "Disable" : "Enable"} session auto-resume (currently #{on_off(session_auto_resume_enabled?)})",
|
|
1360
|
-
"Back"
|
|
1361
|
-
]
|
|
1362
|
-
end
|
|
1363
|
-
|
|
1364
|
-
def composer_busy_help?
|
|
1365
|
-
ConfigFiles.composer_busy_help?(safely_read_config.to_h)
|
|
1366
|
-
end
|
|
1367
|
-
|
|
1368
|
-
def banner_enabled?
|
|
1369
|
-
ConfigFiles.banner_enabled?(safely_read_config.to_h)
|
|
1370
|
-
end
|
|
1371
|
-
|
|
1372
|
-
def session_auto_resume_enabled?
|
|
1373
|
-
ConfigFiles.session_auto_resume_enabled?(safely_read_config.to_h)
|
|
1374
|
-
end
|
|
1375
|
-
|
|
1376
|
-
def set_composer_busy_help(enabled)
|
|
1377
|
-
update_nested_config("composer", "busy_help" => enabled)
|
|
1378
|
-
end
|
|
1379
|
-
|
|
1380
|
-
def set_banner_enabled(enabled)
|
|
1381
|
-
update_nested_config("banner", "enabled" => enabled)
|
|
1382
|
-
end
|
|
1383
|
-
|
|
1384
|
-
def set_session_auto_resume_enabled(enabled)
|
|
1385
|
-
update_nested_config("sessions", "auto_resume" => enabled)
|
|
1386
|
-
end
|
|
1387
|
-
|
|
1388
|
-
def configure_tools_settings
|
|
1389
|
-
selected = @prompt.select("Tools & Search", tools_setting_choices, title: "Settings")
|
|
1390
|
-
case selected.to_s.downcase
|
|
1391
|
-
when /\Aenable web search/, /\Adisable web search/
|
|
1392
|
-
set_web_search_enabled(!web_search_enabled?)
|
|
1393
|
-
@prompt.say("\nWeb search #{web_search_enabled? ? "enabled" : "disabled"}.\n")
|
|
1394
|
-
when /\Aweb search provider/
|
|
1395
|
-
configure_web_search_provider
|
|
1396
|
-
when /\Aallow model-provider/, /\Adisallow model-provider/
|
|
1397
|
-
set_web_search_allow_model_providers(!web_search_allow_model_providers?)
|
|
1398
|
-
@prompt.say("\nModel-provider web search #{web_search_allow_model_providers? ? "enabled" : "disabled"}.\n")
|
|
1399
|
-
when /\Aenable workspace guardrails/, /\Adisable workspace guardrails/
|
|
1400
|
-
set_workspace_guardrails_enabled(!workspace_guardrails_enabled?)
|
|
1401
|
-
@prompt.say("\nWorkspace guardrails #{workspace_guardrails_enabled? ? "enabled" : "disabled"}.\n")
|
|
1402
|
-
end
|
|
1403
|
-
end
|
|
1404
|
-
|
|
1405
|
-
def tools_setting_choices
|
|
1406
|
-
[
|
|
1407
|
-
"#{web_search_enabled? ? "Disable" : "Enable"} web search (currently #{on_off(web_search_enabled?)})",
|
|
1408
|
-
"Web search provider (#{web_search_provider})",
|
|
1409
|
-
"#{web_search_allow_model_providers? ? "Disallow" : "Allow"} model-provider web search (currently #{on_off(web_search_allow_model_providers?)})",
|
|
1410
|
-
"#{workspace_guardrails_enabled? ? "Disable" : "Enable"} workspace guardrails (currently #{on_off(workspace_guardrails_enabled?)})",
|
|
1411
|
-
"Back"
|
|
1412
|
-
]
|
|
1413
|
-
end
|
|
1414
|
-
|
|
1415
|
-
def configure_web_search_provider
|
|
1416
|
-
providers = %w[auto exa perplexity gemini legacy]
|
|
1417
|
-
selected = @prompt.select("Web search provider", providers.map { |provider| provider == web_search_provider ? "#{provider} (current)" : provider }, title: "Settings")
|
|
1418
|
-
provider = providers.find { |value| selected.to_s.downcase.start_with?(value) }
|
|
1419
|
-
return unless provider
|
|
1420
|
-
|
|
1421
|
-
update_nested_config("web_search", "provider" => provider)
|
|
1422
|
-
end
|
|
1423
|
-
|
|
1424
|
-
def web_search_config
|
|
1425
|
-
config = safely_read_config.to_h
|
|
1426
|
-
value = config["web_search"] || config["webSearch"] || config["web_research"] || config["webResearch"]
|
|
1427
|
-
value.is_a?(Hash) ? value : {}
|
|
1428
|
-
end
|
|
1429
|
-
|
|
1430
|
-
def web_search_enabled?
|
|
1431
|
-
web_search_config["enabled"] != false
|
|
1432
|
-
end
|
|
1433
|
-
|
|
1434
|
-
def web_search_provider
|
|
1435
|
-
web_search_config["provider"].to_s.empty? ? "auto" : web_search_config["provider"].to_s
|
|
1436
|
-
end
|
|
1437
|
-
|
|
1438
|
-
def web_search_allow_model_providers?
|
|
1439
|
-
web_search_config["allow_model_providers"] == true
|
|
1440
|
-
end
|
|
1441
|
-
|
|
1442
|
-
def set_web_search_enabled(enabled)
|
|
1443
|
-
update_nested_config("web_search", "enabled" => enabled)
|
|
1444
|
-
end
|
|
1445
|
-
|
|
1446
|
-
def set_web_search_allow_model_providers(enabled)
|
|
1447
|
-
update_nested_config("web_search", "allow_model_providers" => enabled)
|
|
1448
|
-
end
|
|
1449
|
-
|
|
1450
|
-
def set_workspace_guardrails_enabled(enabled)
|
|
1451
|
-
update_nested_config("tools", "workspace_guardrails" => enabled)
|
|
1452
|
-
end
|
|
1453
|
-
|
|
1454
|
-
def configure_context_settings
|
|
1455
|
-
selected = @prompt.select("Context & Compaction", context_setting_choices, title: "Settings")
|
|
1456
|
-
case selected.to_s.downcase
|
|
1457
|
-
when /\Aenable auto-compaction/, /\Adisable auto-compaction/
|
|
1458
|
-
set_compaction_enabled(!compaction_enabled?)
|
|
1459
|
-
@prompt.say("\nAuto-compaction #{compaction_enabled? ? "enabled" : "disabled"}.\n")
|
|
1460
|
-
else
|
|
1461
|
-
@prompt.say("\n#{auto_compaction_status_line}\n") if selected.to_s.downcase.start_with?("status")
|
|
1462
|
-
end
|
|
1463
|
-
end
|
|
1464
|
-
|
|
1465
|
-
def context_setting_choices
|
|
1466
|
-
[
|
|
1467
|
-
"#{compaction_enabled? ? "Disable" : "Enable"} auto-compaction (currently #{on_off(compaction_enabled?)})",
|
|
1468
|
-
"Status",
|
|
1469
|
-
"Back"
|
|
1470
|
-
]
|
|
1471
|
-
end
|
|
1472
|
-
|
|
1473
|
-
def compaction_enabled?
|
|
1474
|
-
Kward::Compaction::Settings.from_config(safely_read_config.to_h).enabled
|
|
1475
|
-
end
|
|
1476
|
-
|
|
1477
|
-
def set_compaction_enabled(enabled)
|
|
1478
|
-
update_nested_config("compaction", "enabled" => enabled)
|
|
1479
|
-
end
|
|
1480
|
-
|
|
1481
|
-
def configure_personalization_settings(conversation)
|
|
1482
|
-
selected = @prompt.select("Personalization", personalization_setting_choices(conversation), title: "Settings")
|
|
1483
|
-
case selected.to_s.downcase
|
|
1484
|
-
when /\Adefault persona/
|
|
1485
|
-
configure_default_persona(conversation)
|
|
1486
|
-
when /\Aactive instructions/
|
|
1487
|
-
show_active_instructions_summary(conversation)
|
|
1488
|
-
end
|
|
1489
|
-
end
|
|
1490
|
-
|
|
1491
|
-
def personalization_setting_choices(conversation)
|
|
1492
|
-
[
|
|
1493
|
-
"Default persona (#{default_persona_label})",
|
|
1494
|
-
"Active instructions summary",
|
|
1495
|
-
"Back"
|
|
1496
|
-
]
|
|
1497
|
-
end
|
|
1498
|
-
|
|
1499
|
-
def default_persona_label
|
|
1500
|
-
personas = safely_read_config.to_h["personas"]
|
|
1501
|
-
value = personas.is_a?(Hash) ? personas["default"] : nil
|
|
1502
|
-
value.to_s.empty? ? "none" : value.to_s
|
|
1503
|
-
end
|
|
1504
|
-
|
|
1505
|
-
def configure_default_persona(conversation)
|
|
1506
|
-
config = safely_read_config.to_h
|
|
1507
|
-
personas = config["personas"].is_a?(Hash) ? config["personas"] : {}
|
|
1508
|
-
entries = ConfigFiles.crew_character_labels(personas)
|
|
1509
|
-
choices = entries.map { |key, label| key == personas["default"] ? "#{label} (#{key}, current)" : "#{label} (#{key})" }
|
|
1510
|
-
if choices.empty?
|
|
1511
|
-
@prompt.say("\nNo configured personas found. Edit #{ConfigFiles.config_path} to add personas.\n")
|
|
1512
|
-
return
|
|
1513
|
-
end
|
|
1514
|
-
|
|
1515
|
-
selected = @prompt.select("Default persona", choices, title: "Settings")
|
|
1516
|
-
key = entries.keys.find { |candidate| selected.to_s.include?("(#{candidate}") }
|
|
1517
|
-
return unless key
|
|
1518
|
-
|
|
1519
|
-
personas = personas.dup
|
|
1520
|
-
personas["default"] = key
|
|
1521
|
-
ConfigFiles.update_config("personas" => personas)
|
|
1522
|
-
conversation&.refresh_system_message!
|
|
1523
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
1524
|
-
end
|
|
1525
|
-
|
|
1526
|
-
def show_active_instructions_summary(conversation)
|
|
1527
|
-
label = ConfigFiles.active_persona_label(workspace_root: current_workspace_root, model: current_model_id, config: safely_read_config.to_h)
|
|
1528
|
-
lines = ["Active persona: #{label || "none"}"]
|
|
1529
|
-
lines << "Global AGENTS.md: #{ConfigFiles.agents_prompt ? "present" : "absent"}"
|
|
1530
|
-
lines << "Workspace AGENTS.md: #{ConfigFiles.workspace_agents_prompt(current_workspace_root) ? "present" : "absent"}"
|
|
1531
|
-
lines << "Messages: #{conversation.messages.length}" if conversation&.respond_to?(:messages)
|
|
1532
|
-
@prompt.say("\n#{lines.join("\n")}\n")
|
|
1533
|
-
end
|
|
1534
|
-
|
|
1535
|
-
def configure_logging_settings
|
|
1536
|
-
selected = @prompt.select("Logging", logging_setting_choices, title: "Settings")
|
|
1537
|
-
key = logging_key_for_choice(selected)
|
|
1538
|
-
return unless key
|
|
1539
|
-
|
|
1540
|
-
set_logging_value(key, !logging_enabled?(key))
|
|
1541
|
-
@prompt.say("\nLogging #{key.tr("_", " ")} #{logging_enabled?(key) ? "enabled" : "disabled"}.\n")
|
|
1542
|
-
end
|
|
1543
|
-
|
|
1544
|
-
def logging_setting_choices
|
|
1545
|
-
[
|
|
1546
|
-
"#{logging_enabled?("enabled") ? "Disable" : "Enable"} local logging (currently #{on_off(logging_enabled?("enabled"))})",
|
|
1547
|
-
"#{logging_enabled?("tokens") ? "Disable" : "Enable"} token logs (currently #{on_off(logging_enabled?("tokens"))})",
|
|
1548
|
-
"#{logging_enabled?("performance") ? "Disable" : "Enable"} performance logs (currently #{on_off(logging_enabled?("performance"))})",
|
|
1549
|
-
"#{logging_enabled?("tools") ? "Disable" : "Enable"} tool logs (currently #{on_off(logging_enabled?("tools"))})",
|
|
1550
|
-
"#{logging_enabled?("errors") ? "Disable" : "Enable"} error logs (currently #{on_off(logging_enabled?("errors"))})",
|
|
1551
|
-
"Back"
|
|
1552
|
-
]
|
|
1553
|
-
end
|
|
1554
|
-
|
|
1555
|
-
def logging_key_for_choice(selected)
|
|
1556
|
-
text = selected.to_s.downcase
|
|
1557
|
-
return "enabled" if text.include?("local logging")
|
|
1558
|
-
return "tokens" if text.include?("token logs")
|
|
1559
|
-
return "performance" if text.include?("performance logs")
|
|
1560
|
-
return "tools" if text.include?("tool logs")
|
|
1561
|
-
return "errors" if text.include?("error logs")
|
|
1562
|
-
|
|
1563
|
-
nil
|
|
1564
|
-
end
|
|
1565
|
-
|
|
1566
|
-
def logging_enabled?(key)
|
|
1567
|
-
logging = safely_read_config.to_h["logging"]
|
|
1568
|
-
logging.is_a?(Hash) && logging[key] == true
|
|
1569
|
-
end
|
|
1570
|
-
|
|
1571
|
-
def set_logging_value(key, value)
|
|
1572
|
-
update_nested_config("logging", key => value)
|
|
1573
|
-
end
|
|
1574
|
-
|
|
1575
|
-
def show_advanced_settings
|
|
1576
|
-
lines = [
|
|
1577
|
-
"Config path: #{ConfigFiles.config_path}",
|
|
1578
|
-
"Config directory: #{ConfigFiles.config_dir}",
|
|
1579
|
-
"Cache directory: #{ConfigFiles.cache_dir}",
|
|
1580
|
-
"Memory directory: #{ConfigFiles.memory_dir}",
|
|
1581
|
-
"Plugin directory: #{ConfigFiles.plugin_dir}",
|
|
1582
|
-
"Plugins: #{ConfigFiles.plugin_paths.length}",
|
|
1583
|
-
"Skills: #{ConfigFiles.skills.length}",
|
|
1584
|
-
"Prompt templates: #{ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES).length}"
|
|
1585
|
-
]
|
|
1586
|
-
@prompt.say("\n#{lines.join("\n")}\n")
|
|
1587
|
-
end
|
|
1588
|
-
|
|
1589
|
-
def update_nested_config(section, values)
|
|
1590
|
-
config = ConfigFiles.read_config
|
|
1591
|
-
current = config[section].is_a?(Hash) ? config[section].dup : {}
|
|
1592
|
-
config[section] = current.merge(values)
|
|
1593
|
-
ConfigFiles.write_config(config)
|
|
1594
|
-
config
|
|
1595
|
-
end
|
|
1596
|
-
|
|
1597
|
-
def on_off(value)
|
|
1598
|
-
value ? "on" : "off"
|
|
1599
|
-
end
|
|
1600
|
-
|
|
1601
|
-
def login_interactively
|
|
1602
|
-
unless login_picker_available?
|
|
1603
|
-
@prompt.say("\nLogin provider picker is unavailable in this prompt.\n")
|
|
1604
|
-
return
|
|
1605
|
-
end
|
|
1606
|
-
|
|
1607
|
-
selected = @prompt.select("OAuth provider", login_provider_choices, title: "Login")
|
|
1608
|
-
provider = selected_login_provider(selected)
|
|
1609
|
-
return unless provider
|
|
1610
|
-
|
|
1611
|
-
login(provider: provider)
|
|
1612
|
-
reload_client_config
|
|
1613
|
-
rescue StandardError => e
|
|
1614
|
-
@prompt.say("\nLogin error: #{e.message}\n")
|
|
1615
|
-
end
|
|
1616
|
-
|
|
1617
|
-
def configure_model(conversation = nil, models: nil)
|
|
1618
|
-
unless model_overlay_available?
|
|
1619
|
-
@prompt.say("\nModel overlay is unavailable in this prompt.\n")
|
|
1620
|
-
return
|
|
1621
|
-
end
|
|
1622
|
-
|
|
1623
|
-
models ||= normalized_available_models
|
|
1624
|
-
choices = model_choices(models)
|
|
1625
|
-
selected = @prompt.select("Default model", choices, title: "Models", custom: true)
|
|
1626
|
-
return unless selected
|
|
1627
|
-
|
|
1628
|
-
provider, model = selected_model(selected, models)
|
|
1629
|
-
raise "Model must be a non-empty string" if model.to_s.strip.empty?
|
|
1630
|
-
|
|
1631
|
-
ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
|
|
1632
|
-
reload_client_config
|
|
1633
|
-
refresh_conversation_runtime(conversation)
|
|
1634
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
1635
|
-
rescue StandardError => e
|
|
1636
|
-
@prompt.say("\nModel error: #{e.message}\n")
|
|
1637
|
-
end
|
|
1638
|
-
|
|
1639
|
-
def print_openrouter_catalog
|
|
1640
|
-
unless @client.respond_to?(:openrouter_catalog)
|
|
1641
|
-
@prompt.say("\nOpenRouter catalog is unavailable for this client.\n")
|
|
1642
|
-
return
|
|
1643
|
-
end
|
|
1644
|
-
|
|
1645
|
-
models = Array(@client.openrouter_catalog)
|
|
1646
|
-
if models.empty?
|
|
1647
|
-
@prompt.say("\nNo OpenRouter catalog models available.\n")
|
|
1648
|
-
else
|
|
1649
|
-
ids = models.map { |model| model[:id] || model["id"] || model }.map(&:to_s).reject(&:empty?)
|
|
1650
|
-
@prompt.say("\nOpenRouter catalog:\n#{ids.join("\n")}\n")
|
|
1651
|
-
end
|
|
1652
|
-
rescue StandardError => e
|
|
1653
|
-
@prompt.say("\nOpenRouter catalog error: #{e.message}\n")
|
|
1654
|
-
end
|
|
1655
|
-
|
|
1656
|
-
def configure_reasoning(conversation = nil)
|
|
1657
|
-
unless model_overlay_available?
|
|
1658
|
-
@prompt.say("\nReasoning overlay is unavailable in this prompt.\n")
|
|
1659
|
-
return
|
|
1660
|
-
end
|
|
1661
|
-
|
|
1662
|
-
choices = ModelInfo::REASONING_EFFORT_CHOICES
|
|
1663
|
-
selected = @prompt.select("Reasoning effort", reasoning_choices(choices), title: "Reasoning")
|
|
1664
|
-
return unless selected
|
|
1665
|
-
|
|
1666
|
-
effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
|
|
1667
|
-
raise "Reasoning effort must be low, medium, high, or extra high" unless effort
|
|
1668
|
-
|
|
1669
|
-
ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(current_model_provider) => effort)
|
|
1670
|
-
reload_client_config
|
|
1671
|
-
refresh_conversation_runtime(conversation)
|
|
1672
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
1673
|
-
rescue StandardError => e
|
|
1674
|
-
@prompt.say("\nReasoning error: #{e.message}\n")
|
|
1675
|
-
end
|
|
1676
|
-
|
|
1677
|
-
def login_picker_available?
|
|
1678
|
-
@prompt.respond_to?(:select)
|
|
1679
|
-
end
|
|
1680
|
-
|
|
1681
|
-
def login_provider_choices
|
|
1682
|
-
["OpenAI", "OpenRouter", "GitHub"]
|
|
1683
|
-
end
|
|
1684
|
-
|
|
1685
|
-
def selected_login_provider(selected)
|
|
1686
|
-
case selected.to_s.downcase
|
|
1687
|
-
when /\Aopenai\b/
|
|
1688
|
-
"openai"
|
|
1689
|
-
when /\Aopenrouter\b/
|
|
1690
|
-
"openrouter"
|
|
1691
|
-
when /\Agithub\b/
|
|
1692
|
-
"github"
|
|
1693
|
-
end
|
|
1694
|
-
end
|
|
1695
|
-
|
|
1696
|
-
def model_overlay_available?
|
|
1697
|
-
@prompt.respond_to?(:select)
|
|
1698
|
-
end
|
|
1699
|
-
|
|
1700
|
-
def settings_overlay_available?
|
|
1701
|
-
@prompt.respond_to?(:select) && @prompt.respond_to?(:update_overlay_settings)
|
|
1702
|
-
end
|
|
1703
|
-
|
|
1704
|
-
def choose_overlay_setting(message, choices, values)
|
|
1705
|
-
choice = @prompt.select(message, choices, title: "Settings")
|
|
1706
|
-
return nil unless choice
|
|
1707
|
-
|
|
1708
|
-
values.find { |value| choice.to_s.downcase.start_with?(value) }
|
|
1709
|
-
end
|
|
1710
|
-
|
|
1711
|
-
def normalized_available_models
|
|
1712
|
-
current_provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
1713
|
-
current_model = @client.respond_to?(:current_model) ? @client.current_model : nil
|
|
1714
|
-
current_reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
|
|
1715
|
-
models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
|
|
1716
|
-
models.map do |model|
|
|
1717
|
-
ModelInfo.normalize(
|
|
1718
|
-
model,
|
|
1719
|
-
current_provider: current_provider,
|
|
1720
|
-
current_model: current_model,
|
|
1721
|
-
current_reasoning_effort: current_reasoning
|
|
1722
|
-
)
|
|
1723
|
-
end
|
|
1724
|
-
end
|
|
1725
|
-
|
|
1726
|
-
def model_choices(models)
|
|
1727
|
-
choices = models.map do |model|
|
|
1728
|
-
label = "#{model[:provider]} #{model[:id]}"
|
|
1729
|
-
label += " (current)" if model[:current]
|
|
1730
|
-
label
|
|
1731
|
-
end
|
|
1732
|
-
choices.empty? ? ["#{current_model_provider} #{current_model_id} (current)"] : choices.uniq
|
|
1733
|
-
end
|
|
1734
|
-
|
|
1735
|
-
def selected_model(selected, models)
|
|
1736
|
-
text = selected.to_s.sub(/ \(current\)\z/, "").strip
|
|
1737
|
-
known = models.find { |model| "#{model[:provider]} #{model[:id]}" == text }
|
|
1738
|
-
return [known[:provider], known[:id]] if known
|
|
1739
|
-
|
|
1740
|
-
provider, model = text.split(/\s+/, 2)
|
|
1741
|
-
if ["Codex", "OpenRouter", "Copilot"].include?(provider) && !model.to_s.strip.empty?
|
|
1742
|
-
[provider, model.strip]
|
|
1743
|
-
else
|
|
1744
|
-
[current_model_provider, text]
|
|
1745
|
-
end
|
|
1746
|
-
end
|
|
1747
|
-
|
|
1748
|
-
def reasoning_choices(choices)
|
|
1749
|
-
current = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort.to_s : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
1750
|
-
choices.map do |effort, label|
|
|
1751
|
-
text = label.dup
|
|
1752
|
-
text += " (current)" if current == effort
|
|
1753
|
-
text
|
|
1754
|
-
end
|
|
1755
|
-
end
|
|
1756
|
-
|
|
1757
|
-
def current_model_provider
|
|
1758
|
-
@client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
1759
|
-
end
|
|
1760
|
-
|
|
1761
|
-
def current_model_id
|
|
1762
|
-
@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
1763
|
-
end
|
|
1764
|
-
|
|
1765
|
-
def current_reasoning_effort
|
|
1766
|
-
@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
1767
|
-
end
|
|
1768
|
-
|
|
1769
|
-
def reload_client_config
|
|
1770
|
-
@client.reload_config if @client.respond_to?(:reload_config)
|
|
1771
|
-
end
|
|
1772
|
-
|
|
1773
|
-
def refresh_conversation_runtime(conversation)
|
|
1774
|
-
return unless conversation&.respond_to?(:update_runtime_context!)
|
|
1775
|
-
|
|
1776
|
-
conversation.update_runtime_context!(model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
1777
|
-
@active_session.update_runtime(model: conversation.model, reasoning_effort: conversation.reasoning_effort) if @active_session&.respond_to?(:update_runtime)
|
|
1778
|
-
update_assistant_prompt(conversation)
|
|
1779
|
-
end
|
|
1780
|
-
|
|
1781
|
-
def overlay_alignment_choices(settings)
|
|
1782
|
-
ConfigFiles::OVERLAY_ALIGNMENTS.map do |alignment|
|
|
1783
|
-
label = alignment.capitalize
|
|
1784
|
-
label += " (current)" if settings["alignment"] == alignment
|
|
1785
|
-
label
|
|
1786
|
-
end
|
|
1787
|
-
end
|
|
1788
|
-
|
|
1789
|
-
def overlay_width_choices(settings)
|
|
1790
|
-
ConfigFiles::OVERLAY_WIDTHS.map do |width|
|
|
1791
|
-
label = width.capitalize
|
|
1792
|
-
label += " (current)" if settings["width"] == width
|
|
1793
|
-
label
|
|
1794
|
-
end
|
|
1795
|
-
end
|
|
1796
|
-
|
|
1797
|
-
def start_new_session(session_store)
|
|
1798
|
-
return say_sessions_unavailable unless session_store
|
|
1799
|
-
|
|
1800
|
-
previous_session = @active_session
|
|
1801
|
-
@active_session = track_session(session_store.create)
|
|
1802
|
-
reset_session_diff
|
|
1803
|
-
cleanup_replaced_session(previous_session)
|
|
1804
|
-
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
1805
|
-
@active_session.attach(conversation)
|
|
1806
|
-
update_assistant_prompt(conversation)
|
|
1807
|
-
clear_prompt_transcript
|
|
1808
|
-
print_visual_banner
|
|
1809
|
-
build_interactive_agent(conversation)
|
|
1810
|
-
end
|
|
1811
|
-
|
|
1812
|
-
def resume_session(session_store, argument)
|
|
1813
|
-
return say_sessions_unavailable unless session_store
|
|
1814
|
-
|
|
1815
|
-
path = argument.to_s.strip
|
|
1816
|
-
path = select_session_path(session_store) if path.empty?
|
|
1817
|
-
return nil if path.to_s.empty?
|
|
1818
|
-
|
|
1819
|
-
previous_session = @active_session
|
|
1820
|
-
@active_session, conversation = session_store.load(path, workspace: configured_workspace(root: session_store.cwd), model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
1821
|
-
reset_session_diff(@active_session.path)
|
|
1822
|
-
track_session(@active_session)
|
|
1823
|
-
cleanup_replaced_session(previous_session)
|
|
1824
|
-
update_assistant_prompt(conversation)
|
|
1825
|
-
restore_prompt_transcript do
|
|
1826
|
-
@prompt.say("\nResumed session: #{@active_session.path}\n")
|
|
1827
|
-
render_conversation_transcript(conversation)
|
|
1828
|
-
end
|
|
1829
|
-
agent = build_interactive_agent(conversation)
|
|
1830
|
-
@prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
|
|
1831
|
-
agent
|
|
1832
|
-
rescue StandardError => e
|
|
1833
|
-
@prompt.say("\nError: #{e.message}\n")
|
|
1834
|
-
nil
|
|
1835
|
-
end
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
def navigate_session_tree(session_store)
|
|
1839
|
-
return say_sessions_unavailable unless session_store
|
|
1840
|
-
unless @active_session
|
|
1841
|
-
@prompt.say("\nNo active persisted session.\n")
|
|
1842
|
-
return nil
|
|
1843
|
-
end
|
|
1844
|
-
|
|
1845
|
-
tree_items = session_tree_items(session_store)
|
|
1846
|
-
if tree_items.empty?
|
|
1847
|
-
@prompt.say("\nNo session tree entries found.\n")
|
|
1848
|
-
return nil
|
|
1849
|
-
end
|
|
1850
|
-
|
|
1851
|
-
labels_by_entry_id = tree_items.to_h { |item| [item[:entry]["id"].to_s, item[:label]] }
|
|
1852
|
-
current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
|
|
1853
|
-
initial_index = tree_items.index { |item| item[:entry]["id"].to_s == current_leaf_id.to_s } || tree_items.length - 1
|
|
1854
|
-
choice = select_session_tree_entry(labels_by_entry_id.values, initial_index: initial_index)
|
|
1855
|
-
return nil unless choice
|
|
1856
|
-
|
|
1857
|
-
entry_id = labels_by_entry_id.key(choice)
|
|
1858
|
-
entry = tree_items.find { |item| item[:entry]["id"].to_s == entry_id }&.fetch(:entry)
|
|
1859
|
-
return nil unless entry
|
|
1860
|
-
|
|
1861
|
-
selected_text = apply_session_tree_entry(entry)
|
|
1862
|
-
@prompt.say("\nMoved session tree position to #{entry["id"]}.\n")
|
|
1863
|
-
if selected_text && !selected_text.empty?
|
|
1864
|
-
if @prompt.respond_to?(:prefill_input)
|
|
1865
|
-
@prompt.prefill_input(selected_text)
|
|
1866
|
-
else
|
|
1867
|
-
@prompt.say("\nSelected text for editing:\n#{selected_text}\n")
|
|
1868
|
-
end
|
|
1869
|
-
end
|
|
1870
|
-
agent = reload_active_session(session_store)
|
|
1871
|
-
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
1872
|
-
agent
|
|
1873
|
-
rescue StandardError => e
|
|
1874
|
-
@prompt.say("\nSession tree error: #{e.message}\n")
|
|
1875
|
-
nil
|
|
1876
|
-
end
|
|
1877
|
-
|
|
1878
|
-
def select_session_tree_entry(labels, initial_index: 0)
|
|
1879
|
-
if @prompt.respond_to?(:select)
|
|
1880
|
-
return @prompt.select("Tree>", labels, title: "Session Tree", initial_index: initial_index)
|
|
1881
|
-
end
|
|
1882
|
-
|
|
1883
|
-
numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
|
|
1884
|
-
@prompt.say("\nSession tree:\n#{numbered_labels.join("\n")}\n")
|
|
1885
|
-
answer = @prompt.ask("Tree entry number>").to_s.strip
|
|
1886
|
-
answer.match?(/\A\d+\z/) ? labels[answer.to_i - 1] : nil
|
|
1887
|
-
end
|
|
1888
|
-
|
|
1889
|
-
def apply_session_tree_entry(entry)
|
|
1890
|
-
message = entry["message"]
|
|
1891
|
-
if message.is_a?(Hash) && message_role(message) == "user"
|
|
1892
|
-
target_leaf = entry["parentId"]
|
|
1893
|
-
target_leaf.to_s.empty? ? @active_session.reset_leaf : @active_session.branch(target_leaf)
|
|
1894
|
-
return full_message_text(message)
|
|
1895
|
-
end
|
|
1896
|
-
|
|
1897
|
-
@active_session.branch(entry["id"])
|
|
1898
|
-
nil
|
|
1899
|
-
end
|
|
1900
|
-
|
|
1901
|
-
def reload_active_session(session_store)
|
|
1902
|
-
@active_session, conversation = session_store.load(
|
|
1903
|
-
@active_session.path,
|
|
1904
|
-
workspace: configured_workspace(root: session_store.cwd),
|
|
1905
|
-
model: current_model_id,
|
|
1906
|
-
reasoning_effort: current_reasoning_effort
|
|
1907
|
-
)
|
|
1908
|
-
reset_session_diff(@active_session.path)
|
|
1909
|
-
track_session(@active_session)
|
|
1910
|
-
update_assistant_prompt(conversation)
|
|
1911
|
-
restore_prompt_transcript do
|
|
1912
|
-
render_conversation_transcript(conversation)
|
|
1913
|
-
end
|
|
1914
|
-
build_interactive_agent(conversation)
|
|
1915
|
-
end
|
|
1916
|
-
|
|
1917
|
-
def session_tree_items(session_store)
|
|
1918
|
-
roots = session_store.session_tree(@active_session.path)
|
|
1919
|
-
current_leaf_id = @active_session.leaf_id || session_store.current_leaf(@active_session.path)
|
|
1920
|
-
SessionTreeRenderer.new(roots: roots, current_leaf_id: current_leaf_id).items
|
|
1921
|
-
end
|
|
1922
|
-
|
|
1923
|
-
def rename_session(argument)
|
|
1924
|
-
unless @active_session
|
|
1925
|
-
@prompt.say("\nNo active persisted session.\n")
|
|
1926
|
-
return
|
|
1927
|
-
end
|
|
1928
|
-
|
|
1929
|
-
@active_session.rename(argument)
|
|
1930
|
-
label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
|
|
1931
|
-
@prompt.say("\n#{label}\n")
|
|
1932
|
-
end
|
|
1933
|
-
|
|
1934
|
-
def clone_session(session_store, agent)
|
|
1935
|
-
return say_sessions_unavailable unless session_store
|
|
1936
|
-
|
|
1937
|
-
previous_session = @active_session
|
|
1938
|
-
@active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
|
|
1939
|
-
reset_session_diff(@active_session.path)
|
|
1940
|
-
cleanup_replaced_session(previous_session)
|
|
1941
|
-
@prompt.say("\nCloned session: #{@active_session.path}\n")
|
|
1942
|
-
render_conversation_transcript(agent.conversation)
|
|
1943
|
-
agent
|
|
1944
|
-
end
|
|
1945
|
-
|
|
1946
|
-
def copy_session_text(conversation, argument)
|
|
1947
|
-
target = copy_target(argument)
|
|
1948
|
-
unless target
|
|
1949
|
-
@prompt.say("\nUsage: /copy [last|transcript]\n")
|
|
1950
|
-
return
|
|
1951
|
-
end
|
|
1952
|
-
|
|
1953
|
-
content = copy_target_content(conversation, target)
|
|
1954
|
-
if content.to_s.empty?
|
|
1955
|
-
@prompt.say("\nNothing to copy.\n")
|
|
1956
|
-
return
|
|
1957
|
-
end
|
|
1958
|
-
|
|
1959
|
-
result = Clipboard.new(output: $stdout).copy(content)
|
|
1960
|
-
if result.success?
|
|
1961
|
-
@prompt.say("\nCopied #{copy_target_label(target)}.\n")
|
|
1962
|
-
else
|
|
1963
|
-
@prompt.say("\nCopy failed: #{result.message}.\n")
|
|
1964
|
-
end
|
|
1965
|
-
end
|
|
1966
|
-
|
|
1967
|
-
def copy_target(argument)
|
|
1968
|
-
target = argument.to_s.strip.downcase
|
|
1969
|
-
target = "last" if target.empty?
|
|
1970
|
-
return target if ["last", "transcript"].include?(target)
|
|
1971
|
-
|
|
1972
|
-
nil
|
|
1973
|
-
end
|
|
1974
|
-
|
|
1975
|
-
def full_message_text(message)
|
|
1976
|
-
CLITranscriptFormatter.full_text(message)
|
|
1977
|
-
end
|
|
1978
|
-
|
|
1979
|
-
def copy_target_content(conversation, target)
|
|
1980
|
-
case target
|
|
1981
|
-
when "last"
|
|
1982
|
-
last_assistant_copy_text(conversation)
|
|
1983
|
-
when "transcript"
|
|
1984
|
-
markdown_transcript(conversation)
|
|
1985
|
-
else
|
|
1986
|
-
""
|
|
1987
|
-
end
|
|
1988
|
-
end
|
|
1989
|
-
|
|
1990
|
-
def last_assistant_copy_text(conversation)
|
|
1991
|
-
message = conversation.messages.reverse.find { |item| message_role(item) == "assistant" }
|
|
1992
|
-
return "" unless message
|
|
1993
|
-
|
|
1994
|
-
CLITranscriptFormatter.content_text(message_content(message))
|
|
1995
|
-
end
|
|
1996
|
-
|
|
1997
|
-
def copy_target_label(target)
|
|
1998
|
-
target == "transcript" ? "transcript" : "last assistant response"
|
|
1999
|
-
end
|
|
2000
|
-
|
|
2001
|
-
def compact_context(agent, argument)
|
|
2002
|
-
result = Compactor.new(
|
|
2003
|
-
conversation: agent.conversation,
|
|
2004
|
-
client: @client,
|
|
2005
|
-
tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
|
|
2006
|
-
).compact(custom_instructions: argument)
|
|
2007
|
-
@prompt.say("\nCompacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.\n")
|
|
2008
|
-
render_transcript_block("Assistant", result.summary)
|
|
2009
|
-
rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e
|
|
2010
|
-
@prompt.say("\n#{e.message}\n")
|
|
2011
|
-
rescue StandardError => e
|
|
2012
|
-
@prompt.say("\nCompaction error: #{e.message}\n")
|
|
2013
|
-
end
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
def render_conversation_transcript(conversation)
|
|
2017
|
-
tool_calls_by_id = {}
|
|
2018
|
-
@prompt.say("\n#{colored("Transcript", :cyan, :bold)}\n")
|
|
2019
|
-
conversation.messages.each do |message|
|
|
2020
|
-
role = message_role(message)
|
|
2021
|
-
next if role == "system"
|
|
2022
|
-
|
|
2023
|
-
case role
|
|
2024
|
-
when "user"
|
|
2025
|
-
print_user_transcript(
|
|
2026
|
-
CLITranscriptFormatter.user_transcript_input(message),
|
|
2027
|
-
display_input: CLITranscriptFormatter.user_display_text(message),
|
|
2028
|
-
attachment_references: CLITranscriptFormatter.image_references(message),
|
|
2029
|
-
image_parts: CLITranscriptFormatter.image_parts(message)
|
|
2030
|
-
)
|
|
2031
|
-
when "assistant"
|
|
2032
|
-
render_reasoning(message)
|
|
2033
|
-
render_assistant_message(message)
|
|
2034
|
-
message_tool_calls(message).each do |tool_call|
|
|
2035
|
-
tool_calls_by_id[tool_call_id(tool_call)] = tool_call
|
|
2036
|
-
render_tool_call(tool_call)
|
|
2037
|
-
end
|
|
2038
|
-
when "tool"
|
|
2039
|
-
render_tool_message(message, tool_calls_by_id)
|
|
2040
|
-
when "compactionSummary"
|
|
2041
|
-
render_transcript_block("Compaction summary", message_summary(message))
|
|
2042
|
-
else
|
|
2043
|
-
render_transcript_block(role.to_s.capitalize, CLITranscriptFormatter.content_text(message_content(message)))
|
|
2044
|
-
end
|
|
2045
|
-
end
|
|
2046
|
-
end
|
|
2047
|
-
|
|
2048
|
-
def render_reasoning(message)
|
|
2049
|
-
reasoning = CLITranscriptFormatter.reasoning(message)
|
|
2050
|
-
render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
|
|
2051
|
-
end
|
|
2052
|
-
|
|
2053
|
-
def render_assistant_message(message)
|
|
2054
|
-
content = CLITranscriptFormatter.content_text(message_content(message))
|
|
2055
|
-
return if content.empty?
|
|
2056
|
-
|
|
2057
|
-
render_transcript_block("Assistant", content)
|
|
2058
|
-
end
|
|
2059
|
-
|
|
2060
|
-
def render_tool_message(message, tool_calls_by_id)
|
|
2061
|
-
tool_call = tool_calls_by_id[message_tool_call_id(message)] || CLITranscriptFormatter.synthetic_tool_call(message_name(message), message_tool_call_id(message))
|
|
2062
|
-
render_tool_result(tool_call, message_content(message).to_s)
|
|
2063
|
-
end
|
|
2064
|
-
|
|
2065
|
-
def render_tool_call(tool_call)
|
|
2066
|
-
if prompt_interface?
|
|
2067
|
-
print_tool_call(tool_call)
|
|
2068
|
-
else
|
|
2069
|
-
@prompt.say("\n#{colored("Tool>", :magenta, :bold)}\n#{tool_command(tool_call)}\n")
|
|
2070
|
-
end
|
|
2071
|
-
end
|
|
2072
|
-
|
|
2073
|
-
def render_tool_result(tool_call, content)
|
|
2074
|
-
summary = limit_tool_output_lines(tool_result_summary(tool_call, content), INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
2075
|
-
if prompt_interface?
|
|
2076
|
-
print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
2077
|
-
else
|
|
2078
|
-
@prompt.say("\n#{colored("Tool output>", :cyan, :bold)}\n#{summary}\n")
|
|
2079
|
-
end
|
|
2080
|
-
end
|
|
2081
|
-
|
|
2082
|
-
def render_transcript_block(label, content)
|
|
2083
|
-
return if content.to_s.empty?
|
|
2084
|
-
|
|
2085
|
-
rendered = render_markdown_transcript(content)
|
|
2086
|
-
if prompt_interface?
|
|
2087
|
-
print_block_delta(label, rendered)
|
|
2088
|
-
finish_stream_block
|
|
2089
|
-
else
|
|
2090
|
-
@prompt.say("\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}\n#{rendered}\n")
|
|
2091
|
-
end
|
|
2092
|
-
end
|
|
2093
|
-
|
|
2094
|
-
def render_markdown_transcript(content)
|
|
2095
|
-
ANSI.markdown(content, enabled: @color_enabled)
|
|
2096
|
-
end
|
|
2097
|
-
|
|
2098
|
-
def append_markdown_delta(chunks, label, delta)
|
|
2099
|
-
text = delta.to_s
|
|
2100
|
-
return if text.empty?
|
|
2101
|
-
|
|
2102
|
-
if chunks.last&.first == label
|
|
2103
|
-
chunks.last[1] << text
|
|
2104
|
-
else
|
|
2105
|
-
chunks << [label, +text]
|
|
2106
|
-
end
|
|
2107
|
-
end
|
|
2108
|
-
|
|
2109
|
-
def flush_markdown_deltas(chunks, finish: true, streams: nil)
|
|
2110
|
-
wrote = false
|
|
2111
|
-
entries = ordered_markdown_entries(chunks.dup)
|
|
2112
|
-
if finish && streams
|
|
2113
|
-
streamed_labels = entries.map(&:first)
|
|
2114
|
-
entries = ordered_markdown_entries(entries.concat(streams.keys.reject { |label| streamed_labels.include?(label) }.map { |label| [label, ""] }))
|
|
2115
|
-
end
|
|
2116
|
-
|
|
2117
|
-
entries.each do |label, content|
|
|
2118
|
-
next if content.empty? && !(finish && streams&.key?(label))
|
|
2119
|
-
|
|
2120
|
-
rendered = if streams
|
|
2121
|
-
streams[label] ||= ANSI::MarkdownStream.new(enabled: @color_enabled)
|
|
2122
|
-
streams[label].render(content, final: finish)
|
|
2123
|
-
else
|
|
2124
|
-
render_markdown_transcript(content)
|
|
2125
|
-
end
|
|
2126
|
-
streams.delete(label) if finish && streams
|
|
2127
|
-
next if rendered.empty?
|
|
2128
|
-
|
|
2129
|
-
print_block_delta(label, rendered)
|
|
2130
|
-
finish_stream_block if finish
|
|
2131
|
-
wrote = true
|
|
2132
|
-
end
|
|
2133
|
-
chunks.clear
|
|
2134
|
-
wrote
|
|
2135
|
-
end
|
|
2136
|
-
|
|
2137
|
-
def ordered_markdown_entries(entries)
|
|
2138
|
-
labels = entries.map(&:first)
|
|
2139
|
-
return entries unless labels.include?("Reasoning") && labels.include?("Assistant")
|
|
2140
|
-
|
|
2141
|
-
grouped = { "Reasoning" => +"", "Assistant" => +"" }
|
|
2142
|
-
others = []
|
|
2143
|
-
entries.each do |label, content|
|
|
2144
|
-
if grouped.key?(label)
|
|
2145
|
-
grouped[label] << content.to_s
|
|
2146
|
-
else
|
|
2147
|
-
others << [label, content]
|
|
2148
|
-
end
|
|
2149
|
-
end
|
|
2150
|
-
|
|
2151
|
-
[["Reasoning", grouped["Reasoning"]], ["Assistant", grouped["Assistant"]]] + others
|
|
2152
|
-
end
|
|
2153
|
-
|
|
2154
|
-
def message_role(message)
|
|
2155
|
-
MessageAccess.role(message)
|
|
2156
|
-
end
|
|
2157
|
-
|
|
2158
|
-
def message_content(message)
|
|
2159
|
-
MessageAccess.content(message)
|
|
2160
|
-
end
|
|
2161
|
-
|
|
2162
|
-
def message_summary(message)
|
|
2163
|
-
MessageAccess.summary(message) || message_content(message)
|
|
2164
|
-
end
|
|
2165
|
-
|
|
2166
|
-
def message_name(message)
|
|
2167
|
-
MessageAccess.name(message)
|
|
2168
|
-
end
|
|
2169
|
-
|
|
2170
|
-
def message_tool_call_id(message)
|
|
2171
|
-
MessageAccess.tool_call_id(message)
|
|
2172
|
-
end
|
|
2173
|
-
|
|
2174
|
-
def message_tool_calls(message)
|
|
2175
|
-
MessageAccess.tool_calls(message)
|
|
2176
|
-
end
|
|
2177
|
-
|
|
2178
|
-
def tool_call_id(tool_call)
|
|
2179
|
-
tool_call["id"] || tool_call[:id]
|
|
2180
|
-
end
|
|
2181
|
-
|
|
2182
|
-
def export_session(conversation, argument)
|
|
2183
|
-
path = export_path(argument)
|
|
2184
|
-
File.write(path, markdown_transcript(conversation))
|
|
2185
|
-
@prompt.say("\nExported session: #{path}\n")
|
|
2186
|
-
rescue StandardError => e
|
|
2187
|
-
@prompt.say("\nError: #{e.message}\n")
|
|
2188
|
-
end
|
|
2189
|
-
|
|
2190
|
-
def say_sessions_unavailable
|
|
2191
|
-
@prompt.say("\nSessions are unavailable for this interactive loop.\n")
|
|
2192
|
-
nil
|
|
2193
|
-
end
|
|
2194
|
-
|
|
2195
|
-
def clear_prompt_transcript
|
|
2196
|
-
@prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
|
|
2197
|
-
end
|
|
2198
|
-
|
|
2199
|
-
def restore_prompt_transcript(&block)
|
|
2200
|
-
if @prompt.respond_to?(:restore_transcript)
|
|
2201
|
-
@prompt.restore_transcript(&block)
|
|
2202
|
-
else
|
|
2203
|
-
block.call
|
|
2204
|
-
end
|
|
2205
|
-
end
|
|
2206
|
-
|
|
2207
|
-
def select_session_path(session_store)
|
|
2208
|
-
sessions = session_store.recent(limit: nil)
|
|
2209
|
-
if sessions.empty?
|
|
2210
|
-
@prompt.say("\nNo saved sessions found.\n")
|
|
2211
|
-
return nil
|
|
2212
|
-
end
|
|
2213
|
-
|
|
2214
|
-
labels = sessions.map { |session| session_label(session) }
|
|
2215
|
-
if @prompt.respond_to?(:select)
|
|
2216
|
-
choice = @prompt.select("Session>", labels)
|
|
2217
|
-
return nil unless choice
|
|
2218
|
-
|
|
2219
|
-
selected = sessions[labels.index(choice)]
|
|
2220
|
-
return selected&.path
|
|
2221
|
-
end
|
|
2222
|
-
|
|
2223
|
-
numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
|
|
2224
|
-
@prompt.say("\nRecent sessions:\n#{numbered_labels.join("\n")}\n")
|
|
2225
|
-
answer = @prompt.ask("Session number or path>").to_s.strip
|
|
2226
|
-
if answer.match?(/\A\d+\z/)
|
|
2227
|
-
sessions[answer.to_i - 1]&.path
|
|
2228
|
-
else
|
|
2229
|
-
answer
|
|
2230
|
-
end
|
|
2231
|
-
end
|
|
2232
|
-
|
|
2233
|
-
def session_label(session)
|
|
2234
|
-
title = session.name.to_s.strip
|
|
2235
|
-
title = session.first_message.to_s.strip if title.empty?
|
|
2236
|
-
title = session.id if title.empty?
|
|
2237
|
-
"#{session_tree_prefix(session)}#{title} — #{File.basename(session.path)}"
|
|
2238
|
-
end
|
|
2239
|
-
|
|
2240
|
-
def session_tree_prefix(session)
|
|
2241
|
-
depth = session.respond_to?(:depth) ? session.depth.to_i : 0
|
|
2242
|
-
return "" if depth <= 0
|
|
2243
|
-
|
|
2244
|
-
ancestors = session.respond_to?(:ancestor_continues) ? Array(session.ancestor_continues) : []
|
|
2245
|
-
prefix = ancestors.map { |continues| continues ? "│ " : " " }.join
|
|
2246
|
-
branch = session.respond_to?(:is_last) && session.is_last ? "└─ " : "├─ "
|
|
2247
|
-
prefix + branch
|
|
2248
|
-
end
|
|
2249
|
-
|
|
2250
|
-
def export_path(argument)
|
|
2251
|
-
default_path = if @active_session
|
|
2252
|
-
@active_session.path.sub(/\.jsonl\z/, ".md")
|
|
2253
|
-
else
|
|
2254
|
-
File.expand_path("kward-session-#{Time.now.utc.iso8601(3).tr(':', '-')}.md", Dir.pwd)
|
|
2255
|
-
end
|
|
2256
|
-
session_dir = @session_store&.session_dir || (@active_session && File.dirname(@active_session.path))
|
|
2257
|
-
|
|
2258
|
-
ExportPath.resolve(argument, workspace_root: Dir.pwd, default_path: default_path, session_dir: session_dir)
|
|
2259
|
-
end
|
|
2260
|
-
|
|
2261
|
-
def markdown_transcript(conversation)
|
|
2262
|
-
TranscriptExport.content(conversation)
|
|
2263
|
-
end
|
|
2264
|
-
|
|
2265
|
-
def setup_interactive_prompt
|
|
2266
|
-
return unless @stdin.tty?
|
|
2267
|
-
return unless @prompt.is_a?(TTY::Prompt)
|
|
2268
|
-
|
|
2269
|
-
prompt_interface = load_prompt_interface
|
|
2270
|
-
return unless prompt_interface
|
|
2271
|
-
|
|
2272
|
-
banner_enabled = ConfigFiles.banner_enabled?
|
|
2273
|
-
@prompt = prompt_interface.new(
|
|
2274
|
-
slash_commands: slash_command_entries,
|
|
2275
|
-
overlay_settings: ConfigFiles.overlay_settings,
|
|
2276
|
-
footer: prompt_footer_renderer,
|
|
2277
|
-
composer_status: method(:composer_status_text),
|
|
2278
|
-
busy_help: ConfigFiles.composer_busy_help?,
|
|
2279
|
-
attachment_badges: method(:composer_attachment_badges),
|
|
2280
|
-
attachment_parser: method(:composer_attachment_parser),
|
|
2281
|
-
banner_pixels: banner_enabled ? Kward::PromptInterface::BANNER_LOGO_PIXELS : nil,
|
|
2282
|
-
banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
|
|
2283
|
-
)
|
|
2284
|
-
@prompt.start
|
|
2285
|
-
end
|
|
2286
|
-
|
|
2287
|
-
def load_prompt_interface
|
|
2288
|
-
require_relative "prompt_interface"
|
|
2289
|
-
PromptInterface
|
|
2290
|
-
rescue LoadError => e
|
|
2291
|
-
raise unless missing_tty_tui_load_error?(e)
|
|
2292
|
-
|
|
2293
|
-
nil
|
|
2294
|
-
end
|
|
2295
|
-
|
|
2296
|
-
def missing_tty_tui_load_error?(error)
|
|
2297
|
-
["tty-cursor", "tty-reader", "tty-screen"].include?(error.path) ||
|
|
2298
|
-
error.message.match?(/cannot load such file -- tty-(cursor|reader|screen)/)
|
|
2299
|
-
end
|
|
2300
|
-
|
|
2301
|
-
def prompt_interface?
|
|
2302
|
-
@prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
|
|
2303
|
-
end
|
|
2304
|
-
|
|
2305
|
-
def print_visual_banner
|
|
2306
|
-
@prompt.print_visual_banner if @prompt.respond_to?(:print_visual_banner)
|
|
2307
|
-
end
|
|
2308
|
-
|
|
2309
|
-
def prompt_templates
|
|
2310
|
-
@prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
|
|
2311
|
-
end
|
|
2312
|
-
|
|
2313
|
-
def plugin_registry
|
|
2314
|
-
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_slash_command_names)
|
|
2315
|
-
end
|
|
2316
|
-
|
|
2317
|
-
def plugin_commands
|
|
2318
|
-
plugin_registry.commands
|
|
2319
|
-
end
|
|
2320
|
-
|
|
2321
|
-
def plugin_command_for(command)
|
|
2322
|
-
plugin_registry.command_for(command)
|
|
2323
|
-
end
|
|
2324
|
-
|
|
2325
|
-
def reload_plugins(conversation)
|
|
2326
|
-
@plugin_registry = PluginRegistry.load(reserved_commands: reserved_slash_command_names)
|
|
2327
|
-
conversation.plugin_registry = @plugin_registry if conversation.respond_to?(:plugin_registry=)
|
|
2328
|
-
conversation.refresh_system_message! if conversation.respond_to?(:refresh_system_message!)
|
|
2329
|
-
@prompt.say("\nPlugins reloaded.\n")
|
|
2330
|
-
end
|
|
2331
|
-
|
|
2332
|
-
def reserved_slash_command_names
|
|
2333
|
-
BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
|
|
2334
|
-
end
|
|
2335
|
-
|
|
2336
|
-
def slash_command_entries
|
|
2337
|
-
prompt_entries = prompt_templates.map do |template|
|
|
2338
|
-
{
|
|
2339
|
-
name: template.command,
|
|
2340
|
-
description: template.description,
|
|
2341
|
-
argument_hint: template.argument_hint
|
|
2342
|
-
}
|
|
2343
|
-
end
|
|
2344
|
-
plugin_entries = plugin_commands.map(&:entry)
|
|
2345
|
-
BUILTIN_SLASH_COMMANDS + prompt_entries + plugin_entries
|
|
2346
|
-
end
|
|
2347
|
-
|
|
2348
|
-
def prompt_template_for(command)
|
|
2349
|
-
prompt_templates.find { |template| template.command == command }
|
|
2350
|
-
end
|
|
2351
|
-
|
|
2352
|
-
def expand_prompt_template(input)
|
|
2353
|
-
PromptCommands.expand(input, templates: prompt_templates, reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
|
|
2354
|
-
end
|
|
2355
|
-
|
|
2356
|
-
def run_plugin_command(name, argument, agent)
|
|
2357
|
-
command = plugin_command_for(name)
|
|
2358
|
-
return [false, nil] unless command
|
|
2359
|
-
|
|
2360
|
-
agent.conversation.plugin_registry ||= plugin_registry if agent.conversation.respond_to?(:plugin_registry)
|
|
2361
|
-
context = plugin_context(agent.conversation, argument)
|
|
2362
|
-
command.handler.call(argument, context)
|
|
2363
|
-
[true, nil]
|
|
2364
|
-
rescue StandardError => e
|
|
2365
|
-
@prompt.say("\nPlugin command /#{name} error: #{e.message}\n")
|
|
2366
|
-
[true, nil]
|
|
2367
|
-
end
|
|
2368
|
-
|
|
2369
|
-
def prompt_footer_renderer
|
|
2370
|
-
renderer = plugin_registry.footer_renderer
|
|
2371
|
-
return nil unless renderer
|
|
2372
|
-
|
|
2373
|
-
lambda do
|
|
2374
|
-
context = plugin_context(current_footer_conversation, "")
|
|
2375
|
-
renderer.call(context).to_s
|
|
2376
|
-
rescue StandardError => e
|
|
2377
|
-
warn "Warning: Kward plugin footer error: #{e.message}"
|
|
2378
|
-
""
|
|
2379
|
-
end
|
|
2380
|
-
end
|
|
2381
|
-
|
|
2382
|
-
def composer_status_text
|
|
2383
|
-
provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
2384
|
-
model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
2385
|
-
reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
2386
|
-
reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
|
|
2387
|
-
text = "#{provider} #{model} · #{reasoning}"
|
|
2388
|
-
parts = []
|
|
2389
|
-
diff = composer_session_diff_text
|
|
2390
|
-
parts << diff if diff
|
|
2391
|
-
usage = composer_context_usage(provider, model)
|
|
2392
|
-
parts << composer_context_percent_text(usage[:percent]) if usage
|
|
2393
|
-
parts << text
|
|
2394
|
-
parts.join(" · ")
|
|
2395
|
-
end
|
|
2396
|
-
|
|
2397
|
-
def composer_session_diff_text
|
|
2398
|
-
return nil if @session_diff.nil? || @session_diff.empty?
|
|
2399
|
-
|
|
2400
|
-
additions = ANSI.colorize("+#{@session_diff.additions}", :green, enabled: @color_enabled)
|
|
2401
|
-
deletions = ANSI.colorize("-#{@session_diff.deletions}", :red, enabled: @color_enabled)
|
|
2402
|
-
"#{additions}|#{deletions}"
|
|
2403
|
-
end
|
|
2404
|
-
|
|
2405
|
-
def composer_context_percent_text(percent)
|
|
2406
|
-
value = percent.round
|
|
2407
|
-
color = if value >= 85
|
|
2408
|
-
:red
|
|
2409
|
-
elsif value >= 50
|
|
2410
|
-
:yellow
|
|
2411
|
-
end
|
|
2412
|
-
ANSI.colorize("#{value}%", color, enabled: @color_enabled)
|
|
2413
|
-
end
|
|
2414
|
-
|
|
2415
|
-
def composer_context_window
|
|
2416
|
-
provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
2417
|
-
model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
2418
|
-
provider = ModelInfo.provider_label(provider)
|
|
2419
|
-
@client.respond_to?(:current_context_window) ? @client.current_context_window : ModelInfo.context_window(provider, model)
|
|
2420
|
-
end
|
|
2421
|
-
|
|
2422
|
-
def composer_context_usage(provider, model)
|
|
2423
|
-
context_window = composer_context_window
|
|
2424
|
-
context_parts = if @client.respond_to?(:current_context_parts)
|
|
2425
|
-
@client.current_context_parts(current_footer_conversation.messages, footer_tool_schemas)
|
|
2426
|
-
else
|
|
2427
|
-
{ provider: provider, model: model, messages: current_footer_conversation.messages, tools: footer_tool_schemas }
|
|
2428
|
-
end
|
|
2429
|
-
@context_usage.call(
|
|
2430
|
-
provider: provider,
|
|
2431
|
-
model: model,
|
|
2432
|
-
context_window: context_window,
|
|
2433
|
-
context_parts: context_parts
|
|
2434
|
-
)
|
|
2435
|
-
end
|
|
2436
|
-
|
|
2437
|
-
def footer_tool_schemas
|
|
2438
|
-
@footer_tool_registry&.schemas || []
|
|
2439
|
-
end
|
|
2440
|
-
|
|
2441
|
-
def current_footer_conversation
|
|
2442
|
-
@footer_conversation || Conversation.new(system_message: nil)
|
|
2443
|
-
end
|
|
2444
|
-
|
|
2445
|
-
def plugin_context(conversation, args)
|
|
2446
|
-
PluginRegistry::Context.new(
|
|
2447
|
-
conversation: conversation,
|
|
2448
|
-
args: args,
|
|
2449
|
-
session: @active_session,
|
|
2450
|
-
workspace_root: conversation.workspace_root,
|
|
2451
|
-
say_callback: lambda { |message| @prompt.say("\n#{message}\n") }
|
|
2452
|
-
)
|
|
2453
|
-
end
|
|
2454
|
-
|
|
2455
|
-
def selected_slash_command_input(input)
|
|
2456
|
-
return nil if prompt_interface?
|
|
2457
|
-
return nil unless @prompt.respond_to?(:select)
|
|
2458
|
-
return nil unless input.match?(%r{\A/[^\s/]*\z})
|
|
2459
|
-
return nil if prompt_template_for(input.delete_prefix("/"))
|
|
2460
|
-
|
|
2461
|
-
prefix = input.delete_prefix("/").downcase
|
|
2462
|
-
return nil if slash_command_entries.any? { |entry| entry[:name].downcase == prefix }
|
|
2463
|
-
|
|
2464
|
-
matches = slash_command_entries.select { |entry| entry[:name].downcase.start_with?(prefix) }
|
|
2465
|
-
return nil if matches.empty?
|
|
2466
|
-
|
|
2467
|
-
labels = matches.map { |entry| slash_command_label(entry) }
|
|
2468
|
-
choice = @prompt.select("Slash command>", labels)
|
|
2469
|
-
entry = matches[labels.index(choice)]
|
|
2470
|
-
entry ? "/#{entry[:name]}" : nil
|
|
2471
|
-
end
|
|
2472
|
-
|
|
2473
|
-
def slash_command_label(entry)
|
|
2474
|
-
hint = entry[:argument_hint].to_s.empty? ? "" : " #{entry[:argument_hint]}"
|
|
2475
|
-
description = entry[:description].to_s.empty? ? "" : " - #{entry[:description]}"
|
|
2476
|
-
"/#{entry[:name]}#{hint}#{description}"
|
|
2477
|
-
end
|
|
2478
|
-
|
|
2479
|
-
def auto_name_active_session(input)
|
|
2480
|
-
return unless @active_session
|
|
2481
|
-
return unless @active_session.name.to_s.strip.empty?
|
|
2482
|
-
|
|
2483
|
-
name = default_session_name(input)
|
|
2484
|
-
@active_session.rename(name) unless name.empty?
|
|
2485
|
-
end
|
|
2486
|
-
|
|
2487
|
-
def default_session_name(input)
|
|
2488
|
-
input.to_s.gsub(/\s+/, " ").strip.slice(0, 120).to_s
|
|
2489
|
-
end
|
|
2490
|
-
|
|
2491
|
-
def run_interactive_turn(agent, input, display_input: nil)
|
|
2492
|
-
prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
|
|
2493
|
-
print_user_transcript(input, display_input: display_input) if prompt_interface?
|
|
2494
|
-
return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
|
|
2495
|
-
|
|
2496
|
-
queued_inputs = []
|
|
2497
|
-
cancellation = Cancellation.new
|
|
2498
|
-
cancelled = false
|
|
2499
|
-
steering = steering_supported? ? Steering.new : nil
|
|
2500
|
-
event_queue = Queue.new
|
|
2501
|
-
stream_state = {
|
|
2502
|
-
streamed: false,
|
|
2503
|
-
last_flush: monotonic_now,
|
|
2504
|
-
stream_block_open: false,
|
|
2505
|
-
markdown_streams: {},
|
|
2506
|
-
defer_assistant_streaming: defer_assistant_streaming?(agent)
|
|
2507
|
-
}
|
|
2508
|
-
markdown_chunks = []
|
|
2509
|
-
answer = nil
|
|
2510
|
-
error = nil
|
|
2511
|
-
@prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
|
|
2512
|
-
|
|
2513
|
-
worker = Thread.new do
|
|
2514
|
-
options = agent_display_options(display_input)
|
|
2515
|
-
options[:cancellation] = cancellation
|
|
2516
|
-
options[:steering] = steering if steering
|
|
2517
|
-
answer = agent.ask(input, **options) do |event|
|
|
2518
|
-
event_queue << event
|
|
2519
|
-
end
|
|
2520
|
-
rescue StandardError => e
|
|
2521
|
-
error = e
|
|
2522
|
-
end
|
|
2523
|
-
worker.report_on_exception = false
|
|
2524
|
-
|
|
2525
|
-
while worker.alive?
|
|
2526
|
-
begin
|
|
2527
|
-
poll_result = collect_busy_input(queued_inputs, steering)
|
|
2528
|
-
sleep 0.01
|
|
2529
|
-
rescue Interrupt
|
|
2530
|
-
poll_result = PromptInterface::CANCEL_INPUT
|
|
2531
|
-
end
|
|
2532
|
-
if poll_result == PromptInterface::CANCEL_INPUT && !cancelled
|
|
2533
|
-
cancelled = true
|
|
2534
|
-
cancellation.cancel!
|
|
2535
|
-
worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
|
|
2536
|
-
end
|
|
2537
|
-
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
|
|
2538
|
-
end
|
|
2539
|
-
begin
|
|
2540
|
-
worker.join
|
|
2541
|
-
rescue Cancellation::CancelledError => e
|
|
2542
|
-
error ||= e
|
|
2543
|
-
end
|
|
2544
|
-
drain_busy_input(queued_inputs, nil) unless cancelled
|
|
2545
|
-
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
|
|
2546
|
-
raise error if error && !error.is_a?(Cancellation::CancelledError)
|
|
2547
|
-
|
|
2548
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
|
|
2549
|
-
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
|
|
2550
|
-
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
|
|
2551
|
-
queued_inputs
|
|
2552
|
-
ensure
|
|
2553
|
-
@prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
|
|
2554
|
-
end
|
|
2555
|
-
|
|
2556
|
-
def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
|
|
2557
|
-
drained = 0
|
|
2558
|
-
loop do
|
|
2559
|
-
break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
|
|
2560
|
-
|
|
2561
|
-
event = event_queue.pop(true)
|
|
2562
|
-
drained += 1
|
|
2563
|
-
notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
|
|
2564
|
-
handle_interactive_event(event, markdown_chunks, stream_state)
|
|
2565
|
-
rescue ThreadError
|
|
2566
|
-
break
|
|
2567
|
-
end
|
|
2568
|
-
|
|
2569
|
-
flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
|
|
2570
|
-
end
|
|
2571
|
-
|
|
2572
|
-
def notify_plugin_transcript_event(event, conversation)
|
|
2573
|
-
return unless conversation
|
|
2574
|
-
return if plugin_registry.transcript_event_handlers.empty?
|
|
2575
|
-
|
|
2576
|
-
plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
|
|
2577
|
-
end
|
|
2578
|
-
|
|
2579
|
-
def handle_interactive_event(event, markdown_chunks, stream_state)
|
|
2580
|
-
case event
|
|
2581
|
-
when Events::ReasoningDelta
|
|
2582
|
-
stream_state[:streamed] = true
|
|
2583
|
-
append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
|
|
2584
|
-
when Events::AssistantDelta
|
|
2585
|
-
stream_state[:streamed] = true
|
|
2586
|
-
append_markdown_delta(markdown_chunks, "Assistant", event.delta)
|
|
2587
|
-
when Events::SteeringApplied
|
|
2588
|
-
@prompt.clear_steered_count if @prompt.respond_to?(:clear_steered_count)
|
|
2589
|
-
when Events::Retry
|
|
2590
|
-
stream_state[:streamed] = true
|
|
2591
|
-
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
2592
|
-
print_retry(event)
|
|
2593
|
-
when Events::ToolCall
|
|
2594
|
-
stream_state[:streamed] = true
|
|
2595
|
-
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
2596
|
-
print_tool_call(event.tool_call)
|
|
2597
|
-
when Events::ToolResult
|
|
2598
|
-
stream_state[:streamed] = true
|
|
2599
|
-
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
2600
|
-
update_session_diff(event.content, tool_call: event.tool_call)
|
|
2601
|
-
print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
2602
|
-
end
|
|
2603
|
-
end
|
|
2604
|
-
|
|
2605
|
-
def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
|
|
2606
|
-
if force
|
|
2607
|
-
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
2608
|
-
return
|
|
2609
|
-
end
|
|
2610
|
-
return if markdown_chunks.empty?
|
|
2611
|
-
return unless monotonic_now - stream_state[:last_flush] >= STREAM_RENDER_INTERVAL
|
|
2612
|
-
|
|
2613
|
-
chunks_to_flush = markdown_chunks
|
|
2614
|
-
if stream_state[:defer_assistant_streaming]
|
|
2615
|
-
chunks_to_flush, delayed_chunks = split_deferred_assistant_entries(markdown_chunks)
|
|
2616
|
-
return if chunks_to_flush.empty?
|
|
2617
|
-
|
|
2618
|
-
markdown_chunks.replace(delayed_chunks)
|
|
2619
|
-
end
|
|
2620
|
-
|
|
2621
|
-
stream_state[:stream_block_open] = true if flush_markdown_deltas(chunks_to_flush, finish: false, streams: stream_state[:markdown_streams])
|
|
2622
|
-
stream_state[:last_flush] = monotonic_now
|
|
2623
|
-
end
|
|
2624
|
-
|
|
2625
|
-
def finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
2626
|
-
wrote = flush_markdown_deltas(markdown_chunks, streams: stream_state[:markdown_streams])
|
|
2627
|
-
finish_stream_block if stream_state[:stream_block_open] && !wrote
|
|
2628
|
-
stream_state[:stream_block_open] = false
|
|
2629
|
-
stream_state[:last_flush] = monotonic_now
|
|
2630
|
-
end
|
|
2631
|
-
|
|
2632
|
-
def split_deferred_assistant_entries(markdown_chunks)
|
|
2633
|
-
markdown_chunks.partition { |label, _content| label != "Assistant" }
|
|
2634
|
-
end
|
|
2635
|
-
|
|
2636
|
-
def monotonic_now
|
|
2637
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
2638
|
-
end
|
|
2639
|
-
|
|
2640
|
-
def collect_queued_input(queued_inputs)
|
|
2641
|
-
collect_busy_input(queued_inputs, nil)
|
|
2642
|
-
end
|
|
2643
|
-
|
|
2644
|
-
def collect_busy_input(queued_inputs, steering)
|
|
2645
|
-
return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
|
|
2646
|
-
|
|
2647
|
-
poll_result = @prompt.poll_input
|
|
2648
|
-
case poll_result
|
|
2649
|
-
when String
|
|
2650
|
-
if steering && !poll_result.strip.empty?
|
|
2651
|
-
begin
|
|
2652
|
-
steering.submit(poll_result)
|
|
2653
|
-
@prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
|
|
2654
|
-
rescue StandardError
|
|
2655
|
-
queued_inputs << poll_result
|
|
2656
|
-
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
2657
|
-
end
|
|
2658
|
-
else
|
|
2659
|
-
queued_inputs << poll_result unless poll_result.strip.empty?
|
|
2660
|
-
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
2661
|
-
end
|
|
2662
|
-
when PromptInterface::EXIT_INPUT
|
|
2663
|
-
queued_inputs << "/exit"
|
|
2664
|
-
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
2665
|
-
end
|
|
2666
|
-
poll_result
|
|
2667
|
-
end
|
|
2668
|
-
|
|
2669
|
-
def drain_queued_input(queued_inputs)
|
|
2670
|
-
drain_busy_input(queued_inputs, nil)
|
|
2671
|
-
end
|
|
2672
|
-
|
|
2673
|
-
def drain_busy_input(queued_inputs, steering)
|
|
2674
|
-
deadline = Time.now + 0.15
|
|
2675
|
-
loop do
|
|
2676
|
-
poll_result = collect_busy_input(queued_inputs, steering)
|
|
2677
|
-
break if Time.now > deadline && poll_result.nil?
|
|
2678
|
-
|
|
2679
|
-
sleep 0.01
|
|
2680
|
-
end
|
|
2681
|
-
end
|
|
2682
|
-
|
|
2683
|
-
def steering_supported?
|
|
2684
|
-
@client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
|
|
2685
|
-
end
|
|
2686
|
-
|
|
2687
|
-
def defer_assistant_streaming?(agent)
|
|
2688
|
-
return false unless agent.respond_to?(:conversation)
|
|
2689
|
-
|
|
2690
|
-
conversation = agent.conversation
|
|
2691
|
-
model = conversation.respond_to?(:model) && conversation.model ? conversation.model : current_model_id
|
|
2692
|
-
ModelInfo.reasoning_supported?(current_model_provider, model)
|
|
2693
|
-
end
|
|
2694
|
-
|
|
2695
|
-
def run_blocking_interactive_turn(agent, input, display_input: nil)
|
|
2696
|
-
streamed = false
|
|
2697
|
-
markdown_chunks = []
|
|
2698
|
-
answer = agent.ask(input, **agent_display_options(display_input)) do |event|
|
|
2699
|
-
case event
|
|
2700
|
-
when Events::ReasoningDelta
|
|
2701
|
-
streamed = true
|
|
2702
|
-
append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
|
|
2703
|
-
when Events::AssistantDelta
|
|
2704
|
-
streamed = true
|
|
2705
|
-
append_markdown_delta(markdown_chunks, "Assistant", event.delta)
|
|
2706
|
-
when Events::Retry
|
|
2707
|
-
streamed = true
|
|
2708
|
-
flush_markdown_deltas(markdown_chunks)
|
|
2709
|
-
print_retry(event)
|
|
2710
|
-
when Events::ToolCall
|
|
2711
|
-
streamed = true
|
|
2712
|
-
flush_markdown_deltas(markdown_chunks)
|
|
2713
|
-
print_tool_call(event.tool_call)
|
|
2714
|
-
when Events::ToolResult
|
|
2715
|
-
streamed = true
|
|
2716
|
-
flush_markdown_deltas(markdown_chunks)
|
|
2717
|
-
print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
2718
|
-
end
|
|
2719
|
-
end
|
|
2720
|
-
flush_markdown_deltas(markdown_chunks) if streamed
|
|
2721
|
-
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
|
|
2722
|
-
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
|
|
2723
|
-
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
|
|
2724
|
-
[]
|
|
2725
|
-
end
|
|
2726
|
-
|
|
2727
|
-
def prepare_memory_context(conversation, input)
|
|
2728
|
-
manager = Memory::Manager.new
|
|
2729
|
-
retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
|
|
2730
|
-
conversation.last_memory_retrieval = retrieval
|
|
2731
|
-
conversation.memory_context = manager.memory_block(retrieval)
|
|
2732
|
-
conversation.refresh_system_message!
|
|
2733
|
-
rescue StandardError => e
|
|
2734
|
-
warn "Memory retrieval failed: #{e.message}"
|
|
2735
|
-
nil
|
|
2736
|
-
end
|
|
2737
|
-
|
|
2738
|
-
def persist_memory_state(conversation)
|
|
2739
|
-
@active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
|
|
2740
|
-
rescue StandardError
|
|
2741
|
-
nil
|
|
2742
|
-
end
|
|
2743
|
-
|
|
2744
|
-
def auto_summarize_memory(conversation)
|
|
2745
|
-
manager = Memory::Manager.new
|
|
2746
|
-
return unless manager.enabled? && manager.auto_summary_enabled?
|
|
2747
|
-
|
|
2748
|
-
summarize_memory(conversation, manager: manager)
|
|
2749
|
-
rescue StandardError => e
|
|
2750
|
-
warn "Memory auto-summary failed: #{e.message}"
|
|
2751
|
-
nil
|
|
2752
|
-
end
|
|
2753
|
-
|
|
2754
|
-
def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
|
|
2755
|
-
visible_input = display_input.nil? ? input : display_input
|
|
2756
|
-
@prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
|
|
2757
|
-
print_attachment_badges(input, references: attachment_references)
|
|
2758
|
-
print_pasted_images(input, image_parts: image_parts)
|
|
2759
|
-
end
|
|
2760
|
-
|
|
2761
|
-
def print_attachment_badges(input, references: nil)
|
|
2762
|
-
badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
|
|
2763
|
-
return if badges.empty?
|
|
2764
|
-
|
|
2765
|
-
@prompt.say("#{badges.join("\n")}\n")
|
|
2766
|
-
end
|
|
2767
|
-
|
|
2768
|
-
def composer_attachment_badges(input, attachments = [])
|
|
2769
|
-
references = Array(attachments)
|
|
2770
|
-
references = Kward::ImageAttachments.references_from_text(input) if references.empty?
|
|
2771
|
-
references.map { |reference| attachment_badge_text(reference) }
|
|
2772
|
-
end
|
|
2773
|
-
|
|
2774
|
-
def composer_attachment_parser(input)
|
|
2775
|
-
Kward::ImageAttachments.extract_references_from_text(input)
|
|
2776
|
-
end
|
|
2777
|
-
|
|
2778
|
-
def submitted_display_input(input)
|
|
2779
|
-
input.respond_to?(:display_input) ? input.display_input : nil
|
|
2780
|
-
end
|
|
2781
|
-
|
|
2782
|
-
def attachment_badge_text(reference)
|
|
2783
|
-
status = reference[:status] || reference["status"]
|
|
2784
|
-
label = reference[:label] || reference["label"] || "image"
|
|
2785
|
-
if status == :missing || status.to_s == "missing"
|
|
2786
|
-
"[image?] #{label} not found"
|
|
2787
|
-
else
|
|
2788
|
-
media_type = reference[:media_type] || reference["media_type"] || reference[:mimeType] || reference["mimeType"] || "image"
|
|
2789
|
-
size = format_attachment_size(reference[:size_bytes] || reference["size_bytes"] || reference[:sizeBytes] || reference["sizeBytes"])
|
|
2790
|
-
"[image] #{label} · #{media_type}#{size.empty? ? "" : " · #{size}"}"
|
|
2791
|
-
end
|
|
2792
|
-
end
|
|
2793
|
-
|
|
2794
|
-
def format_attachment_size(bytes)
|
|
2795
|
-
value = bytes.to_i
|
|
2796
|
-
return "" unless value.positive?
|
|
2797
|
-
return "#{value} B" if value < 1024
|
|
2798
|
-
|
|
2799
|
-
units = %w[KB MB GB]
|
|
2800
|
-
size = value.to_f / 1024
|
|
2801
|
-
unit = units.shift
|
|
2802
|
-
while size >= 1024 && units.any?
|
|
2803
|
-
size /= 1024
|
|
2804
|
-
unit = units.shift
|
|
2805
|
-
end
|
|
2806
|
-
formatted = size >= 10 ? size.round.to_s : format("%.1f", size).sub(/\.0\z/, "")
|
|
2807
|
-
"#{formatted} #{unit}"
|
|
2808
|
-
end
|
|
2809
|
-
|
|
2810
|
-
def agent_display_options(display_input)
|
|
2811
|
-
display_input.nil? ? {} : { display_input: display_input }
|
|
2812
|
-
end
|
|
2813
|
-
|
|
2814
|
-
def print_pasted_images(input, image_parts: nil)
|
|
2815
|
-
parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
|
|
2816
|
-
parts.each do |part|
|
|
2817
|
-
sequence = Kward::ImageAttachments.terminal_image_sequence(part)
|
|
2818
|
-
next unless sequence
|
|
2819
|
-
|
|
2820
|
-
if @prompt.respond_to?(:say_visual)
|
|
2821
|
-
@prompt.say_visual(sequence)
|
|
2822
|
-
else
|
|
2823
|
-
@prompt.say(sequence)
|
|
2824
|
-
end
|
|
2825
|
-
end
|
|
2826
|
-
end
|
|
2827
|
-
|
|
2828
|
-
def print_block_delta(label, delta)
|
|
2829
|
-
if prompt_interface?
|
|
2830
|
-
@prompt.start_stream_block(label)
|
|
2831
|
-
@prompt.write_delta(delta)
|
|
2832
|
-
else
|
|
2833
|
-
start_stream_block(label)
|
|
2834
|
-
print delta
|
|
2835
|
-
$stdout.flush
|
|
2836
|
-
end
|
|
2837
|
-
end
|
|
2838
|
-
|
|
2839
|
-
def print_retry(event)
|
|
2840
|
-
message = retry_message(event)
|
|
2841
|
-
if prompt_interface?
|
|
2842
|
-
if @prompt.respond_to?(:write_stream_block)
|
|
2843
|
-
@prompt.write_stream_block("Retry", "#{message}\n", finish: true)
|
|
2844
|
-
else
|
|
2845
|
-
@prompt.start_stream_block("Retry")
|
|
2846
|
-
@prompt.write_delta("#{message}\n")
|
|
2847
|
-
@prompt.finish_stream_block
|
|
2848
|
-
end
|
|
2849
|
-
else
|
|
2850
|
-
start_stream_block("Retry")
|
|
2851
|
-
puts message
|
|
2852
|
-
$stdout.flush
|
|
2853
|
-
@stream_block = nil
|
|
2854
|
-
end
|
|
2855
|
-
end
|
|
2856
|
-
|
|
2857
|
-
def retry_message(event)
|
|
2858
|
-
RetryMessage.format(event)
|
|
2859
|
-
end
|
|
2860
|
-
|
|
2861
|
-
def print_tool_call(tool_call)
|
|
2862
|
-
if prompt_interface?
|
|
2863
|
-
if @prompt.respond_to?(:write_stream_block)
|
|
2864
|
-
@prompt.write_stream_block("Tool", "#{tool_command(tool_call)}\n", finish: true)
|
|
2865
|
-
else
|
|
2866
|
-
@prompt.start_stream_block("Tool")
|
|
2867
|
-
@prompt.write_delta("#{tool_command(tool_call)}\n")
|
|
2868
|
-
@prompt.finish_stream_block
|
|
2869
|
-
end
|
|
2870
|
-
else
|
|
2871
|
-
start_stream_block("Tool")
|
|
2872
|
-
puts tool_command(tool_call)
|
|
2873
|
-
$stdout.flush
|
|
2874
|
-
@stream_block = nil
|
|
2875
|
-
end
|
|
2876
|
-
end
|
|
2877
|
-
|
|
2878
|
-
def print_tool_result(tool_call, content, line_limit: nil)
|
|
2879
|
-
summary = tool_result_summary(tool_call, content)
|
|
2880
|
-
summary = limit_tool_output_lines(summary, line_limit) if line_limit
|
|
2881
|
-
if prompt_interface?
|
|
2882
|
-
summary = summary.end_with?("\n") ? summary : "#{summary}\n"
|
|
2883
|
-
if @prompt.respond_to?(:write_stream_block)
|
|
2884
|
-
@prompt.write_stream_block("Tool output", summary, finish: true)
|
|
2885
|
-
else
|
|
2886
|
-
@prompt.start_stream_block("Tool output")
|
|
2887
|
-
@prompt.write_delta(summary)
|
|
2888
|
-
@prompt.finish_stream_block
|
|
2889
|
-
end
|
|
2890
|
-
else
|
|
2891
|
-
start_stream_block("Tool output")
|
|
2892
|
-
print summary
|
|
2893
|
-
puts unless summary.end_with?("\n")
|
|
2894
|
-
$stdout.flush
|
|
2895
|
-
@stream_block = nil
|
|
2896
|
-
end
|
|
2897
|
-
end
|
|
2898
|
-
|
|
2899
|
-
def tool_result_summary(tool_call, content)
|
|
2900
|
-
name = tool_call_name(tool_call)
|
|
2901
|
-
args = tool_call_args(tool_call)
|
|
2902
|
-
text = content.to_s
|
|
2903
|
-
return error_tool_summary(name, args, text) if text.start_with?("Error:", "Declined:")
|
|
2904
|
-
|
|
2905
|
-
case name
|
|
2906
|
-
when "read_file"
|
|
2907
|
-
read_file_summary(args, text)
|
|
2908
|
-
when "write_file", "edit_file"
|
|
2909
|
-
file_change_summary(name, args, text)
|
|
2910
|
-
when "run_shell_command"
|
|
2911
|
-
shell_command_summary(args, text)
|
|
2912
|
-
when "web_search"
|
|
2913
|
-
web_search_summary(args, text)
|
|
2914
|
-
else
|
|
2915
|
-
generic_tool_summary(name, text)
|
|
2916
|
-
end
|
|
2917
|
-
end
|
|
2918
|
-
|
|
2919
|
-
def limit_tool_output_lines(content, line_limit)
|
|
2920
|
-
lines = content.to_s.lines
|
|
2921
|
-
return content.to_s if lines.length <= line_limit
|
|
2922
|
-
|
|
2923
|
-
kept_lines = lines.first(line_limit - 1).join
|
|
2924
|
-
omitted_lines = lines.length - (line_limit - 1)
|
|
2925
|
-
suffix = omitted_lines == 1 ? "line" : "lines"
|
|
2926
|
-
notice = "...[truncated #{omitted_lines} #{suffix}]"
|
|
2927
|
-
kept_lines.end_with?("\n") || kept_lines.empty? ? "#{kept_lines}#{notice}" : "#{kept_lines}\n#{notice}"
|
|
2928
|
-
end
|
|
2929
|
-
|
|
2930
|
-
def read_file_summary(args, content)
|
|
2931
|
-
path = args["path"] || args[:path] || "(unknown path)"
|
|
2932
|
-
"read_file: #{path}\n#{content.lines.count} lines, #{content.bytesize} bytes"
|
|
2933
|
-
end
|
|
2934
|
-
|
|
2935
|
-
def file_change_summary(name, args, content)
|
|
2936
|
-
path = args["path"] || args[:path] || path_from_tool_result(content) || "(unknown path)"
|
|
2937
|
-
concise = content.lines.first.to_s.strip
|
|
2938
|
-
concise = "completed" if concise.empty?
|
|
2939
|
-
"#{name}: #{path}\n#{concise}"
|
|
2940
|
-
end
|
|
2941
|
-
|
|
2942
|
-
def shell_command_summary(args, content)
|
|
2943
|
-
command = args["command"] || args[:command] || ""
|
|
2944
|
-
lines = ["run_shell_command: #{command}".strip]
|
|
2945
|
-
lines << "Exit status: #{shell_exit_status(content) || "unknown"}"
|
|
2946
|
-
stdout = shell_section(content, "STDOUT")
|
|
2947
|
-
stderr = shell_section(content, "STDERR")
|
|
2948
|
-
lines << compact_stream_summary("stdout", stdout) unless stdout.empty?
|
|
2949
|
-
lines << compact_stream_summary("stderr", stderr) unless stderr.empty?
|
|
2950
|
-
lines.join("\n")
|
|
2951
|
-
end
|
|
2952
|
-
|
|
2953
|
-
def web_search_summary(args, content)
|
|
2954
|
-
queries = Array(args["queries"] || args[:queries]).map(&:to_s)
|
|
2955
|
-
queries = web_search_queries_from_content(content) if queries.empty?
|
|
2956
|
-
counts = web_search_result_counts(content)
|
|
2957
|
-
lines = ["web_search"]
|
|
2958
|
-
queries.each do |query|
|
|
2959
|
-
lines << "#{query}: #{counts.fetch(query, 0)} result(s)"
|
|
2960
|
-
end
|
|
2961
|
-
lines << "#{web_search_total_count(content)} result(s)" if queries.empty?
|
|
2962
|
-
lines.join("\n")
|
|
2963
|
-
end
|
|
2964
|
-
|
|
2965
|
-
def error_tool_summary(name, args, content)
|
|
2966
|
-
path = args["path"] || args[:path]
|
|
2967
|
-
command = args["command"] || args[:command]
|
|
2968
|
-
context = path || command
|
|
2969
|
-
[name, context, content.lines.first.to_s.strip].compact.reject(&:empty?).join("\n")
|
|
2970
|
-
end
|
|
2971
|
-
|
|
2972
|
-
def generic_tool_summary(name, content)
|
|
2973
|
-
text = content.to_s
|
|
2974
|
-
return "#{name}: #{text}" if text.length <= RESTORED_TOOL_OUTPUT_LIMIT
|
|
2975
|
-
|
|
2976
|
-
"#{name}: #{text[0, RESTORED_TOOL_OUTPUT_LIMIT]}\n...[truncated #{text.length - RESTORED_TOOL_OUTPUT_LIMIT} bytes]"
|
|
2977
|
-
end
|
|
2978
|
-
|
|
2979
|
-
def compact_stream_summary(label, text)
|
|
2980
|
-
summary = text.strip
|
|
2981
|
-
summary = summary[0, 500] + "\n...[truncated #{summary.length - 500} chars]" if summary.length > 500
|
|
2982
|
-
"#{label} (#{text.bytesize} bytes):#{summary.empty? ? "" : "\n#{summary}"}"
|
|
2983
|
-
end
|
|
2984
|
-
|
|
2985
|
-
def shell_exit_status(content)
|
|
2986
|
-
content.match(/^Exit status: ([^\n]+)/)&.[](1)
|
|
2987
|
-
end
|
|
2988
|
-
|
|
2989
|
-
def shell_section(content, name)
|
|
2990
|
-
match = content.match(/^#{Regexp.escape(name)}:\n(.*?)(?=\nSTD(?:OUT|ERR):\n|\z)/m)
|
|
2991
|
-
match ? match[1] : ""
|
|
2992
|
-
end
|
|
2993
|
-
|
|
2994
|
-
def web_search_queries_from_content(content)
|
|
2995
|
-
content.scan(/^## Query: (.+)$/).flatten
|
|
2996
|
-
end
|
|
2997
|
-
|
|
2998
|
-
def web_search_result_counts(content)
|
|
2999
|
-
counts = {}
|
|
3000
|
-
current_query = nil
|
|
3001
|
-
content.each_line do |line|
|
|
3002
|
-
if (match = line.match(/^## Query: (.+)$/))
|
|
3003
|
-
current_query = match[1]
|
|
3004
|
-
counts[current_query] ||= 0
|
|
3005
|
-
elsif current_query && line.match?(/^\d+\. /)
|
|
3006
|
-
counts[current_query] += 1
|
|
3007
|
-
end
|
|
3008
|
-
end
|
|
3009
|
-
counts
|
|
3010
|
-
end
|
|
3011
|
-
|
|
3012
|
-
def web_search_total_count(content)
|
|
3013
|
-
content.each_line.count { |line| line.match?(/^\d+\. /) }
|
|
3014
|
-
end
|
|
3015
|
-
|
|
3016
|
-
def path_from_tool_result(content)
|
|
3017
|
-
content.match(/\b(?:to|file|Edited)\s+([^:\n]+?)(?:\s|:|\z)/)&.[](1)
|
|
3018
|
-
end
|
|
3019
|
-
|
|
3020
|
-
def tool_call_name(tool_call)
|
|
3021
|
-
ToolCall.name(tool_call) || "unknown_tool"
|
|
3022
|
-
end
|
|
3023
|
-
|
|
3024
|
-
def tool_call_args(tool_call)
|
|
3025
|
-
ToolCall.arguments(tool_call)
|
|
3026
|
-
end
|
|
3027
|
-
|
|
3028
|
-
def start_stream_block(label)
|
|
3029
|
-
return if @stream_block == label
|
|
3030
|
-
|
|
3031
|
-
puts if @stream_block
|
|
3032
|
-
puts "\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}"
|
|
3033
|
-
@stream_block = label
|
|
3034
|
-
end
|
|
3035
|
-
|
|
3036
|
-
def finish_stream_block
|
|
3037
|
-
if prompt_interface?
|
|
3038
|
-
@prompt.finish_stream_block
|
|
3039
|
-
else
|
|
3040
|
-
puts if @stream_block
|
|
3041
|
-
@stream_block = nil
|
|
3042
|
-
end
|
|
3043
|
-
end
|
|
3044
|
-
|
|
3045
|
-
def colored(text, *styles)
|
|
3046
|
-
ANSI.colorize(text, *styles, enabled: @color_enabled)
|
|
3047
|
-
end
|
|
3048
|
-
|
|
3049
|
-
def transcript_label(label)
|
|
3050
|
-
label == "Assistant" ? assistant_prompt_name : label
|
|
3051
|
-
end
|
|
3052
|
-
|
|
3053
|
-
def label_color(label)
|
|
3054
|
-
case label
|
|
3055
|
-
when "Reasoning"
|
|
3056
|
-
:yellow
|
|
3057
|
-
when "Assistant", "Kward"
|
|
3058
|
-
:green
|
|
3059
|
-
when "Tool"
|
|
3060
|
-
:magenta
|
|
3061
|
-
when "Tool output"
|
|
3062
|
-
:cyan
|
|
3063
|
-
else
|
|
3064
|
-
:blue
|
|
3065
|
-
end
|
|
3066
|
-
end
|
|
3067
|
-
|
|
3068
|
-
def tool_command(tool_call)
|
|
3069
|
-
name = tool_call_name(tool_call)
|
|
3070
|
-
args = tool_call_args(tool_call)
|
|
3071
|
-
|
|
3072
|
-
if name == "run_shell_command"
|
|
3073
|
-
args["command"] || args[:command] || ""
|
|
3074
|
-
elsif args.empty?
|
|
3075
|
-
name.to_s
|
|
3076
|
-
else
|
|
3077
|
-
"#{name} #{JSON.dump(args)}"
|
|
3078
|
-
end
|
|
3079
|
-
end
|
|
3080
|
-
|
|
3081
329
|
end
|
|
3082
330
|
end
|