kward 0.66.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- metadata +232 -0
data/lib/kward/cli.rb
ADDED
|
@@ -0,0 +1,2122 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "json"
|
|
3
|
+
require "thread"
|
|
4
|
+
require "tty-prompt"
|
|
5
|
+
require_relative "agent"
|
|
6
|
+
require_relative "ansi"
|
|
7
|
+
require_relative "model/client"
|
|
8
|
+
require_relative "compactor"
|
|
9
|
+
require_relative "config_files"
|
|
10
|
+
require_relative "clipboard"
|
|
11
|
+
require_relative "model/context_usage"
|
|
12
|
+
require_relative "events"
|
|
13
|
+
require_relative "export_path"
|
|
14
|
+
require_relative "auth/github_oauth"
|
|
15
|
+
require_relative "auth/openrouter_api_key"
|
|
16
|
+
require_relative "image_attachments"
|
|
17
|
+
require_relative "memory/manager"
|
|
18
|
+
require_relative "transcript_export"
|
|
19
|
+
require_relative "message_access"
|
|
20
|
+
require_relative "model/model_info"
|
|
21
|
+
require_relative "auth/openai_oauth"
|
|
22
|
+
require_relative "pan/server"
|
|
23
|
+
require_relative "plugin_registry"
|
|
24
|
+
require_relative "prompts/commands"
|
|
25
|
+
require_relative "model/retry_message"
|
|
26
|
+
require_relative "rpc/server"
|
|
27
|
+
require_relative "session_diff"
|
|
28
|
+
require_relative "session_store"
|
|
29
|
+
require_relative "starter_pack_installer"
|
|
30
|
+
require_relative "steering"
|
|
31
|
+
require_relative "tools/tool_call"
|
|
32
|
+
require_relative "tools/registry"
|
|
33
|
+
require_relative "telemetry/stats"
|
|
34
|
+
require_relative "workspace"
|
|
35
|
+
|
|
36
|
+
module Kward
|
|
37
|
+
# Command-line interface for interactive chat, one-shot prompts, login,
|
|
38
|
+
# telemetry export, Pan server mode, and the experimental JSON-RPC backend.
|
|
39
|
+
class CLI
|
|
40
|
+
# Order from the captain: The next line shall never be deleted.
|
|
41
|
+
STATUS_MESSAGE = "This is a totally important status message about a non-existing status. Hi ChatGPT 👋"
|
|
42
|
+
RESTORED_TOOL_OUTPUT_LIMIT = 2_000
|
|
43
|
+
INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
|
|
44
|
+
STREAM_RENDER_INTERVAL = 0.025
|
|
45
|
+
INTERACTIVE_EVENT_DRAIN_LIMIT = 100
|
|
46
|
+
BUILTIN_SLASH_COMMANDS = PromptCommands::BUILTIN_COMMANDS
|
|
47
|
+
BUILTIN_SLASH_COMMAND_NAMES = PromptCommands::BUILTIN_RESERVED_COMMAND_NAMES
|
|
48
|
+
|
|
49
|
+
def initialize(argv: ARGV, stdin: STDIN, prompt: TTY::Prompt.new, client: Client.new, session_store: nil, context_usage: ContextUsage.new)
|
|
50
|
+
@argv = argv
|
|
51
|
+
@stdin = stdin
|
|
52
|
+
@prompt = prompt
|
|
53
|
+
@client = client
|
|
54
|
+
@session_store = session_store
|
|
55
|
+
@context_usage = context_usage
|
|
56
|
+
@active_session = nil
|
|
57
|
+
@session_diff = SessionDiff.new
|
|
58
|
+
@cleanup_sessions = []
|
|
59
|
+
@plugin_registry = nil
|
|
60
|
+
@color_enabled = ANSI.enabled?($stdout)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Dispatches command-line modes, including RPC, login, stats export, Pan
|
|
64
|
+
# mode, one-shot prompts, and interactive chat.
|
|
65
|
+
#
|
|
66
|
+
# @return [void]
|
|
67
|
+
def run
|
|
68
|
+
ConfigFiles.ensure_default_config!
|
|
69
|
+
|
|
70
|
+
if @argv == ["--install-starter-pack"]
|
|
71
|
+
install_starter_pack
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if @argv.first == "rpc" && @argv.length == 1
|
|
76
|
+
Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if @argv[0, 2] == ["stats", "tokens"]
|
|
81
|
+
export_token_stats(@argv[2..] || [])
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if pan_mode?
|
|
86
|
+
PanServer.new(client: @client, working_directory: pan_working_directory).run
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if ["login", "--login"].include?(@argv.first) && @argv.length <= 2
|
|
91
|
+
login(provider: @argv[1])
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
first_prompt = @argv.join(" ").strip
|
|
96
|
+
unless first_prompt.empty?
|
|
97
|
+
answer = one_shot(first_prompt)
|
|
98
|
+
puts answer unless answer.empty?
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
stdin_prompt = piped_prompt
|
|
103
|
+
unless stdin_prompt.empty?
|
|
104
|
+
answer = one_shot(stdin_prompt)
|
|
105
|
+
puts answer unless answer.empty?
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
interactive_loop
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def one_shot(input)
|
|
113
|
+
streamed = false
|
|
114
|
+
assistant_streamed = false
|
|
115
|
+
markdown_chunks = []
|
|
116
|
+
conversation = new_conversation
|
|
117
|
+
agent = Agent.new(
|
|
118
|
+
client: @client,
|
|
119
|
+
tool_registry: ToolRegistry.new(prompt: @prompt),
|
|
120
|
+
conversation: conversation
|
|
121
|
+
)
|
|
122
|
+
answer = agent.ask(input) do |event|
|
|
123
|
+
case event
|
|
124
|
+
when Events::ReasoningDelta
|
|
125
|
+
streamed = true
|
|
126
|
+
append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
|
|
127
|
+
when Events::AssistantDelta
|
|
128
|
+
streamed = true
|
|
129
|
+
assistant_streamed = true
|
|
130
|
+
append_markdown_delta(markdown_chunks, "Assistant", event.delta)
|
|
131
|
+
when Events::Retry
|
|
132
|
+
streamed = true
|
|
133
|
+
flush_markdown_deltas(markdown_chunks)
|
|
134
|
+
print_retry(event)
|
|
135
|
+
when Events::ToolCall
|
|
136
|
+
streamed = true
|
|
137
|
+
flush_markdown_deltas(markdown_chunks)
|
|
138
|
+
print_tool_call(event.tool_call)
|
|
139
|
+
when Events::ToolResult
|
|
140
|
+
streamed = true
|
|
141
|
+
flush_markdown_deltas(markdown_chunks)
|
|
142
|
+
print_tool_result(event.tool_call, event.content)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
flush_markdown_deltas(markdown_chunks) if streamed
|
|
146
|
+
assistant_streamed ? "" : render_markdown_transcript(answer)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def login(provider: nil, oauth: nil)
|
|
150
|
+
provider = provider.to_s.downcase
|
|
151
|
+
if provider == "openrouter"
|
|
152
|
+
auth = oauth || OpenRouterAPIKey.new
|
|
153
|
+
path = auth.login(prompt: @prompt)
|
|
154
|
+
@prompt.say("#{colored("Saved", :green, :bold)} OpenRouter API key to #{path}")
|
|
155
|
+
return
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
oauth ||= provider == "github" ? GithubOAuth.new : OpenAIOAuth.new
|
|
159
|
+
path = oauth.login(prompt: @prompt)
|
|
160
|
+
name = provider == "github" ? "GitHub" : "OpenAI"
|
|
161
|
+
@prompt.say("#{colored("Saved", :green, :bold)} #{name} OAuth login to #{path}")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def interactive_loop(agent: nil)
|
|
165
|
+
setup_interactive_prompt
|
|
166
|
+
session_store = interactive_session_store(agent)
|
|
167
|
+
if session_store && agent.nil?
|
|
168
|
+
@active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
169
|
+
reset_session_diff
|
|
170
|
+
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
171
|
+
@active_session.attach(conversation)
|
|
172
|
+
agent = build_interactive_agent(conversation)
|
|
173
|
+
elsif session_store
|
|
174
|
+
@active_session = track_session(session_store.create(model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
175
|
+
reset_session_diff
|
|
176
|
+
@active_session.attach(agent.conversation)
|
|
177
|
+
else
|
|
178
|
+
agent ||= build_interactive_agent(new_conversation)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
update_assistant_prompt(agent.conversation)
|
|
182
|
+
@footer_conversation = agent.conversation
|
|
183
|
+
|
|
184
|
+
print_visual_banner
|
|
185
|
+
|
|
186
|
+
@pending_inputs = []
|
|
187
|
+
|
|
188
|
+
loop do
|
|
189
|
+
input = @pending_inputs.shift || @prompt.ask("You>")
|
|
190
|
+
break if input.nil?
|
|
191
|
+
|
|
192
|
+
display_input = submitted_display_input(input)
|
|
193
|
+
command_input = display_input.nil? ? input : display_input
|
|
194
|
+
command = command_input.strip
|
|
195
|
+
next if command.empty? && input.strip.empty?
|
|
196
|
+
if command.empty?
|
|
197
|
+
handled = false
|
|
198
|
+
else
|
|
199
|
+
selected_input = selected_slash_command_input(command_input)
|
|
200
|
+
if selected_input
|
|
201
|
+
input = selected_input
|
|
202
|
+
command = input.strip
|
|
203
|
+
display_input = input if display_input
|
|
204
|
+
end
|
|
205
|
+
break if ["/exit", "/quit"].include?(command)
|
|
206
|
+
handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
|
|
207
|
+
agent = replacement_agent if replacement_agent
|
|
208
|
+
end
|
|
209
|
+
next if handled
|
|
210
|
+
|
|
211
|
+
expanded_input = expand_prompt_template(input)
|
|
212
|
+
display_input = display_input || input if expanded_input
|
|
213
|
+
input = expanded_input || input
|
|
214
|
+
@footer_conversation = agent.conversation
|
|
215
|
+
begin
|
|
216
|
+
pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
|
|
217
|
+
pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
@prompt.say("\nError: #{e.message}\n")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
agent.conversation
|
|
224
|
+
rescue Interrupt
|
|
225
|
+
@prompt.say("\nGoodbye.")
|
|
226
|
+
agent&.conversation
|
|
227
|
+
ensure
|
|
228
|
+
begin
|
|
229
|
+
@prompt.close if prompt_interface?
|
|
230
|
+
ensure
|
|
231
|
+
cleanup_unused_sessions
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def piped_prompt
|
|
236
|
+
return "" if @stdin.tty?
|
|
237
|
+
|
|
238
|
+
@stdin.read.strip
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private
|
|
242
|
+
|
|
243
|
+
def install_starter_pack
|
|
244
|
+
result = StarterPackInstaller.install
|
|
245
|
+
installed_count = result.installed.length
|
|
246
|
+
skipped_count = result.skipped.length
|
|
247
|
+
@prompt.say("Installed #{installed_count} starter pack file#{installed_count == 1 ? "" : "s"}.")
|
|
248
|
+
@prompt.say("Skipped #{skipped_count} existing starter pack file#{skipped_count == 1 ? "" : "s"}.") if skipped_count.positive?
|
|
249
|
+
rescue StandardError => e
|
|
250
|
+
warn "Failed to install starter pack: #{e.message}"
|
|
251
|
+
exit 1
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def pan_mode?
|
|
255
|
+
@argv.include?("--pan-mode")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def export_token_stats(arguments)
|
|
259
|
+
options = parse_token_stats_options(arguments)
|
|
260
|
+
csv = TelemetryStats.new.token_usage_csv(options[:range], bucket: options[:bucket])
|
|
261
|
+
if options[:output]
|
|
262
|
+
File.write(options[:output], csv)
|
|
263
|
+
else
|
|
264
|
+
$stdout.write(csv)
|
|
265
|
+
end
|
|
266
|
+
rescue ArgumentError => e
|
|
267
|
+
warn e.message
|
|
268
|
+
warn "Usage: kward stats tokens [range] [--bucket second|minute|hour|day|week|month|year] [--output path]"
|
|
269
|
+
exit 1
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def parse_token_stats_options(arguments)
|
|
273
|
+
remaining = []
|
|
274
|
+
bucket = nil
|
|
275
|
+
output = nil
|
|
276
|
+
index = 0
|
|
277
|
+
while index < arguments.length
|
|
278
|
+
argument = arguments[index]
|
|
279
|
+
case argument
|
|
280
|
+
when "--bucket"
|
|
281
|
+
index += 1
|
|
282
|
+
raise ArgumentError, "Missing value for --bucket" if index >= arguments.length
|
|
283
|
+
|
|
284
|
+
bucket = arguments[index]
|
|
285
|
+
when /\A--bucket=(.+)\z/
|
|
286
|
+
bucket = Regexp.last_match(1)
|
|
287
|
+
when "--output"
|
|
288
|
+
index += 1
|
|
289
|
+
raise ArgumentError, "Missing value for --output" if index >= arguments.length
|
|
290
|
+
|
|
291
|
+
output = arguments[index]
|
|
292
|
+
when /\A--output=(.+)\z/
|
|
293
|
+
output = Regexp.last_match(1)
|
|
294
|
+
else
|
|
295
|
+
remaining << argument
|
|
296
|
+
end
|
|
297
|
+
index += 1
|
|
298
|
+
end
|
|
299
|
+
{ range: remaining.join(" "), bucket: bucket, output: output }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def pan_working_directory
|
|
303
|
+
value = option_value("--working-directory")
|
|
304
|
+
value.to_s.strip.empty? ? Dir.pwd : value
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def option_value(name)
|
|
308
|
+
@argv.each_with_index do |argument, index|
|
|
309
|
+
return argument.split("=", 2).last if argument.start_with?("#{name}=")
|
|
310
|
+
return @argv[index + 1] if argument == name
|
|
311
|
+
end
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def interactive_session_store(agent)
|
|
316
|
+
return @session_store if @session_store
|
|
317
|
+
return nil if agent
|
|
318
|
+
|
|
319
|
+
SessionStore.new
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def track_session(session)
|
|
323
|
+
@cleanup_sessions << session if session
|
|
324
|
+
session
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def reset_session_diff(path = nil)
|
|
328
|
+
@session_diff = path ? SessionDiff.from_session_file(path) : SessionDiff.new
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def update_session_diff(content)
|
|
332
|
+
return unless @session_diff&.add_tool_result(content)
|
|
333
|
+
|
|
334
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def cleanup_unused_sessions
|
|
338
|
+
@cleanup_sessions.reverse_each do |session|
|
|
339
|
+
session.delete_if_unused if session.respond_to?(:delete_if_unused)
|
|
340
|
+
end
|
|
341
|
+
@cleanup_sessions.clear
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def cleanup_replaced_session(previous_session)
|
|
345
|
+
return unless previous_session
|
|
346
|
+
return if @active_session && File.expand_path(previous_session.path) == File.expand_path(@active_session.path)
|
|
347
|
+
|
|
348
|
+
previous_session.delete_if_unused if previous_session.respond_to?(:delete_if_unused)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def new_conversation(workspace_root: Dir.pwd)
|
|
352
|
+
Conversation.new(workspace_root: workspace_root, model: current_model_id, reasoning_effort: current_reasoning_effort, plugin_registry: plugin_registry)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def update_assistant_prompt(conversation)
|
|
356
|
+
@assistant_prompt = assistant_prompt_label(conversation)
|
|
357
|
+
@prompt.update_assistant_label(assistant_prompt_name) if @prompt.respond_to?(:update_assistant_label)
|
|
358
|
+
@assistant_prompt
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def assistant_prompt_label(conversation)
|
|
362
|
+
label = ConfigFiles.active_persona_label(workspace_root: conversation.workspace_root, model: conversation.model)
|
|
363
|
+
"#{label || "Assistant"}>"
|
|
364
|
+
rescue StandardError
|
|
365
|
+
"Assistant>"
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def assistant_prompt_name
|
|
369
|
+
assistant_output_prompt.delete_suffix(">")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def assistant_output_prompt
|
|
373
|
+
@assistant_prompt || "Assistant>"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def build_interactive_agent(conversation)
|
|
377
|
+
conversation.plugin_registry ||= plugin_registry if conversation.respond_to?(:plugin_registry)
|
|
378
|
+
workspace = Workspace.new(root: conversation.workspace_root)
|
|
379
|
+
tool_registry = ToolRegistry.new(workspace: workspace, prompt: @prompt)
|
|
380
|
+
@footer_conversation = conversation
|
|
381
|
+
@footer_tool_registry = tool_registry
|
|
382
|
+
Agent.new(
|
|
383
|
+
client: @client,
|
|
384
|
+
tool_registry: tool_registry,
|
|
385
|
+
conversation: conversation
|
|
386
|
+
)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def handle_local_slash_command(command, agent, session_store)
|
|
390
|
+
name, argument = parse_slash_command(command)
|
|
391
|
+
case name
|
|
392
|
+
when "status"
|
|
393
|
+
run_busy_local_command_and_requeue { print_status }
|
|
394
|
+
[true, nil]
|
|
395
|
+
when "stats"
|
|
396
|
+
run_busy_local_command_and_requeue { print_stats(argument) }
|
|
397
|
+
[true, nil]
|
|
398
|
+
when "crew"
|
|
399
|
+
@prompt.say("\nThe /crew command is not implemented yet.\n")
|
|
400
|
+
[true, nil]
|
|
401
|
+
when "memory"
|
|
402
|
+
activity = memory_summarize_command?(argument) ? "summarizing" : "loading"
|
|
403
|
+
run_busy_local_command_and_requeue(activity: activity) { handle_memory_command(argument, agent) }
|
|
404
|
+
[true, nil]
|
|
405
|
+
when "redraw"
|
|
406
|
+
run_busy_local_command_and_requeue { @prompt.redraw if @prompt.respond_to?(:redraw) }
|
|
407
|
+
[true, nil]
|
|
408
|
+
when "settings"
|
|
409
|
+
configure_settings
|
|
410
|
+
[true, nil]
|
|
411
|
+
when "login"
|
|
412
|
+
login_interactively
|
|
413
|
+
[true, nil]
|
|
414
|
+
when "model"
|
|
415
|
+
models = run_busy_local_command_and_requeue { normalized_available_models }
|
|
416
|
+
configure_model(agent.conversation, models: models)
|
|
417
|
+
[true, nil]
|
|
418
|
+
when "openrouter/catalog"
|
|
419
|
+
run_busy_local_command_and_requeue { print_openrouter_catalog }
|
|
420
|
+
[true, nil]
|
|
421
|
+
when "reasoning"
|
|
422
|
+
configure_reasoning(agent.conversation)
|
|
423
|
+
[true, nil]
|
|
424
|
+
when "new"
|
|
425
|
+
[true, run_busy_local_command_and_requeue { start_new_session(session_store) }]
|
|
426
|
+
when "resume"
|
|
427
|
+
[true, run_busy_local_command_and_requeue do
|
|
428
|
+
path = argument.to_s.strip
|
|
429
|
+
path = select_session_path(session_store) if session_store && path.empty?
|
|
430
|
+
resume_session(session_store, path)
|
|
431
|
+
end]
|
|
432
|
+
when "name"
|
|
433
|
+
run_busy_local_command_and_requeue { rename_session(argument) }
|
|
434
|
+
[true, nil]
|
|
435
|
+
when "clone"
|
|
436
|
+
[true, run_busy_local_command_and_requeue { clone_session(session_store, agent) }]
|
|
437
|
+
when "copy"
|
|
438
|
+
run_busy_local_command_and_requeue { copy_session_text(agent.conversation, argument) }
|
|
439
|
+
[true, nil]
|
|
440
|
+
when "export"
|
|
441
|
+
run_busy_local_command_and_requeue { export_session(agent.conversation, argument) }
|
|
442
|
+
[true, nil]
|
|
443
|
+
when "compact"
|
|
444
|
+
run_busy_local_command_and_requeue(activity: "compacting") { compact_context(agent, argument) }
|
|
445
|
+
[true, nil]
|
|
446
|
+
else
|
|
447
|
+
return run_plugin_command(name, argument, agent) if plugin_command_for(name)
|
|
448
|
+
|
|
449
|
+
[false, nil]
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def parse_slash_command(command)
|
|
454
|
+
PromptCommands.parse(command) || [nil, ""]
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def memory_summarize_command?(argument)
|
|
458
|
+
subcommand, = argument.to_s.strip.split(/\s+/, 2)
|
|
459
|
+
["summarize", "learn"].include?(subcommand)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def print_status
|
|
463
|
+
lines = [STATUS_MESSAGE]
|
|
464
|
+
lines << ""
|
|
465
|
+
lines << auto_compaction_status_line
|
|
466
|
+
if @active_session
|
|
467
|
+
lines << "Session: #{@active_session.name || @active_session.id}"
|
|
468
|
+
lines << "File: #{@active_session.path}"
|
|
469
|
+
end
|
|
470
|
+
lines.compact!
|
|
471
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{lines.join("\n")}\n")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def auto_compaction_status_line
|
|
475
|
+
settings = Kward::Compaction::Settings.from_config
|
|
476
|
+
return "Auto-compaction: disabled" unless settings.enabled
|
|
477
|
+
|
|
478
|
+
context_window = composer_context_window
|
|
479
|
+
return "Auto-compaction: enabled, unknown context window" unless context_window.to_i.positive?
|
|
480
|
+
|
|
481
|
+
reserve_tokens = Kward::Compactor.auto_compaction_reserve_tokens(
|
|
482
|
+
context_window: context_window,
|
|
483
|
+
configured_reserve_tokens: settings.reserve_tokens
|
|
484
|
+
)
|
|
485
|
+
percent = ((reserve_tokens.to_f / context_window.to_i) * 100).round(1)
|
|
486
|
+
"Auto-compaction reserve: #{reserve_tokens} tokens (#{percent}% of #{context_window})"
|
|
487
|
+
rescue StandardError => e
|
|
488
|
+
warn "Auto-compaction status unavailable: #{e.message}"
|
|
489
|
+
nil
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def print_stats(argument)
|
|
493
|
+
result = TelemetryStats.new.collect(argument)
|
|
494
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{TelemetryStats.format(result)}\n")
|
|
495
|
+
rescue ArgumentError => e
|
|
496
|
+
message = e.message == TelemetryStats::USAGE ? e.message : "#{e.message}\n#{TelemetryStats::USAGE}"
|
|
497
|
+
@prompt.say("\n#{message}\n")
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def handle_memory_command(argument, agent)
|
|
501
|
+
subcommand, rest = argument.to_s.strip.split(/\s+/, 2)
|
|
502
|
+
manager = Memory::Manager.new
|
|
503
|
+
case subcommand
|
|
504
|
+
when "enable"
|
|
505
|
+
manager.enable
|
|
506
|
+
agent.conversation.refresh_system_message!
|
|
507
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory enabled.\n")
|
|
508
|
+
when "disable"
|
|
509
|
+
manager.disable
|
|
510
|
+
agent.conversation.memory_context = nil
|
|
511
|
+
agent.conversation.refresh_system_message!
|
|
512
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory disabled.\n")
|
|
513
|
+
when "auto-summary"
|
|
514
|
+
case rest.to_s.strip
|
|
515
|
+
when "enable", "on"
|
|
516
|
+
manager.auto_summary_enable
|
|
517
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary enabled.\n")
|
|
518
|
+
when "disable", "off"
|
|
519
|
+
manager.auto_summary_disable
|
|
520
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Memory auto-summary disabled.\n")
|
|
521
|
+
else
|
|
522
|
+
@prompt.say("\nUsage: /memory auto-summary enable|disable\n")
|
|
523
|
+
end
|
|
524
|
+
when "core"
|
|
525
|
+
record = manager.add_core(unquote_argument(rest))
|
|
526
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added core memory #{record["id"]}.\n")
|
|
527
|
+
when "add"
|
|
528
|
+
record = manager.add_soft(unquote_argument(rest), scope: "workspace:#{agent.conversation.workspace_root}")
|
|
529
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Added soft memory #{record["id"]}.\n")
|
|
530
|
+
when "list"
|
|
531
|
+
@prompt.say("\n#{format_memory_list(manager.list)}\n")
|
|
532
|
+
when "forget"
|
|
533
|
+
forgotten = manager.forget_memory(rest.to_s.strip)
|
|
534
|
+
@prompt.say("\n#{forgotten ? "Forgot #{rest.to_s.strip}." : "No memory found for #{rest.to_s.strip}."}\n")
|
|
535
|
+
when "promote"
|
|
536
|
+
record = manager.promote_soft_to_core(rest.to_s.strip)
|
|
537
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Promoted to core memory #{record["id"]}.\n")
|
|
538
|
+
when "inspect"
|
|
539
|
+
@prompt.say("\n#{JSON.pretty_generate(manager.inspect_memory)}\n")
|
|
540
|
+
when "why"
|
|
541
|
+
explanation = agent.conversation.last_memory_retrieval || manager.explain_retrieval
|
|
542
|
+
@prompt.say("\n#{format_memory_why(explanation)}\n")
|
|
543
|
+
when "summarize", "learn"
|
|
544
|
+
records = summarize_memory(agent.conversation, manager: manager)
|
|
545
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} Learned #{records.length} soft #{records.length == 1 ? "memory" : "memories"}.\n")
|
|
546
|
+
else
|
|
547
|
+
@prompt.say("\nUsage: /memory enable|disable|auto-summary enable|disable|core <text>|add <text>|list|forget <id>|promote <id>|inspect|why|summarize\n")
|
|
548
|
+
end
|
|
549
|
+
rescue StandardError => e
|
|
550
|
+
@prompt.say("\nMemory command failed: #{e.message}\n")
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def summarize_memory(conversation, manager: Memory::Manager.new)
|
|
554
|
+
records = manager.summarize_conversation(conversation, client: @client)
|
|
555
|
+
@active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
|
|
556
|
+
records
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def unquote_argument(text)
|
|
560
|
+
value = text.to_s.strip
|
|
561
|
+
value = value[1...-1] if value.length >= 2 && ((value.start_with?("\"") && value.end_with?("\"")) || (value.start_with?("'") && value.end_with?("'")))
|
|
562
|
+
value
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def format_memory_list(memories)
|
|
566
|
+
lines = ["Core Memories:"]
|
|
567
|
+
Array(memories["core"]).each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
|
|
568
|
+
lines << "- none" if Array(memories["core"]).empty?
|
|
569
|
+
lines << "Soft Memories:"
|
|
570
|
+
Array(memories["soft"]).each { |item| lines << "- #{item["id"]} [#{item["scope"]}] #{item["text"]}" }
|
|
571
|
+
lines << "- none" if Array(memories["soft"]).empty?
|
|
572
|
+
lines.join("\n")
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def format_memory_why(explanation)
|
|
576
|
+
reasons = Array(explanation["reasons"])
|
|
577
|
+
return explanation["message"] || "No memories were retrieved." if reasons.empty?
|
|
578
|
+
|
|
579
|
+
(["Memory retrieval reasons:"] + reasons.map { |item| "- #{item["id"]} (#{item["layer"]}, score #{item["score"]}): #{Array(item["reasons"]).join("; ")}" }).join("\n")
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def run_busy_local_command(activity: "loading")
|
|
583
|
+
return yield unless prompt_interface?
|
|
584
|
+
|
|
585
|
+
queued_inputs = []
|
|
586
|
+
result = nil
|
|
587
|
+
error = nil
|
|
588
|
+
@prompt.begin_busy_input("You>", activity: activity) if @prompt.respond_to?(:begin_busy_input)
|
|
589
|
+
|
|
590
|
+
worker = Thread.new do
|
|
591
|
+
result = yield
|
|
592
|
+
rescue StandardError => e
|
|
593
|
+
error = e
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
while worker.alive?
|
|
597
|
+
collect_queued_input(queued_inputs)
|
|
598
|
+
sleep 0.02
|
|
599
|
+
end
|
|
600
|
+
worker.join
|
|
601
|
+
drain_queued_input(queued_inputs)
|
|
602
|
+
raise error if error
|
|
603
|
+
|
|
604
|
+
[result, queued_inputs]
|
|
605
|
+
ensure
|
|
606
|
+
@prompt.finish_busy_input if prompt_interface? && @prompt.respond_to?(:finish_busy_input)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def run_busy_local_command_and_requeue(activity: "loading")
|
|
610
|
+
return yield unless prompt_interface?
|
|
611
|
+
|
|
612
|
+
result, queued_inputs = run_busy_local_command(activity: activity) { yield }
|
|
613
|
+
queued_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
|
|
614
|
+
result
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def current_workspace_root
|
|
618
|
+
return @active_session.cwd.to_s unless @active_session&.cwd.to_s.empty?
|
|
619
|
+
|
|
620
|
+
Dir.pwd
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def configure_settings
|
|
624
|
+
unless settings_overlay_available?
|
|
625
|
+
@prompt.say("\nSettings overlay is unavailable in this prompt.\n")
|
|
626
|
+
return
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
settings = ConfigFiles.overlay_settings
|
|
630
|
+
alignment = choose_overlay_setting("Overlay alignment", overlay_alignment_choices(settings), ConfigFiles::OVERLAY_ALIGNMENTS)
|
|
631
|
+
return unless alignment
|
|
632
|
+
|
|
633
|
+
settings = ConfigFiles.update_overlay_settings("alignment" => alignment)
|
|
634
|
+
@prompt.update_overlay_settings(settings)
|
|
635
|
+
|
|
636
|
+
width = choose_overlay_setting("Overlay width", overlay_width_choices(settings), ConfigFiles::OVERLAY_WIDTHS)
|
|
637
|
+
return unless width
|
|
638
|
+
|
|
639
|
+
settings = ConfigFiles.update_overlay_settings("width" => width)
|
|
640
|
+
@prompt.update_overlay_settings(settings)
|
|
641
|
+
@prompt.say("\nSaved overlay settings.\n")
|
|
642
|
+
rescue StandardError => e
|
|
643
|
+
@prompt.say("\nSettings error: #{e.message}\n")
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def login_interactively
|
|
647
|
+
unless login_picker_available?
|
|
648
|
+
@prompt.say("\nLogin provider picker is unavailable in this prompt.\n")
|
|
649
|
+
return
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
selected = @prompt.select("OAuth provider", login_provider_choices, title: "Login")
|
|
653
|
+
provider = selected_login_provider(selected)
|
|
654
|
+
return unless provider
|
|
655
|
+
|
|
656
|
+
login(provider: provider)
|
|
657
|
+
reload_client_config
|
|
658
|
+
rescue StandardError => e
|
|
659
|
+
@prompt.say("\nLogin error: #{e.message}\n")
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def configure_model(conversation = nil, models: nil)
|
|
663
|
+
unless model_overlay_available?
|
|
664
|
+
@prompt.say("\nModel overlay is unavailable in this prompt.\n")
|
|
665
|
+
return
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
models ||= normalized_available_models
|
|
669
|
+
choices = model_choices(models)
|
|
670
|
+
selected = @prompt.select("Default model", choices, title: "Models", custom: true)
|
|
671
|
+
return unless selected
|
|
672
|
+
|
|
673
|
+
provider, model = selected_model(selected, models)
|
|
674
|
+
raise "Model must be a non-empty string" if model.to_s.strip.empty?
|
|
675
|
+
|
|
676
|
+
ConfigFiles.update_config(ModelInfo.config_values_for_selection(provider, model))
|
|
677
|
+
reload_client_config
|
|
678
|
+
refresh_conversation_runtime(conversation)
|
|
679
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
680
|
+
rescue StandardError => e
|
|
681
|
+
@prompt.say("\nModel error: #{e.message}\n")
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def print_openrouter_catalog
|
|
685
|
+
unless @client.respond_to?(:openrouter_catalog)
|
|
686
|
+
@prompt.say("\nOpenRouter catalog is unavailable for this client.\n")
|
|
687
|
+
return
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
models = Array(@client.openrouter_catalog)
|
|
691
|
+
if models.empty?
|
|
692
|
+
@prompt.say("\nNo OpenRouter catalog models available.\n")
|
|
693
|
+
else
|
|
694
|
+
ids = models.map { |model| model[:id] || model["id"] || model }.map(&:to_s).reject(&:empty?)
|
|
695
|
+
@prompt.say("\nOpenRouter catalog:\n#{ids.join("\n")}\n")
|
|
696
|
+
end
|
|
697
|
+
rescue StandardError => e
|
|
698
|
+
@prompt.say("\nOpenRouter catalog error: #{e.message}\n")
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
def configure_reasoning(conversation = nil)
|
|
702
|
+
unless model_overlay_available?
|
|
703
|
+
@prompt.say("\nReasoning overlay is unavailable in this prompt.\n")
|
|
704
|
+
return
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
choices = ModelInfo::REASONING_EFFORT_CHOICES
|
|
708
|
+
selected = @prompt.select("Reasoning effort", reasoning_choices(choices), title: "Reasoning")
|
|
709
|
+
return unless selected
|
|
710
|
+
|
|
711
|
+
effort, = choices.find { |_value, label| selected.to_s.downcase.start_with?(label.downcase) }
|
|
712
|
+
raise "Reasoning effort must be low, medium, high, or extra high" unless effort
|
|
713
|
+
|
|
714
|
+
ConfigFiles.update_config(ModelInfo.reasoning_config_key_for_provider(current_model_provider) => effort)
|
|
715
|
+
reload_client_config
|
|
716
|
+
refresh_conversation_runtime(conversation)
|
|
717
|
+
@prompt.redraw if @prompt.respond_to?(:redraw)
|
|
718
|
+
rescue StandardError => e
|
|
719
|
+
@prompt.say("\nReasoning error: #{e.message}\n")
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
def login_picker_available?
|
|
723
|
+
@prompt.respond_to?(:select)
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
def login_provider_choices
|
|
727
|
+
["OpenAI", "OpenRouter", "GitHub"]
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
def selected_login_provider(selected)
|
|
731
|
+
case selected.to_s.downcase
|
|
732
|
+
when /\Aopenai\b/
|
|
733
|
+
"openai"
|
|
734
|
+
when /\Aopenrouter\b/
|
|
735
|
+
"openrouter"
|
|
736
|
+
when /\Agithub\b/
|
|
737
|
+
"github"
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def model_overlay_available?
|
|
742
|
+
@prompt.respond_to?(:select)
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
def settings_overlay_available?
|
|
746
|
+
@prompt.respond_to?(:select) && @prompt.respond_to?(:update_overlay_settings)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
def choose_overlay_setting(message, choices, values)
|
|
750
|
+
choice = @prompt.select(message, choices, title: "Settings")
|
|
751
|
+
return nil unless choice
|
|
752
|
+
|
|
753
|
+
values.find { |value| choice.to_s.downcase.start_with?(value) }
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
def normalized_available_models
|
|
757
|
+
current_provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
758
|
+
current_model = @client.respond_to?(:current_model) ? @client.current_model : nil
|
|
759
|
+
current_reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : nil
|
|
760
|
+
models = @client.respond_to?(:available_models) ? Array(@client.available_models) : []
|
|
761
|
+
models.map do |model|
|
|
762
|
+
ModelInfo.normalize(
|
|
763
|
+
model,
|
|
764
|
+
current_provider: current_provider,
|
|
765
|
+
current_model: current_model,
|
|
766
|
+
current_reasoning_effort: current_reasoning
|
|
767
|
+
)
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
def model_choices(models)
|
|
772
|
+
choices = models.map do |model|
|
|
773
|
+
label = "#{model[:provider]} #{model[:id]}"
|
|
774
|
+
label += " (current)" if model[:current]
|
|
775
|
+
label
|
|
776
|
+
end
|
|
777
|
+
choices.empty? ? ["#{current_model_provider} #{current_model_id} (current)"] : choices.uniq
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def selected_model(selected, models)
|
|
781
|
+
text = selected.to_s.sub(/ \(current\)\z/, "").strip
|
|
782
|
+
known = models.find { |model| "#{model[:provider]} #{model[:id]}" == text }
|
|
783
|
+
return [known[:provider], known[:id]] if known
|
|
784
|
+
|
|
785
|
+
provider, model = text.split(/\s+/, 2)
|
|
786
|
+
if ["Codex", "OpenRouter", "Copilot"].include?(provider) && !model.to_s.strip.empty?
|
|
787
|
+
[provider, model.strip]
|
|
788
|
+
else
|
|
789
|
+
[current_model_provider, text]
|
|
790
|
+
end
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def reasoning_choices(choices)
|
|
794
|
+
current = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort.to_s : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
795
|
+
choices.map do |effort, label|
|
|
796
|
+
text = label.dup
|
|
797
|
+
text += " (current)" if current == effort
|
|
798
|
+
text
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def current_model_provider
|
|
803
|
+
@client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def current_model_id
|
|
807
|
+
@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
def current_reasoning_effort
|
|
811
|
+
@client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
812
|
+
end
|
|
813
|
+
|
|
814
|
+
def reload_client_config
|
|
815
|
+
@client.reload_config if @client.respond_to?(:reload_config)
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def refresh_conversation_runtime(conversation)
|
|
819
|
+
return unless conversation&.respond_to?(:update_runtime_context!)
|
|
820
|
+
|
|
821
|
+
conversation.update_runtime_context!(model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
822
|
+
@active_session.update_runtime(model: conversation.model, reasoning_effort: conversation.reasoning_effort) if @active_session&.respond_to?(:update_runtime)
|
|
823
|
+
update_assistant_prompt(conversation)
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def overlay_alignment_choices(settings)
|
|
827
|
+
ConfigFiles::OVERLAY_ALIGNMENTS.map do |alignment|
|
|
828
|
+
label = alignment.capitalize
|
|
829
|
+
label += " (current)" if settings["alignment"] == alignment
|
|
830
|
+
label
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
def overlay_width_choices(settings)
|
|
835
|
+
ConfigFiles::OVERLAY_WIDTHS.map do |width|
|
|
836
|
+
label = width.capitalize
|
|
837
|
+
label += " (current)" if settings["width"] == width
|
|
838
|
+
label
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def start_new_session(session_store)
|
|
843
|
+
return say_sessions_unavailable unless session_store
|
|
844
|
+
|
|
845
|
+
previous_session = @active_session
|
|
846
|
+
@active_session = track_session(session_store.create)
|
|
847
|
+
reset_session_diff
|
|
848
|
+
cleanup_replaced_session(previous_session)
|
|
849
|
+
conversation = new_conversation(workspace_root: session_store.cwd)
|
|
850
|
+
@active_session.attach(conversation)
|
|
851
|
+
update_assistant_prompt(conversation)
|
|
852
|
+
clear_prompt_transcript
|
|
853
|
+
print_visual_banner
|
|
854
|
+
build_interactive_agent(conversation)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def resume_session(session_store, argument)
|
|
858
|
+
return say_sessions_unavailable unless session_store
|
|
859
|
+
|
|
860
|
+
path = argument.to_s.strip
|
|
861
|
+
path = select_session_path(session_store) if path.empty?
|
|
862
|
+
return nil if path.to_s.empty?
|
|
863
|
+
|
|
864
|
+
previous_session = @active_session
|
|
865
|
+
@active_session, conversation = session_store.load(path, workspace: Workspace.new(root: session_store.cwd), model: current_model_id, reasoning_effort: current_reasoning_effort)
|
|
866
|
+
reset_session_diff(@active_session.path)
|
|
867
|
+
track_session(@active_session)
|
|
868
|
+
cleanup_replaced_session(previous_session)
|
|
869
|
+
update_assistant_prompt(conversation)
|
|
870
|
+
restore_prompt_transcript do
|
|
871
|
+
@prompt.say("\nResumed session: #{@active_session.path}\n")
|
|
872
|
+
render_conversation_transcript(conversation)
|
|
873
|
+
end
|
|
874
|
+
agent = build_interactive_agent(conversation)
|
|
875
|
+
@prompt.redraw if @prompt.respond_to?(:redraw) && !@prompt.respond_to?(:restore_transcript)
|
|
876
|
+
agent
|
|
877
|
+
rescue StandardError => e
|
|
878
|
+
@prompt.say("\nError: #{e.message}\n")
|
|
879
|
+
nil
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def rename_session(argument)
|
|
883
|
+
unless @active_session
|
|
884
|
+
@prompt.say("\nNo active persisted session.\n")
|
|
885
|
+
return
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
@active_session.rename(argument)
|
|
889
|
+
label = @active_session.name ? "Named session: #{@active_session.name}" : "Cleared session name."
|
|
890
|
+
@prompt.say("\n#{label}\n")
|
|
891
|
+
end
|
|
892
|
+
|
|
893
|
+
def clone_session(session_store, agent)
|
|
894
|
+
return say_sessions_unavailable unless session_store
|
|
895
|
+
|
|
896
|
+
previous_session = @active_session
|
|
897
|
+
@active_session = track_session(session_store.create_from_conversation(agent.conversation, parent_session: previous_session))
|
|
898
|
+
reset_session_diff(@active_session.path)
|
|
899
|
+
cleanup_replaced_session(previous_session)
|
|
900
|
+
@prompt.say("\nCloned session: #{@active_session.path}\n")
|
|
901
|
+
render_conversation_transcript(agent.conversation)
|
|
902
|
+
agent
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def copy_session_text(conversation, argument)
|
|
906
|
+
target = copy_target(argument)
|
|
907
|
+
unless target
|
|
908
|
+
@prompt.say("\nUsage: /copy [last|transcript]\n")
|
|
909
|
+
return
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
content = copy_target_content(conversation, target)
|
|
913
|
+
if content.to_s.empty?
|
|
914
|
+
@prompt.say("\nNothing to copy.\n")
|
|
915
|
+
return
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
result = Clipboard.new(output: $stdout).copy(content)
|
|
919
|
+
if result.success?
|
|
920
|
+
@prompt.say("\nCopied #{copy_target_label(target)}.\n")
|
|
921
|
+
else
|
|
922
|
+
@prompt.say("\nCopy failed: #{result.message}.\n")
|
|
923
|
+
end
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
def copy_target(argument)
|
|
927
|
+
target = argument.to_s.strip.downcase
|
|
928
|
+
target = "last" if target.empty?
|
|
929
|
+
return target if ["last", "transcript"].include?(target)
|
|
930
|
+
|
|
931
|
+
nil
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
def copy_target_content(conversation, target)
|
|
935
|
+
case target
|
|
936
|
+
when "last"
|
|
937
|
+
last_assistant_copy_text(conversation)
|
|
938
|
+
when "transcript"
|
|
939
|
+
markdown_transcript(conversation)
|
|
940
|
+
else
|
|
941
|
+
""
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
def last_assistant_copy_text(conversation)
|
|
946
|
+
message = conversation.messages.reverse.find { |item| message_role(item) == "assistant" }
|
|
947
|
+
return "" unless message
|
|
948
|
+
|
|
949
|
+
message_content_text(message_content(message))
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def copy_target_label(target)
|
|
953
|
+
target == "transcript" ? "transcript" : "last assistant response"
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def compact_context(agent, argument)
|
|
957
|
+
result = Compactor.new(
|
|
958
|
+
conversation: agent.conversation,
|
|
959
|
+
client: @client,
|
|
960
|
+
tool_result_summarizer: lambda { |tool_call, content| tool_result_summary(tool_call, content) }
|
|
961
|
+
).compact(custom_instructions: argument)
|
|
962
|
+
@prompt.say("\nCompacted context: #{result.old_message_count} messages -> #{result.new_message_count} messages.\n")
|
|
963
|
+
render_transcript_block("Assistant", result.summary)
|
|
964
|
+
rescue Compactor::NothingToCompact, Compactor::AlreadyCompacted, Compactor::EmptySummary => e
|
|
965
|
+
@prompt.say("\n#{e.message}\n")
|
|
966
|
+
rescue StandardError => e
|
|
967
|
+
@prompt.say("\nCompaction error: #{e.message}\n")
|
|
968
|
+
end
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def render_conversation_transcript(conversation)
|
|
972
|
+
tool_calls_by_id = {}
|
|
973
|
+
@prompt.say("\n#{colored("Transcript", :cyan, :bold)}\n")
|
|
974
|
+
conversation.messages.each do |message|
|
|
975
|
+
role = message_role(message)
|
|
976
|
+
next if role == "system"
|
|
977
|
+
|
|
978
|
+
case role
|
|
979
|
+
when "user"
|
|
980
|
+
print_user_transcript(
|
|
981
|
+
message_user_transcript_input(message),
|
|
982
|
+
display_input: message_user_display_text(message),
|
|
983
|
+
attachment_references: message_image_references(message),
|
|
984
|
+
image_parts: message_image_parts(message)
|
|
985
|
+
)
|
|
986
|
+
when "assistant"
|
|
987
|
+
render_reasoning(message)
|
|
988
|
+
render_assistant_message(message)
|
|
989
|
+
message_tool_calls(message).each do |tool_call|
|
|
990
|
+
tool_calls_by_id[tool_call_id(tool_call)] = tool_call
|
|
991
|
+
render_tool_call(tool_call)
|
|
992
|
+
end
|
|
993
|
+
when "tool"
|
|
994
|
+
render_tool_message(message, tool_calls_by_id)
|
|
995
|
+
when "compactionSummary"
|
|
996
|
+
render_transcript_block("Compaction summary", message_summary(message))
|
|
997
|
+
else
|
|
998
|
+
render_transcript_block(role.to_s.capitalize, message_content_text(message_content(message)))
|
|
999
|
+
end
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
|
|
1003
|
+
def render_reasoning(message)
|
|
1004
|
+
reasoning = message_reasoning(message)
|
|
1005
|
+
render_transcript_block("Reasoning", reasoning) unless reasoning.empty?
|
|
1006
|
+
end
|
|
1007
|
+
|
|
1008
|
+
def render_assistant_message(message)
|
|
1009
|
+
content = message_content_text(message_content(message))
|
|
1010
|
+
return if content.empty?
|
|
1011
|
+
|
|
1012
|
+
render_transcript_block("Assistant", content)
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
def render_tool_message(message, tool_calls_by_id)
|
|
1016
|
+
tool_call = tool_calls_by_id[message_tool_call_id(message)] || synthetic_tool_call(message_name(message), message_tool_call_id(message))
|
|
1017
|
+
render_tool_result(tool_call, message_content(message).to_s)
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
def render_tool_call(tool_call)
|
|
1021
|
+
if prompt_interface?
|
|
1022
|
+
print_tool_call(tool_call)
|
|
1023
|
+
else
|
|
1024
|
+
@prompt.say("\n#{colored("Tool>", :magenta, :bold)}\n#{tool_command(tool_call)}\n")
|
|
1025
|
+
end
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
def render_tool_result(tool_call, content)
|
|
1029
|
+
summary = limit_tool_output_lines(tool_result_summary(tool_call, content), INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
1030
|
+
if prompt_interface?
|
|
1031
|
+
print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
1032
|
+
else
|
|
1033
|
+
@prompt.say("\n#{colored("Tool output>", :cyan, :bold)}\n#{summary}\n")
|
|
1034
|
+
end
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
def render_transcript_block(label, content)
|
|
1038
|
+
return if content.to_s.empty?
|
|
1039
|
+
|
|
1040
|
+
rendered = render_markdown_transcript(content)
|
|
1041
|
+
if prompt_interface?
|
|
1042
|
+
print_block_delta(label, rendered)
|
|
1043
|
+
finish_stream_block
|
|
1044
|
+
else
|
|
1045
|
+
@prompt.say("\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}\n#{rendered}\n")
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def render_markdown_transcript(content)
|
|
1050
|
+
ANSI.markdown(content, enabled: @color_enabled)
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
def append_markdown_delta(chunks, label, delta)
|
|
1054
|
+
text = delta.to_s
|
|
1055
|
+
return if text.empty?
|
|
1056
|
+
|
|
1057
|
+
if chunks.last&.first == label
|
|
1058
|
+
chunks.last[1] << text
|
|
1059
|
+
else
|
|
1060
|
+
chunks << [label, +text]
|
|
1061
|
+
end
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
def flush_markdown_deltas(chunks, finish: true, streams: nil)
|
|
1065
|
+
wrote = false
|
|
1066
|
+
entries = ordered_markdown_entries(chunks.dup)
|
|
1067
|
+
if finish && streams
|
|
1068
|
+
streamed_labels = entries.map(&:first)
|
|
1069
|
+
entries = ordered_markdown_entries(entries.concat(streams.keys.reject { |label| streamed_labels.include?(label) }.map { |label| [label, ""] }))
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
entries.each do |label, content|
|
|
1073
|
+
next if content.empty? && !(finish && streams&.key?(label))
|
|
1074
|
+
|
|
1075
|
+
rendered = if streams
|
|
1076
|
+
streams[label] ||= ANSI::MarkdownStream.new(enabled: @color_enabled)
|
|
1077
|
+
streams[label].render(content, final: finish)
|
|
1078
|
+
else
|
|
1079
|
+
render_markdown_transcript(content)
|
|
1080
|
+
end
|
|
1081
|
+
streams.delete(label) if finish && streams
|
|
1082
|
+
next if rendered.empty?
|
|
1083
|
+
|
|
1084
|
+
print_block_delta(label, rendered)
|
|
1085
|
+
finish_stream_block if finish
|
|
1086
|
+
wrote = true
|
|
1087
|
+
end
|
|
1088
|
+
chunks.clear
|
|
1089
|
+
wrote
|
|
1090
|
+
end
|
|
1091
|
+
|
|
1092
|
+
def ordered_markdown_entries(entries)
|
|
1093
|
+
labels = entries.map(&:first)
|
|
1094
|
+
return entries unless labels.include?("Reasoning") && labels.include?("Assistant")
|
|
1095
|
+
|
|
1096
|
+
grouped = { "Reasoning" => +"", "Assistant" => +"" }
|
|
1097
|
+
others = []
|
|
1098
|
+
entries.each do |label, content|
|
|
1099
|
+
if grouped.key?(label)
|
|
1100
|
+
grouped[label] << content.to_s
|
|
1101
|
+
else
|
|
1102
|
+
others << [label, content]
|
|
1103
|
+
end
|
|
1104
|
+
end
|
|
1105
|
+
|
|
1106
|
+
[["Reasoning", grouped["Reasoning"]], ["Assistant", grouped["Assistant"]]] + others
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
def message_reasoning(message)
|
|
1110
|
+
direct = message["reasoning_summary"] || message[:reasoning_summary]
|
|
1111
|
+
return direct.to_s unless direct.to_s.empty?
|
|
1112
|
+
|
|
1113
|
+
content = message_content(message)
|
|
1114
|
+
return "" unless content.is_a?(Array)
|
|
1115
|
+
|
|
1116
|
+
content.filter_map do |part|
|
|
1117
|
+
type = part["type"] || part[:type]
|
|
1118
|
+
next unless ["thinking", "reasoning"].include?(type)
|
|
1119
|
+
|
|
1120
|
+
part["thinking"] || part[:thinking] || part["text"] || part[:text]
|
|
1121
|
+
end.join("\n")
|
|
1122
|
+
end
|
|
1123
|
+
|
|
1124
|
+
def message_content_text(content)
|
|
1125
|
+
case content
|
|
1126
|
+
when Array
|
|
1127
|
+
content.filter_map do |part|
|
|
1128
|
+
type = part["type"] || part[:type]
|
|
1129
|
+
if type == "text"
|
|
1130
|
+
part["text"] || part[:text]
|
|
1131
|
+
elsif type == "image"
|
|
1132
|
+
path = part["path"] || part[:path]
|
|
1133
|
+
media_type = part["media_type"] || part[:media_type] || "image"
|
|
1134
|
+
"[#{media_type}#{path ? ": #{path}" : ""}]"
|
|
1135
|
+
end
|
|
1136
|
+
end.join("\n")
|
|
1137
|
+
else
|
|
1138
|
+
content.to_s
|
|
1139
|
+
end
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def message_display_text(message)
|
|
1143
|
+
display_content = MessageAccess.display_content(message)
|
|
1144
|
+
return display_content.to_s unless display_content.nil?
|
|
1145
|
+
|
|
1146
|
+
message_content_text(message_content(message))
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
def message_user_display_text(message)
|
|
1150
|
+
display_content = MessageAccess.display_content(message)
|
|
1151
|
+
return display_content.to_s unless display_content.nil?
|
|
1152
|
+
|
|
1153
|
+
content = message_content(message)
|
|
1154
|
+
return content.to_s unless content.is_a?(Array)
|
|
1155
|
+
|
|
1156
|
+
text = content.filter_map do |part|
|
|
1157
|
+
type = part["type"] || part[:type]
|
|
1158
|
+
next unless type == "text"
|
|
1159
|
+
|
|
1160
|
+
part["text"] || part[:text]
|
|
1161
|
+
end.join("\n")
|
|
1162
|
+
Kward::ImageAttachments.display_text_without_references(text, Kward::ImageAttachments.references_from_text(text).select { |reference| reference[:status] == :attached })
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
def message_user_transcript_input(message)
|
|
1166
|
+
content = message_content(message)
|
|
1167
|
+
return content.to_s unless content.is_a?(Array)
|
|
1168
|
+
|
|
1169
|
+
message_user_display_text(message)
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
def message_image_parts(message)
|
|
1173
|
+
content = message_content(message)
|
|
1174
|
+
return [] unless content.is_a?(Array)
|
|
1175
|
+
|
|
1176
|
+
content.select do |part|
|
|
1177
|
+
type = part["type"] || part[:type]
|
|
1178
|
+
type == "image"
|
|
1179
|
+
end
|
|
1180
|
+
end
|
|
1181
|
+
|
|
1182
|
+
def message_image_references(message)
|
|
1183
|
+
message_image_parts(message).map { |part| image_part_reference(part) }
|
|
1184
|
+
end
|
|
1185
|
+
|
|
1186
|
+
def image_part_reference(part)
|
|
1187
|
+
data = part[:data] || part["data"]
|
|
1188
|
+
path = part[:path] || part["path"]
|
|
1189
|
+
media_type = part[:media_type] || part["media_type"] || part[:mimeType] || part["mimeType"] || "image"
|
|
1190
|
+
{
|
|
1191
|
+
status: :attached,
|
|
1192
|
+
type: "image",
|
|
1193
|
+
label: path.to_s.empty? ? "pasted image" : File.basename(path),
|
|
1194
|
+
media_type: media_type,
|
|
1195
|
+
size_bytes: decoded_image_size(data),
|
|
1196
|
+
path: path
|
|
1197
|
+
}
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
def decoded_image_size(data)
|
|
1201
|
+
return nil if data.to_s.empty?
|
|
1202
|
+
|
|
1203
|
+
Base64.decode64(data.to_s.gsub(/\s+/, "")).bytesize
|
|
1204
|
+
rescue ArgumentError
|
|
1205
|
+
nil
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
def synthetic_tool_call(name, id)
|
|
1209
|
+
{
|
|
1210
|
+
"id" => id || "restored_tool",
|
|
1211
|
+
"type" => "function",
|
|
1212
|
+
"function" => { "name" => name || "tool", "arguments" => "{}" }
|
|
1213
|
+
}
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
def message_role(message)
|
|
1217
|
+
MessageAccess.role(message)
|
|
1218
|
+
end
|
|
1219
|
+
|
|
1220
|
+
def message_content(message)
|
|
1221
|
+
MessageAccess.content(message)
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
def message_summary(message)
|
|
1225
|
+
MessageAccess.summary(message) || message_content(message)
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
def message_name(message)
|
|
1229
|
+
MessageAccess.name(message)
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
def message_tool_call_id(message)
|
|
1233
|
+
MessageAccess.tool_call_id(message)
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
def message_tool_calls(message)
|
|
1237
|
+
MessageAccess.tool_calls(message)
|
|
1238
|
+
end
|
|
1239
|
+
|
|
1240
|
+
def tool_call_id(tool_call)
|
|
1241
|
+
tool_call["id"] || tool_call[:id]
|
|
1242
|
+
end
|
|
1243
|
+
|
|
1244
|
+
def export_session(conversation, argument)
|
|
1245
|
+
path = export_path(argument)
|
|
1246
|
+
File.write(path, markdown_transcript(conversation))
|
|
1247
|
+
@prompt.say("\nExported session: #{path}\n")
|
|
1248
|
+
rescue StandardError => e
|
|
1249
|
+
@prompt.say("\nError: #{e.message}\n")
|
|
1250
|
+
end
|
|
1251
|
+
|
|
1252
|
+
def say_sessions_unavailable
|
|
1253
|
+
@prompt.say("\nSessions are unavailable for this interactive loop.\n")
|
|
1254
|
+
nil
|
|
1255
|
+
end
|
|
1256
|
+
|
|
1257
|
+
def clear_prompt_transcript
|
|
1258
|
+
@prompt.clear_transcript if @prompt.respond_to?(:clear_transcript)
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1261
|
+
def restore_prompt_transcript(&block)
|
|
1262
|
+
if @prompt.respond_to?(:restore_transcript)
|
|
1263
|
+
@prompt.restore_transcript(&block)
|
|
1264
|
+
else
|
|
1265
|
+
block.call
|
|
1266
|
+
end
|
|
1267
|
+
end
|
|
1268
|
+
|
|
1269
|
+
def select_session_path(session_store)
|
|
1270
|
+
recent_limit = 20
|
|
1271
|
+
sessions = session_store.recent_tree(limit: recent_limit + 1)
|
|
1272
|
+
.reject { |session| active_empty_unnamed_session_info?(session) }
|
|
1273
|
+
.first(recent_limit)
|
|
1274
|
+
if sessions.empty?
|
|
1275
|
+
@prompt.say("\nNo saved sessions found.\n")
|
|
1276
|
+
return nil
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
labels = sessions.map { |session| session_label(session) }
|
|
1280
|
+
if @prompt.respond_to?(:select)
|
|
1281
|
+
choice = @prompt.select("Session>", labels)
|
|
1282
|
+
return nil unless choice
|
|
1283
|
+
|
|
1284
|
+
selected = sessions[labels.index(choice)]
|
|
1285
|
+
return selected&.path
|
|
1286
|
+
end
|
|
1287
|
+
|
|
1288
|
+
numbered_labels = labels.each_with_index.map { |label, index| "#{index + 1}. #{label}" }
|
|
1289
|
+
@prompt.say("\nRecent sessions:\n#{numbered_labels.join("\n")}\n")
|
|
1290
|
+
answer = @prompt.ask("Session number or path>").to_s.strip
|
|
1291
|
+
if answer.match?(/\A\d+\z/)
|
|
1292
|
+
sessions[answer.to_i - 1]&.path
|
|
1293
|
+
else
|
|
1294
|
+
answer
|
|
1295
|
+
end
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
def active_empty_unnamed_session_info?(session)
|
|
1299
|
+
return false unless @active_session
|
|
1300
|
+
return false unless File.expand_path(session.path) == File.expand_path(@active_session.path)
|
|
1301
|
+
|
|
1302
|
+
session.name.to_s.strip.empty? && session.message_count.to_i.zero?
|
|
1303
|
+
end
|
|
1304
|
+
|
|
1305
|
+
def session_label(session)
|
|
1306
|
+
title = session.name.to_s.strip
|
|
1307
|
+
title = session.first_message.to_s.strip if title.empty?
|
|
1308
|
+
title = session.id if title.empty?
|
|
1309
|
+
"#{session_tree_prefix(session)}#{title} — #{File.basename(session.path)}"
|
|
1310
|
+
end
|
|
1311
|
+
|
|
1312
|
+
def session_tree_prefix(session)
|
|
1313
|
+
depth = session.respond_to?(:depth) ? session.depth.to_i : 0
|
|
1314
|
+
return "" if depth <= 0
|
|
1315
|
+
|
|
1316
|
+
ancestors = session.respond_to?(:ancestor_continues) ? Array(session.ancestor_continues) : []
|
|
1317
|
+
prefix = ancestors.map { |continues| continues ? "│ " : " " }.join
|
|
1318
|
+
branch = session.respond_to?(:is_last) && session.is_last ? "└─ " : "├─ "
|
|
1319
|
+
prefix + branch
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
def export_path(argument)
|
|
1323
|
+
default_path = if @active_session
|
|
1324
|
+
@active_session.path.sub(/\.jsonl\z/, ".md")
|
|
1325
|
+
else
|
|
1326
|
+
File.expand_path("kward-session-#{Time.now.utc.iso8601(3).tr(':', '-')}.md", Dir.pwd)
|
|
1327
|
+
end
|
|
1328
|
+
session_dir = @session_store&.session_dir || (@active_session && File.dirname(@active_session.path))
|
|
1329
|
+
|
|
1330
|
+
ExportPath.resolve(argument, workspace_root: Dir.pwd, default_path: default_path, session_dir: session_dir)
|
|
1331
|
+
end
|
|
1332
|
+
|
|
1333
|
+
def markdown_transcript(conversation)
|
|
1334
|
+
TranscriptExport.content(conversation)
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
def setup_interactive_prompt
|
|
1338
|
+
return unless @stdin.tty?
|
|
1339
|
+
return unless @prompt.is_a?(TTY::Prompt)
|
|
1340
|
+
|
|
1341
|
+
prompt_interface = load_prompt_interface
|
|
1342
|
+
return unless prompt_interface
|
|
1343
|
+
|
|
1344
|
+
@prompt = prompt_interface.new(
|
|
1345
|
+
slash_commands: slash_command_entries,
|
|
1346
|
+
overlay_settings: ConfigFiles.overlay_settings,
|
|
1347
|
+
footer: prompt_footer_renderer,
|
|
1348
|
+
composer_status: method(:composer_status_text),
|
|
1349
|
+
busy_help: ConfigFiles.composer_busy_help?,
|
|
1350
|
+
attachment_badges: method(:composer_attachment_badges),
|
|
1351
|
+
attachment_parser: method(:composer_attachment_parser),
|
|
1352
|
+
banner_pixels: Kward::PromptInterface::BANNER_LOGO_PIXELS,
|
|
1353
|
+
banner_message: Kward::PromptInterface::BANNER_MESSAGE
|
|
1354
|
+
)
|
|
1355
|
+
@prompt.start
|
|
1356
|
+
end
|
|
1357
|
+
|
|
1358
|
+
def load_prompt_interface
|
|
1359
|
+
require_relative "prompt_interface"
|
|
1360
|
+
PromptInterface
|
|
1361
|
+
rescue LoadError => e
|
|
1362
|
+
raise unless missing_tty_tui_load_error?(e)
|
|
1363
|
+
|
|
1364
|
+
nil
|
|
1365
|
+
end
|
|
1366
|
+
|
|
1367
|
+
def missing_tty_tui_load_error?(error)
|
|
1368
|
+
["tty-cursor", "tty-reader", "tty-screen"].include?(error.path) ||
|
|
1369
|
+
error.message.match?(/cannot load such file -- tty-(cursor|reader|screen)/)
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
def prompt_interface?
|
|
1373
|
+
@prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
def print_visual_banner
|
|
1377
|
+
@prompt.print_visual_banner if @prompt.respond_to?(:print_visual_banner)
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
def prompt_templates
|
|
1381
|
+
@prompt_templates ||= ConfigFiles.prompt_templates(reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
|
|
1382
|
+
end
|
|
1383
|
+
|
|
1384
|
+
def plugin_registry
|
|
1385
|
+
@plugin_registry ||= PluginRegistry.load(reserved_commands: reserved_slash_command_names)
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1388
|
+
def plugin_commands
|
|
1389
|
+
plugin_registry.commands
|
|
1390
|
+
end
|
|
1391
|
+
|
|
1392
|
+
def plugin_command_for(command)
|
|
1393
|
+
plugin_registry.command_for(command)
|
|
1394
|
+
end
|
|
1395
|
+
|
|
1396
|
+
def reserved_slash_command_names
|
|
1397
|
+
BUILTIN_SLASH_COMMAND_NAMES + prompt_templates.map(&:command)
|
|
1398
|
+
end
|
|
1399
|
+
|
|
1400
|
+
def slash_command_entries
|
|
1401
|
+
prompt_entries = prompt_templates.map do |template|
|
|
1402
|
+
{
|
|
1403
|
+
name: template.command,
|
|
1404
|
+
description: template.description,
|
|
1405
|
+
argument_hint: template.argument_hint
|
|
1406
|
+
}
|
|
1407
|
+
end
|
|
1408
|
+
plugin_entries = plugin_commands.map(&:entry)
|
|
1409
|
+
BUILTIN_SLASH_COMMANDS + prompt_entries + plugin_entries
|
|
1410
|
+
end
|
|
1411
|
+
|
|
1412
|
+
def prompt_template_for(command)
|
|
1413
|
+
prompt_templates.find { |template| template.command == command }
|
|
1414
|
+
end
|
|
1415
|
+
|
|
1416
|
+
def expand_prompt_template(input)
|
|
1417
|
+
PromptCommands.expand(input, templates: prompt_templates, reserved_commands: BUILTIN_SLASH_COMMAND_NAMES)
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
def run_plugin_command(name, argument, agent)
|
|
1421
|
+
command = plugin_command_for(name)
|
|
1422
|
+
return [false, nil] unless command
|
|
1423
|
+
|
|
1424
|
+
agent.conversation.plugin_registry ||= plugin_registry if agent.conversation.respond_to?(:plugin_registry)
|
|
1425
|
+
context = plugin_context(agent.conversation, argument)
|
|
1426
|
+
command.handler.call(argument, context)
|
|
1427
|
+
[true, nil]
|
|
1428
|
+
rescue StandardError => e
|
|
1429
|
+
@prompt.say("\nPlugin command /#{name} error: #{e.message}\n")
|
|
1430
|
+
[true, nil]
|
|
1431
|
+
end
|
|
1432
|
+
|
|
1433
|
+
def prompt_footer_renderer
|
|
1434
|
+
renderer = plugin_registry.footer_renderer
|
|
1435
|
+
return nil unless renderer
|
|
1436
|
+
|
|
1437
|
+
lambda do
|
|
1438
|
+
context = plugin_context(current_footer_conversation, "")
|
|
1439
|
+
renderer.call(context).to_s
|
|
1440
|
+
rescue StandardError => e
|
|
1441
|
+
warn "Warning: Kward plugin footer error: #{e.message}"
|
|
1442
|
+
""
|
|
1443
|
+
end
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
def composer_status_text
|
|
1447
|
+
provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
1448
|
+
model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
1449
|
+
reasoning = @client.respond_to?(:current_reasoning_effort) ? @client.current_reasoning_effort : ModelInfo::DEFAULT_REASONING_EFFORT
|
|
1450
|
+
reasoning = "n/a" unless ModelInfo.reasoning_supported?(provider, model) && !reasoning.to_s.empty?
|
|
1451
|
+
text = "#{provider} #{model} · #{reasoning}"
|
|
1452
|
+
parts = []
|
|
1453
|
+
diff = composer_session_diff_text
|
|
1454
|
+
parts << diff if diff
|
|
1455
|
+
usage = composer_context_usage(provider, model)
|
|
1456
|
+
parts << composer_context_percent_text(usage[:percent]) if usage
|
|
1457
|
+
parts << text
|
|
1458
|
+
parts.join(" · ")
|
|
1459
|
+
end
|
|
1460
|
+
|
|
1461
|
+
def composer_session_diff_text
|
|
1462
|
+
return nil if @session_diff.nil? || @session_diff.empty?
|
|
1463
|
+
|
|
1464
|
+
additions = ANSI.colorize("+#{@session_diff.additions}", :green, enabled: @color_enabled)
|
|
1465
|
+
deletions = ANSI.colorize("-#{@session_diff.deletions}", :red, enabled: @color_enabled)
|
|
1466
|
+
"#{additions}|#{deletions}"
|
|
1467
|
+
end
|
|
1468
|
+
|
|
1469
|
+
def composer_context_percent_text(percent)
|
|
1470
|
+
value = percent.round
|
|
1471
|
+
color = if value >= 85
|
|
1472
|
+
:red
|
|
1473
|
+
elsif value >= 50
|
|
1474
|
+
:yellow
|
|
1475
|
+
end
|
|
1476
|
+
ANSI.colorize("#{value}%", color, enabled: @color_enabled)
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
def composer_context_window
|
|
1480
|
+
provider = @client.respond_to?(:current_provider) ? @client.current_provider : "Codex"
|
|
1481
|
+
model = @client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL
|
|
1482
|
+
provider = ModelInfo.provider_label(provider)
|
|
1483
|
+
@client.respond_to?(:current_context_window) ? @client.current_context_window : ModelInfo.context_window(provider, model)
|
|
1484
|
+
end
|
|
1485
|
+
|
|
1486
|
+
def composer_context_usage(provider, model)
|
|
1487
|
+
context_window = composer_context_window
|
|
1488
|
+
context_parts = if @client.respond_to?(:current_context_parts)
|
|
1489
|
+
@client.current_context_parts(current_footer_conversation.messages, footer_tool_schemas)
|
|
1490
|
+
else
|
|
1491
|
+
{ provider: provider, model: model, messages: current_footer_conversation.messages, tools: footer_tool_schemas }
|
|
1492
|
+
end
|
|
1493
|
+
@context_usage.call(
|
|
1494
|
+
provider: provider,
|
|
1495
|
+
model: model,
|
|
1496
|
+
context_window: context_window,
|
|
1497
|
+
context_parts: context_parts
|
|
1498
|
+
)
|
|
1499
|
+
end
|
|
1500
|
+
|
|
1501
|
+
def footer_tool_schemas
|
|
1502
|
+
@footer_tool_registry&.schemas || []
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
def current_footer_conversation
|
|
1506
|
+
@footer_conversation || Conversation.new(system_message: nil)
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1509
|
+
def plugin_context(conversation, args)
|
|
1510
|
+
PluginRegistry::Context.new(
|
|
1511
|
+
conversation: conversation,
|
|
1512
|
+
args: args,
|
|
1513
|
+
session: @active_session,
|
|
1514
|
+
workspace_root: conversation.workspace_root,
|
|
1515
|
+
say_callback: lambda { |message| @prompt.say("\n#{message}\n") }
|
|
1516
|
+
)
|
|
1517
|
+
end
|
|
1518
|
+
|
|
1519
|
+
def selected_slash_command_input(input)
|
|
1520
|
+
return nil if prompt_interface?
|
|
1521
|
+
return nil unless @prompt.respond_to?(:select)
|
|
1522
|
+
return nil unless input.match?(%r{\A/[^\s/]*\z})
|
|
1523
|
+
return nil if prompt_template_for(input.delete_prefix("/"))
|
|
1524
|
+
|
|
1525
|
+
prefix = input.delete_prefix("/").downcase
|
|
1526
|
+
return nil if slash_command_entries.any? { |entry| entry[:name].downcase == prefix }
|
|
1527
|
+
|
|
1528
|
+
matches = slash_command_entries.select { |entry| entry[:name].downcase.start_with?(prefix) }
|
|
1529
|
+
return nil if matches.empty?
|
|
1530
|
+
|
|
1531
|
+
labels = matches.map { |entry| slash_command_label(entry) }
|
|
1532
|
+
choice = @prompt.select("Slash command>", labels)
|
|
1533
|
+
entry = matches[labels.index(choice)]
|
|
1534
|
+
entry ? "/#{entry[:name]}" : nil
|
|
1535
|
+
end
|
|
1536
|
+
|
|
1537
|
+
def slash_command_label(entry)
|
|
1538
|
+
hint = entry[:argument_hint].to_s.empty? ? "" : " #{entry[:argument_hint]}"
|
|
1539
|
+
description = entry[:description].to_s.empty? ? "" : " - #{entry[:description]}"
|
|
1540
|
+
"/#{entry[:name]}#{hint}#{description}"
|
|
1541
|
+
end
|
|
1542
|
+
|
|
1543
|
+
def run_interactive_turn(agent, input, display_input: nil)
|
|
1544
|
+
prepare_memory_context(agent.conversation, input) if agent.respond_to?(:conversation)
|
|
1545
|
+
print_user_transcript(input, display_input: display_input) if prompt_interface?
|
|
1546
|
+
return run_blocking_interactive_turn(agent, input, display_input: display_input) unless prompt_interface?
|
|
1547
|
+
|
|
1548
|
+
queued_inputs = []
|
|
1549
|
+
cancellation = Cancellation.new
|
|
1550
|
+
cancelled = false
|
|
1551
|
+
steering = steering_supported? ? Steering.new : nil
|
|
1552
|
+
event_queue = Queue.new
|
|
1553
|
+
stream_state = {
|
|
1554
|
+
streamed: false,
|
|
1555
|
+
last_flush: monotonic_now,
|
|
1556
|
+
stream_block_open: false,
|
|
1557
|
+
markdown_streams: {},
|
|
1558
|
+
defer_assistant_streaming: defer_assistant_streaming?(agent)
|
|
1559
|
+
}
|
|
1560
|
+
markdown_chunks = []
|
|
1561
|
+
answer = nil
|
|
1562
|
+
error = nil
|
|
1563
|
+
@prompt.begin_busy_input("You>") if @prompt.respond_to?(:begin_busy_input)
|
|
1564
|
+
|
|
1565
|
+
worker = Thread.new do
|
|
1566
|
+
options = agent_display_options(display_input)
|
|
1567
|
+
options[:cancellation] = cancellation
|
|
1568
|
+
options[:steering] = steering if steering
|
|
1569
|
+
answer = agent.ask(input, **options) do |event|
|
|
1570
|
+
event_queue << event
|
|
1571
|
+
end
|
|
1572
|
+
rescue StandardError => e
|
|
1573
|
+
error = e
|
|
1574
|
+
end
|
|
1575
|
+
worker.report_on_exception = false
|
|
1576
|
+
|
|
1577
|
+
while worker.alive?
|
|
1578
|
+
begin
|
|
1579
|
+
poll_result = collect_busy_input(queued_inputs, steering)
|
|
1580
|
+
sleep 0.01
|
|
1581
|
+
rescue Interrupt
|
|
1582
|
+
poll_result = PromptInterface::CANCEL_INPUT
|
|
1583
|
+
end
|
|
1584
|
+
if poll_result == PromptInterface::CANCEL_INPUT && !cancelled
|
|
1585
|
+
cancelled = true
|
|
1586
|
+
cancellation.cancel!
|
|
1587
|
+
worker.raise(Cancellation::CancelledError, "cancelled") if worker.alive?
|
|
1588
|
+
end
|
|
1589
|
+
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent)
|
|
1590
|
+
end
|
|
1591
|
+
begin
|
|
1592
|
+
worker.join
|
|
1593
|
+
rescue Cancellation::CancelledError => e
|
|
1594
|
+
error ||= e
|
|
1595
|
+
end
|
|
1596
|
+
drain_busy_input(queued_inputs, nil) unless cancelled
|
|
1597
|
+
drain_interactive_events(event_queue, markdown_chunks, stream_state, agent, force: true)
|
|
1598
|
+
raise error if error && !error.is_a?(Cancellation::CancelledError)
|
|
1599
|
+
|
|
1600
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless cancelled || stream_state[:streamed] || answer.to_s.empty?
|
|
1601
|
+
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
|
|
1602
|
+
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation) && queued_inputs.empty? && !cancelled
|
|
1603
|
+
queued_inputs
|
|
1604
|
+
ensure
|
|
1605
|
+
@prompt.finish_busy_input if @prompt.respond_to?(:finish_busy_input)
|
|
1606
|
+
end
|
|
1607
|
+
|
|
1608
|
+
def drain_interactive_events(event_queue, markdown_chunks, stream_state, agent = nil, force: false)
|
|
1609
|
+
drained = 0
|
|
1610
|
+
loop do
|
|
1611
|
+
break if !force && drained >= INTERACTIVE_EVENT_DRAIN_LIMIT
|
|
1612
|
+
|
|
1613
|
+
event = event_queue.pop(true)
|
|
1614
|
+
drained += 1
|
|
1615
|
+
notify_plugin_transcript_event(event, agent.respond_to?(:conversation) ? agent.conversation : nil)
|
|
1616
|
+
handle_interactive_event(event, markdown_chunks, stream_state)
|
|
1617
|
+
rescue ThreadError
|
|
1618
|
+
break
|
|
1619
|
+
end
|
|
1620
|
+
|
|
1621
|
+
flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: force)
|
|
1622
|
+
end
|
|
1623
|
+
|
|
1624
|
+
def notify_plugin_transcript_event(event, conversation)
|
|
1625
|
+
return unless conversation
|
|
1626
|
+
return if plugin_registry.transcript_event_handlers.empty?
|
|
1627
|
+
|
|
1628
|
+
plugin_registry.notify_transcript_event(event, plugin_context(conversation, ""))
|
|
1629
|
+
end
|
|
1630
|
+
|
|
1631
|
+
def handle_interactive_event(event, markdown_chunks, stream_state)
|
|
1632
|
+
case event
|
|
1633
|
+
when Events::ReasoningDelta
|
|
1634
|
+
stream_state[:streamed] = true
|
|
1635
|
+
append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
|
|
1636
|
+
when Events::AssistantDelta
|
|
1637
|
+
stream_state[:streamed] = true
|
|
1638
|
+
append_markdown_delta(markdown_chunks, "Assistant", event.delta)
|
|
1639
|
+
when Events::SteeringApplied
|
|
1640
|
+
@prompt.clear_steered_count if @prompt.respond_to?(:clear_steered_count)
|
|
1641
|
+
when Events::Retry
|
|
1642
|
+
stream_state[:streamed] = true
|
|
1643
|
+
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
1644
|
+
print_retry(event)
|
|
1645
|
+
when Events::ToolCall
|
|
1646
|
+
stream_state[:streamed] = true
|
|
1647
|
+
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
1648
|
+
print_tool_call(event.tool_call)
|
|
1649
|
+
when Events::ToolResult
|
|
1650
|
+
stream_state[:streamed] = true
|
|
1651
|
+
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
1652
|
+
update_session_diff(event.content)
|
|
1653
|
+
print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
1654
|
+
end
|
|
1655
|
+
end
|
|
1656
|
+
|
|
1657
|
+
def flush_interactive_markdown_deltas(markdown_chunks, stream_state, force: false)
|
|
1658
|
+
if force
|
|
1659
|
+
finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
1660
|
+
return
|
|
1661
|
+
end
|
|
1662
|
+
return if markdown_chunks.empty?
|
|
1663
|
+
return unless monotonic_now - stream_state[:last_flush] >= STREAM_RENDER_INTERVAL
|
|
1664
|
+
|
|
1665
|
+
chunks_to_flush = markdown_chunks
|
|
1666
|
+
if stream_state[:defer_assistant_streaming]
|
|
1667
|
+
chunks_to_flush, delayed_chunks = split_deferred_assistant_entries(markdown_chunks)
|
|
1668
|
+
return if chunks_to_flush.empty?
|
|
1669
|
+
|
|
1670
|
+
markdown_chunks.replace(delayed_chunks)
|
|
1671
|
+
end
|
|
1672
|
+
|
|
1673
|
+
stream_state[:stream_block_open] = true if flush_markdown_deltas(chunks_to_flush, finish: false, streams: stream_state[:markdown_streams])
|
|
1674
|
+
stream_state[:last_flush] = monotonic_now
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1677
|
+
def finish_interactive_markdown_deltas(markdown_chunks, stream_state)
|
|
1678
|
+
wrote = flush_markdown_deltas(markdown_chunks, streams: stream_state[:markdown_streams])
|
|
1679
|
+
finish_stream_block if stream_state[:stream_block_open] && !wrote
|
|
1680
|
+
stream_state[:stream_block_open] = false
|
|
1681
|
+
stream_state[:last_flush] = monotonic_now
|
|
1682
|
+
end
|
|
1683
|
+
|
|
1684
|
+
def split_deferred_assistant_entries(markdown_chunks)
|
|
1685
|
+
markdown_chunks.partition { |label, _content| label != "Assistant" }
|
|
1686
|
+
end
|
|
1687
|
+
|
|
1688
|
+
def monotonic_now
|
|
1689
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1690
|
+
end
|
|
1691
|
+
|
|
1692
|
+
def collect_queued_input(queued_inputs)
|
|
1693
|
+
collect_busy_input(queued_inputs, nil)
|
|
1694
|
+
end
|
|
1695
|
+
|
|
1696
|
+
def collect_busy_input(queued_inputs, steering)
|
|
1697
|
+
return nil if @prompt.respond_to?(:modal_active?) && @prompt.modal_active?
|
|
1698
|
+
|
|
1699
|
+
poll_result = @prompt.poll_input
|
|
1700
|
+
case poll_result
|
|
1701
|
+
when String
|
|
1702
|
+
if steering && !poll_result.strip.empty?
|
|
1703
|
+
begin
|
|
1704
|
+
steering.submit(poll_result)
|
|
1705
|
+
@prompt.set_steered_count(1) if @prompt.respond_to?(:set_steered_count)
|
|
1706
|
+
rescue StandardError
|
|
1707
|
+
queued_inputs << poll_result
|
|
1708
|
+
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
1709
|
+
end
|
|
1710
|
+
else
|
|
1711
|
+
queued_inputs << poll_result unless poll_result.strip.empty?
|
|
1712
|
+
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
1713
|
+
end
|
|
1714
|
+
when PromptInterface::EXIT_INPUT
|
|
1715
|
+
queued_inputs << "/exit"
|
|
1716
|
+
@prompt.set_queued_count(queued_inputs.length) if @prompt.respond_to?(:set_queued_count)
|
|
1717
|
+
end
|
|
1718
|
+
poll_result
|
|
1719
|
+
end
|
|
1720
|
+
|
|
1721
|
+
def drain_queued_input(queued_inputs)
|
|
1722
|
+
drain_busy_input(queued_inputs, nil)
|
|
1723
|
+
end
|
|
1724
|
+
|
|
1725
|
+
def drain_busy_input(queued_inputs, steering)
|
|
1726
|
+
deadline = Time.now + 0.15
|
|
1727
|
+
loop do
|
|
1728
|
+
poll_result = collect_busy_input(queued_inputs, steering)
|
|
1729
|
+
break if Time.now > deadline && poll_result.nil?
|
|
1730
|
+
|
|
1731
|
+
sleep 0.01
|
|
1732
|
+
end
|
|
1733
|
+
end
|
|
1734
|
+
|
|
1735
|
+
def steering_supported?
|
|
1736
|
+
@client.respond_to?(:supports_in_flight_steer?) && @client.supports_in_flight_steer?
|
|
1737
|
+
end
|
|
1738
|
+
|
|
1739
|
+
def defer_assistant_streaming?(agent)
|
|
1740
|
+
return false unless agent.respond_to?(:conversation)
|
|
1741
|
+
|
|
1742
|
+
conversation = agent.conversation
|
|
1743
|
+
model = conversation.respond_to?(:model) && conversation.model ? conversation.model : current_model_id
|
|
1744
|
+
ModelInfo.reasoning_supported?(current_model_provider, model)
|
|
1745
|
+
end
|
|
1746
|
+
|
|
1747
|
+
def run_blocking_interactive_turn(agent, input, display_input: nil)
|
|
1748
|
+
streamed = false
|
|
1749
|
+
markdown_chunks = []
|
|
1750
|
+
answer = agent.ask(input, **agent_display_options(display_input)) do |event|
|
|
1751
|
+
case event
|
|
1752
|
+
when Events::ReasoningDelta
|
|
1753
|
+
streamed = true
|
|
1754
|
+
append_markdown_delta(markdown_chunks, "Reasoning", event.delta)
|
|
1755
|
+
when Events::AssistantDelta
|
|
1756
|
+
streamed = true
|
|
1757
|
+
append_markdown_delta(markdown_chunks, "Assistant", event.delta)
|
|
1758
|
+
when Events::Retry
|
|
1759
|
+
streamed = true
|
|
1760
|
+
flush_markdown_deltas(markdown_chunks)
|
|
1761
|
+
print_retry(event)
|
|
1762
|
+
when Events::ToolCall
|
|
1763
|
+
streamed = true
|
|
1764
|
+
flush_markdown_deltas(markdown_chunks)
|
|
1765
|
+
print_tool_call(event.tool_call)
|
|
1766
|
+
when Events::ToolResult
|
|
1767
|
+
streamed = true
|
|
1768
|
+
flush_markdown_deltas(markdown_chunks)
|
|
1769
|
+
print_tool_result(event.tool_call, event.content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
|
|
1770
|
+
end
|
|
1771
|
+
end
|
|
1772
|
+
flush_markdown_deltas(markdown_chunks) if streamed
|
|
1773
|
+
@prompt.say("\n#{colored(assistant_output_prompt, :green, :bold)} #{render_markdown_transcript(answer)}\n") unless streamed || answer.to_s.empty?
|
|
1774
|
+
persist_memory_state(agent.conversation) if agent.respond_to?(:conversation)
|
|
1775
|
+
auto_summarize_memory(agent.conversation) if agent.respond_to?(:conversation)
|
|
1776
|
+
[]
|
|
1777
|
+
end
|
|
1778
|
+
|
|
1779
|
+
def prepare_memory_context(conversation, input)
|
|
1780
|
+
manager = Memory::Manager.new
|
|
1781
|
+
retrieval = manager.retrieve_relevant(input: input, workspace_root: conversation.workspace_root)
|
|
1782
|
+
conversation.last_memory_retrieval = retrieval
|
|
1783
|
+
conversation.memory_context = manager.memory_block(retrieval)
|
|
1784
|
+
conversation.refresh_system_message!
|
|
1785
|
+
rescue StandardError => e
|
|
1786
|
+
warn "Memory retrieval failed: #{e.message}"
|
|
1787
|
+
nil
|
|
1788
|
+
end
|
|
1789
|
+
|
|
1790
|
+
def persist_memory_state(conversation)
|
|
1791
|
+
@active_session&.update_memory_state(session_memories: conversation.session_memories, last_retrieval: conversation.last_memory_retrieval)
|
|
1792
|
+
rescue StandardError
|
|
1793
|
+
nil
|
|
1794
|
+
end
|
|
1795
|
+
|
|
1796
|
+
def auto_summarize_memory(conversation)
|
|
1797
|
+
manager = Memory::Manager.new
|
|
1798
|
+
return unless manager.enabled? && manager.auto_summary_enabled?
|
|
1799
|
+
|
|
1800
|
+
summarize_memory(conversation, manager: manager)
|
|
1801
|
+
rescue StandardError => e
|
|
1802
|
+
warn "Memory auto-summary failed: #{e.message}"
|
|
1803
|
+
nil
|
|
1804
|
+
end
|
|
1805
|
+
|
|
1806
|
+
def print_user_transcript(input, display_input: nil, attachment_references: nil, image_parts: nil)
|
|
1807
|
+
visible_input = display_input.nil? ? input : display_input
|
|
1808
|
+
@prompt.say("\n#{colored("You>", :blue, :bold)} #{visible_input}\n")
|
|
1809
|
+
print_attachment_badges(input, references: attachment_references)
|
|
1810
|
+
print_pasted_images(input, image_parts: image_parts)
|
|
1811
|
+
end
|
|
1812
|
+
|
|
1813
|
+
def print_attachment_badges(input, references: nil)
|
|
1814
|
+
badges = references ? Array(references).map { |reference| attachment_badge_text(reference) } : composer_attachment_badges(input)
|
|
1815
|
+
return if badges.empty?
|
|
1816
|
+
|
|
1817
|
+
@prompt.say("#{badges.join("\n")}\n")
|
|
1818
|
+
end
|
|
1819
|
+
|
|
1820
|
+
def composer_attachment_badges(input, attachments = [])
|
|
1821
|
+
references = Array(attachments)
|
|
1822
|
+
references = Kward::ImageAttachments.references_from_text(input) if references.empty?
|
|
1823
|
+
references.map { |reference| attachment_badge_text(reference) }
|
|
1824
|
+
end
|
|
1825
|
+
|
|
1826
|
+
def composer_attachment_parser(input)
|
|
1827
|
+
Kward::ImageAttachments.extract_references_from_text(input)
|
|
1828
|
+
end
|
|
1829
|
+
|
|
1830
|
+
def submitted_display_input(input)
|
|
1831
|
+
input.respond_to?(:display_input) ? input.display_input : nil
|
|
1832
|
+
end
|
|
1833
|
+
|
|
1834
|
+
def attachment_badge_text(reference)
|
|
1835
|
+
status = reference[:status] || reference["status"]
|
|
1836
|
+
label = reference[:label] || reference["label"] || "image"
|
|
1837
|
+
if status == :missing || status.to_s == "missing"
|
|
1838
|
+
"[image?] #{label} not found"
|
|
1839
|
+
else
|
|
1840
|
+
media_type = reference[:media_type] || reference["media_type"] || reference[:mimeType] || reference["mimeType"] || "image"
|
|
1841
|
+
size = format_attachment_size(reference[:size_bytes] || reference["size_bytes"] || reference[:sizeBytes] || reference["sizeBytes"])
|
|
1842
|
+
"[image] #{label} · #{media_type}#{size.empty? ? "" : " · #{size}"}"
|
|
1843
|
+
end
|
|
1844
|
+
end
|
|
1845
|
+
|
|
1846
|
+
def format_attachment_size(bytes)
|
|
1847
|
+
value = bytes.to_i
|
|
1848
|
+
return "" unless value.positive?
|
|
1849
|
+
return "#{value} B" if value < 1024
|
|
1850
|
+
|
|
1851
|
+
units = %w[KB MB GB]
|
|
1852
|
+
size = value.to_f / 1024
|
|
1853
|
+
unit = units.shift
|
|
1854
|
+
while size >= 1024 && units.any?
|
|
1855
|
+
size /= 1024
|
|
1856
|
+
unit = units.shift
|
|
1857
|
+
end
|
|
1858
|
+
formatted = size >= 10 ? size.round.to_s : format("%.1f", size).sub(/\.0\z/, "")
|
|
1859
|
+
"#{formatted} #{unit}"
|
|
1860
|
+
end
|
|
1861
|
+
|
|
1862
|
+
def agent_display_options(display_input)
|
|
1863
|
+
display_input.nil? ? {} : { display_input: display_input }
|
|
1864
|
+
end
|
|
1865
|
+
|
|
1866
|
+
def print_pasted_images(input, image_parts: nil)
|
|
1867
|
+
parts = image_parts || Kward::ImageAttachments.image_parts_from_text(input)
|
|
1868
|
+
parts.each do |part|
|
|
1869
|
+
sequence = Kward::ImageAttachments.terminal_image_sequence(part)
|
|
1870
|
+
next unless sequence
|
|
1871
|
+
|
|
1872
|
+
if @prompt.respond_to?(:say_visual)
|
|
1873
|
+
@prompt.say_visual(sequence)
|
|
1874
|
+
else
|
|
1875
|
+
@prompt.say(sequence)
|
|
1876
|
+
end
|
|
1877
|
+
end
|
|
1878
|
+
end
|
|
1879
|
+
|
|
1880
|
+
def print_block_delta(label, delta)
|
|
1881
|
+
if prompt_interface?
|
|
1882
|
+
@prompt.start_stream_block(label)
|
|
1883
|
+
@prompt.write_delta(delta)
|
|
1884
|
+
else
|
|
1885
|
+
start_stream_block(label)
|
|
1886
|
+
print delta
|
|
1887
|
+
$stdout.flush
|
|
1888
|
+
end
|
|
1889
|
+
end
|
|
1890
|
+
|
|
1891
|
+
def print_retry(event)
|
|
1892
|
+
message = retry_message(event)
|
|
1893
|
+
if prompt_interface?
|
|
1894
|
+
@prompt.start_stream_block("Retry")
|
|
1895
|
+
@prompt.write_delta("#{message}\n")
|
|
1896
|
+
@prompt.finish_stream_block
|
|
1897
|
+
else
|
|
1898
|
+
start_stream_block("Retry")
|
|
1899
|
+
puts message
|
|
1900
|
+
$stdout.flush
|
|
1901
|
+
@stream_block = nil
|
|
1902
|
+
end
|
|
1903
|
+
end
|
|
1904
|
+
|
|
1905
|
+
def retry_message(event)
|
|
1906
|
+
RetryMessage.format(event)
|
|
1907
|
+
end
|
|
1908
|
+
|
|
1909
|
+
def print_tool_call(tool_call)
|
|
1910
|
+
if prompt_interface?
|
|
1911
|
+
@prompt.start_stream_block("Tool")
|
|
1912
|
+
@prompt.write_delta("#{tool_command(tool_call)}\n")
|
|
1913
|
+
@prompt.finish_stream_block
|
|
1914
|
+
else
|
|
1915
|
+
start_stream_block("Tool")
|
|
1916
|
+
puts tool_command(tool_call)
|
|
1917
|
+
$stdout.flush
|
|
1918
|
+
@stream_block = nil
|
|
1919
|
+
end
|
|
1920
|
+
end
|
|
1921
|
+
|
|
1922
|
+
def print_tool_result(tool_call, content, line_limit: nil)
|
|
1923
|
+
summary = tool_result_summary(tool_call, content)
|
|
1924
|
+
summary = limit_tool_output_lines(summary, line_limit) if line_limit
|
|
1925
|
+
if prompt_interface?
|
|
1926
|
+
@prompt.start_stream_block("Tool output")
|
|
1927
|
+
@prompt.write_delta(summary)
|
|
1928
|
+
@prompt.write_delta("\n") unless summary.end_with?("\n")
|
|
1929
|
+
@prompt.finish_stream_block
|
|
1930
|
+
else
|
|
1931
|
+
start_stream_block("Tool output")
|
|
1932
|
+
print summary
|
|
1933
|
+
puts unless summary.end_with?("\n")
|
|
1934
|
+
$stdout.flush
|
|
1935
|
+
@stream_block = nil
|
|
1936
|
+
end
|
|
1937
|
+
end
|
|
1938
|
+
|
|
1939
|
+
def tool_result_summary(tool_call, content)
|
|
1940
|
+
name = tool_call_name(tool_call)
|
|
1941
|
+
args = tool_call_args(tool_call)
|
|
1942
|
+
text = content.to_s
|
|
1943
|
+
return error_tool_summary(name, args, text) if text.start_with?("Error:", "Declined:")
|
|
1944
|
+
|
|
1945
|
+
case name
|
|
1946
|
+
when "read_file"
|
|
1947
|
+
read_file_summary(args, text)
|
|
1948
|
+
when "write_file", "edit_file"
|
|
1949
|
+
file_change_summary(name, args, text)
|
|
1950
|
+
when "run_shell_command"
|
|
1951
|
+
shell_command_summary(args, text)
|
|
1952
|
+
when "web_search"
|
|
1953
|
+
web_search_summary(args, text)
|
|
1954
|
+
else
|
|
1955
|
+
generic_tool_summary(name, text)
|
|
1956
|
+
end
|
|
1957
|
+
end
|
|
1958
|
+
|
|
1959
|
+
def limit_tool_output_lines(content, line_limit)
|
|
1960
|
+
lines = content.to_s.lines
|
|
1961
|
+
return content.to_s if lines.length <= line_limit
|
|
1962
|
+
|
|
1963
|
+
kept_lines = lines.first(line_limit - 1).join
|
|
1964
|
+
omitted_lines = lines.length - (line_limit - 1)
|
|
1965
|
+
suffix = omitted_lines == 1 ? "line" : "lines"
|
|
1966
|
+
notice = "...[truncated #{omitted_lines} #{suffix}]"
|
|
1967
|
+
kept_lines.end_with?("\n") || kept_lines.empty? ? "#{kept_lines}#{notice}" : "#{kept_lines}\n#{notice}"
|
|
1968
|
+
end
|
|
1969
|
+
|
|
1970
|
+
def read_file_summary(args, content)
|
|
1971
|
+
path = args["path"] || args[:path] || "(unknown path)"
|
|
1972
|
+
"read_file: #{path}\n#{content.lines.count} lines, #{content.bytesize} bytes"
|
|
1973
|
+
end
|
|
1974
|
+
|
|
1975
|
+
def file_change_summary(name, args, content)
|
|
1976
|
+
path = args["path"] || args[:path] || path_from_tool_result(content) || "(unknown path)"
|
|
1977
|
+
concise = content.lines.first.to_s.strip
|
|
1978
|
+
concise = "completed" if concise.empty?
|
|
1979
|
+
"#{name}: #{path}\n#{concise}"
|
|
1980
|
+
end
|
|
1981
|
+
|
|
1982
|
+
def shell_command_summary(args, content)
|
|
1983
|
+
command = args["command"] || args[:command] || ""
|
|
1984
|
+
lines = ["run_shell_command: #{command}".strip]
|
|
1985
|
+
lines << "Exit status: #{shell_exit_status(content) || "unknown"}"
|
|
1986
|
+
stdout = shell_section(content, "STDOUT")
|
|
1987
|
+
stderr = shell_section(content, "STDERR")
|
|
1988
|
+
lines << compact_stream_summary("stdout", stdout) unless stdout.empty?
|
|
1989
|
+
lines << compact_stream_summary("stderr", stderr) unless stderr.empty?
|
|
1990
|
+
lines.join("\n")
|
|
1991
|
+
end
|
|
1992
|
+
|
|
1993
|
+
def web_search_summary(args, content)
|
|
1994
|
+
queries = Array(args["queries"] || args[:queries]).map(&:to_s)
|
|
1995
|
+
queries = web_search_queries_from_content(content) if queries.empty?
|
|
1996
|
+
counts = web_search_result_counts(content)
|
|
1997
|
+
lines = ["web_search"]
|
|
1998
|
+
queries.each do |query|
|
|
1999
|
+
lines << "#{query}: #{counts.fetch(query, 0)} result(s)"
|
|
2000
|
+
end
|
|
2001
|
+
lines << "#{web_search_total_count(content)} result(s)" if queries.empty?
|
|
2002
|
+
lines.join("\n")
|
|
2003
|
+
end
|
|
2004
|
+
|
|
2005
|
+
def error_tool_summary(name, args, content)
|
|
2006
|
+
path = args["path"] || args[:path]
|
|
2007
|
+
command = args["command"] || args[:command]
|
|
2008
|
+
context = path || command
|
|
2009
|
+
[name, context, content.lines.first.to_s.strip].compact.reject(&:empty?).join("\n")
|
|
2010
|
+
end
|
|
2011
|
+
|
|
2012
|
+
def generic_tool_summary(name, content)
|
|
2013
|
+
text = content.to_s
|
|
2014
|
+
return "#{name}: #{text}" if text.length <= RESTORED_TOOL_OUTPUT_LIMIT
|
|
2015
|
+
|
|
2016
|
+
"#{name}: #{text[0, RESTORED_TOOL_OUTPUT_LIMIT]}\n...[truncated #{text.length - RESTORED_TOOL_OUTPUT_LIMIT} bytes]"
|
|
2017
|
+
end
|
|
2018
|
+
|
|
2019
|
+
def compact_stream_summary(label, text)
|
|
2020
|
+
summary = text.strip
|
|
2021
|
+
summary = summary[0, 500] + "\n...[truncated #{summary.length - 500} chars]" if summary.length > 500
|
|
2022
|
+
"#{label} (#{text.bytesize} bytes):#{summary.empty? ? "" : "\n#{summary}"}"
|
|
2023
|
+
end
|
|
2024
|
+
|
|
2025
|
+
def shell_exit_status(content)
|
|
2026
|
+
content.match(/^Exit status: ([^\n]+)/)&.[](1)
|
|
2027
|
+
end
|
|
2028
|
+
|
|
2029
|
+
def shell_section(content, name)
|
|
2030
|
+
match = content.match(/^#{Regexp.escape(name)}:\n(.*?)(?=\nSTD(?:OUT|ERR):\n|\z)/m)
|
|
2031
|
+
match ? match[1] : ""
|
|
2032
|
+
end
|
|
2033
|
+
|
|
2034
|
+
def web_search_queries_from_content(content)
|
|
2035
|
+
content.scan(/^## Query: (.+)$/).flatten
|
|
2036
|
+
end
|
|
2037
|
+
|
|
2038
|
+
def web_search_result_counts(content)
|
|
2039
|
+
counts = {}
|
|
2040
|
+
current_query = nil
|
|
2041
|
+
content.each_line do |line|
|
|
2042
|
+
if (match = line.match(/^## Query: (.+)$/))
|
|
2043
|
+
current_query = match[1]
|
|
2044
|
+
counts[current_query] ||= 0
|
|
2045
|
+
elsif current_query && line.match?(/^\d+\. /)
|
|
2046
|
+
counts[current_query] += 1
|
|
2047
|
+
end
|
|
2048
|
+
end
|
|
2049
|
+
counts
|
|
2050
|
+
end
|
|
2051
|
+
|
|
2052
|
+
def web_search_total_count(content)
|
|
2053
|
+
content.each_line.count { |line| line.match?(/^\d+\. /) }
|
|
2054
|
+
end
|
|
2055
|
+
|
|
2056
|
+
def path_from_tool_result(content)
|
|
2057
|
+
content.match(/\b(?:to|file|Edited)\s+([^:\n]+?)(?:\s|:|\z)/)&.[](1)
|
|
2058
|
+
end
|
|
2059
|
+
|
|
2060
|
+
def tool_call_name(tool_call)
|
|
2061
|
+
ToolCall.name(tool_call) || "unknown_tool"
|
|
2062
|
+
end
|
|
2063
|
+
|
|
2064
|
+
def tool_call_args(tool_call)
|
|
2065
|
+
ToolCall.arguments(tool_call)
|
|
2066
|
+
end
|
|
2067
|
+
|
|
2068
|
+
def start_stream_block(label)
|
|
2069
|
+
return if @stream_block == label
|
|
2070
|
+
|
|
2071
|
+
puts if @stream_block
|
|
2072
|
+
puts "\n#{colored("#{transcript_label(label)}>", label_color(label), :bold)}"
|
|
2073
|
+
@stream_block = label
|
|
2074
|
+
end
|
|
2075
|
+
|
|
2076
|
+
def finish_stream_block
|
|
2077
|
+
if prompt_interface?
|
|
2078
|
+
@prompt.finish_stream_block
|
|
2079
|
+
else
|
|
2080
|
+
puts if @stream_block
|
|
2081
|
+
@stream_block = nil
|
|
2082
|
+
end
|
|
2083
|
+
end
|
|
2084
|
+
|
|
2085
|
+
def colored(text, *styles)
|
|
2086
|
+
ANSI.colorize(text, *styles, enabled: @color_enabled)
|
|
2087
|
+
end
|
|
2088
|
+
|
|
2089
|
+
def transcript_label(label)
|
|
2090
|
+
label == "Assistant" ? assistant_prompt_name : label
|
|
2091
|
+
end
|
|
2092
|
+
|
|
2093
|
+
def label_color(label)
|
|
2094
|
+
case label
|
|
2095
|
+
when "Reasoning"
|
|
2096
|
+
:yellow
|
|
2097
|
+
when "Assistant", "Kward"
|
|
2098
|
+
:green
|
|
2099
|
+
when "Tool"
|
|
2100
|
+
:magenta
|
|
2101
|
+
when "Tool output"
|
|
2102
|
+
:cyan
|
|
2103
|
+
else
|
|
2104
|
+
:blue
|
|
2105
|
+
end
|
|
2106
|
+
end
|
|
2107
|
+
|
|
2108
|
+
def tool_command(tool_call)
|
|
2109
|
+
name = tool_call_name(tool_call)
|
|
2110
|
+
args = tool_call_args(tool_call)
|
|
2111
|
+
|
|
2112
|
+
if name == "run_shell_command"
|
|
2113
|
+
args["command"] || args[:command] || ""
|
|
2114
|
+
elsif args.empty?
|
|
2115
|
+
name.to_s
|
|
2116
|
+
else
|
|
2117
|
+
"#{name} #{JSON.dump(args)}"
|
|
2118
|
+
end
|
|
2119
|
+
end
|
|
2120
|
+
|
|
2121
|
+
end
|
|
2122
|
+
end
|