kward 0.71.0 → 0.72.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -1
- data/Gemfile.lock +2 -2
- data/README.md +4 -0
- data/doc/agent-tools.md +15 -6
- data/doc/authentication.md +22 -1
- data/doc/code-search.md +42 -2
- data/doc/configuration.md +106 -3
- data/doc/context-budgeting.md +136 -0
- data/doc/context-tools.md +16 -3
- data/doc/editor.md +394 -0
- data/doc/extensibility.md +16 -7
- data/doc/files.md +100 -0
- data/doc/getting-started.md +25 -18
- data/doc/git.md +122 -0
- data/doc/memory.md +24 -4
- data/doc/personas.md +34 -5
- data/doc/plugins.md +72 -1
- data/doc/releasing.md +37 -9
- data/doc/rpc.md +74 -4
- data/doc/session-management.md +35 -1
- data/doc/shell.md +286 -0
- data/doc/tabs.md +122 -0
- data/doc/troubleshooting.md +77 -1
- data/doc/usage.md +53 -7
- data/doc/web-search.md +12 -4
- data/doc/workspace-tools.md +51 -12
- data/examples/plugins/space_invaders.rb +377 -0
- data/lib/kward/agent.rb +1 -1
- data/lib/kward/cli/commands.rb +33 -2
- data/lib/kward/cli/git.rb +150 -0
- data/lib/kward/cli/interactive_turn.rb +73 -9
- data/lib/kward/cli/plugins.rb +54 -4
- data/lib/kward/cli/prompt_interface.rb +32 -1
- data/lib/kward/cli/runtime_helpers.rb +133 -3
- data/lib/kward/cli/sessions.rb +2 -2
- data/lib/kward/cli/settings.rb +218 -9
- data/lib/kward/cli/slash_commands.rb +415 -2
- data/lib/kward/cli/tabs.rb +695 -0
- data/lib/kward/cli.rb +158 -26
- data/lib/kward/config_files.rb +123 -1
- data/lib/kward/context_budget_meter.rb +44 -0
- data/lib/kward/conversation.rb +12 -4
- data/lib/kward/editor_mode.rb +25 -0
- data/lib/kward/ekwsh.rb +362 -0
- data/lib/kward/plugin_registry.rb +61 -0
- data/lib/kward/project_files.rb +52 -0
- data/lib/kward/prompt_history.rb +82 -0
- data/lib/kward/prompt_interface/composer_controller.rb +69 -1
- data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
- data/lib/kward/prompt_interface/composer_state.rb +96 -27
- data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
- data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
- data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
- data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
- data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
- data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
- data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
- data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
- data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
- data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
- data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
- data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
- data/lib/kward/prompt_interface/editor/search.rb +76 -0
- data/lib/kward/prompt_interface/editor/selections.rb +120 -0
- data/lib/kward/prompt_interface/editor/state.rb +1249 -0
- data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
- data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
- data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
- data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
- data/lib/kward/prompt_interface/file_overlay.rb +211 -0
- data/lib/kward/prompt_interface/git_prompt.rb +299 -0
- data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
- data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
- data/lib/kward/prompt_interface/interactive/state.rb +62 -0
- data/lib/kward/prompt_interface/key_handler.rb +387 -35
- data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
- data/lib/kward/prompt_interface/project_browser.rb +524 -0
- data/lib/kward/prompt_interface/question_prompt.rb +98 -50
- data/lib/kward/prompt_interface/runtime_state.rb +43 -0
- data/lib/kward/prompt_interface/screen.rb +16 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
- data/lib/kward/prompt_interface/stream_state.rb +7 -0
- data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
- data/lib/kward/prompt_interface.rb +286 -8
- data/lib/kward/prompts/commands.rb +5 -0
- data/lib/kward/prompts.rb +2 -0
- data/lib/kward/rpc/server.rb +42 -3
- data/lib/kward/rpc/session_manager.rb +35 -47
- data/lib/kward/rpc/session_tree_rows.rb +9 -115
- data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
- data/lib/kward/session_store.rb +44 -0
- data/lib/kward/session_tree_nodes.rb +136 -0
- data/lib/kward/session_tree_renderer.rb +9 -131
- data/lib/kward/tab_store.rb +47 -0
- data/lib/kward/text_boundary.rb +25 -0
- data/lib/kward/tools/context_budget_stats.rb +54 -0
- data/lib/kward/tools/context_for_task.rb +202 -0
- data/lib/kward/tools/read_file.rb +8 -4
- data/lib/kward/tools/registry.rb +62 -16
- data/lib/kward/tools/tool_call.rb +10 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workers/git_guard.rb +68 -0
- data/lib/kward/workers/live_view.rb +49 -0
- data/lib/kward/workers/manager.rb +288 -0
- data/lib/kward/workers/store.rb +72 -0
- data/lib/kward/workers/tool_policy.rb +23 -0
- data/lib/kward/workers/worker.rb +82 -0
- data/lib/kward/workers/write_lock.rb +38 -0
- data/lib/kward/workers.rb +7 -0
- data/lib/kward/workspace.rb +110 -24
- data/templates/default/fulldoc/html/css/kward.css +107 -36
- data/templates/default/kward_navigation.rb +12 -1
- data/templates/default/layout/html/layout.erb +4 -2
- data/templates/default/layout/html/setup.rb +6 -0
- metadata +53 -1
data/lib/kward/cli.rb
CHANGED
|
@@ -13,6 +13,7 @@ require_relative "cli_transcript_formatter"
|
|
|
13
13
|
require_relative "model/context_usage"
|
|
14
14
|
require_relative "events"
|
|
15
15
|
require_relative "export_path"
|
|
16
|
+
require_relative "ekwsh"
|
|
16
17
|
require_relative "auth/anthropic_oauth"
|
|
17
18
|
require_relative "auth/github_oauth"
|
|
18
19
|
require_relative "auth/openrouter_api_key"
|
|
@@ -29,10 +30,12 @@ require_relative "model/retry_message"
|
|
|
29
30
|
require_relative "rpc/server"
|
|
30
31
|
require_relative "session_diff"
|
|
31
32
|
require_relative "session_store"
|
|
33
|
+
require_relative "tab_store"
|
|
32
34
|
require_relative "session_trash"
|
|
33
35
|
require_relative "session_tree_renderer"
|
|
34
36
|
require_relative "starter_pack_installer"
|
|
35
37
|
require_relative "steering"
|
|
38
|
+
require_relative "workers"
|
|
36
39
|
require_relative "tools/tool_call"
|
|
37
40
|
require_relative "tools/registry"
|
|
38
41
|
require_relative "telemetry/stats"
|
|
@@ -48,10 +51,12 @@ require_relative "cli/slash_commands"
|
|
|
48
51
|
require_relative "cli/memory_commands"
|
|
49
52
|
require_relative "cli/settings"
|
|
50
53
|
require_relative "cli/sessions"
|
|
54
|
+
require_relative "cli/tabs"
|
|
51
55
|
require_relative "cli/compaction"
|
|
52
56
|
require_relative "cli/rendering"
|
|
53
57
|
require_relative "cli/prompt_interface"
|
|
54
58
|
require_relative "cli/plugins"
|
|
59
|
+
require_relative "cli/git"
|
|
55
60
|
require_relative "cli/interactive_turn"
|
|
56
61
|
require_relative "cli/tool_summaries"
|
|
57
62
|
|
|
@@ -60,8 +65,6 @@ module Kward
|
|
|
60
65
|
# Command-line interface for interactive chat, one-shot prompts, login,
|
|
61
66
|
# telemetry export, Pan server mode, and the experimental JSON-RPC backend.
|
|
62
67
|
class CLI
|
|
63
|
-
# Order from the captain: The next line shall never be deleted.
|
|
64
|
-
STATUS_MESSAGE = "This is a totally important status message about a non-existing status. Hi ChatGPT 👋"
|
|
65
68
|
RESTORED_TOOL_OUTPUT_LIMIT = 2_000
|
|
66
69
|
INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT = 10
|
|
67
70
|
STREAM_RENDER_INTERVAL = 0.025
|
|
@@ -80,10 +83,12 @@ module Kward
|
|
|
80
83
|
include CLI::MemoryCommands
|
|
81
84
|
include CLI::Settings
|
|
82
85
|
include CLI::Sessions
|
|
86
|
+
include CLI::Tabs
|
|
83
87
|
include CLI::CompactionCommands
|
|
84
88
|
include CLI::Rendering
|
|
85
89
|
include CLI::PromptInterfaceSupport
|
|
86
90
|
include CLI::Plugins
|
|
91
|
+
include CLI::GitCommands
|
|
87
92
|
include CLI::InteractiveTurn
|
|
88
93
|
include CLI::ToolSummaries
|
|
89
94
|
|
|
@@ -100,6 +105,11 @@ module Kward
|
|
|
100
105
|
@plugin_registry = nil
|
|
101
106
|
@working_directory = nil
|
|
102
107
|
@prompt_delimited = false
|
|
108
|
+
@requested_mode = "auto"
|
|
109
|
+
@experimental_workers = false
|
|
110
|
+
@foreground_turn_active = false
|
|
111
|
+
@pending_reasoning_config = nil
|
|
112
|
+
@pending_reasoning_config_mutex = Mutex.new
|
|
103
113
|
@color_enabled = ANSI.enabled?($stdout)
|
|
104
114
|
end
|
|
105
115
|
|
|
@@ -164,6 +174,28 @@ module Kward
|
|
|
164
174
|
return
|
|
165
175
|
end
|
|
166
176
|
|
|
177
|
+
if @argv.first == "edit"
|
|
178
|
+
if help_option_arguments?(@argv[1..] || [])
|
|
179
|
+
print_command_help("edit")
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
raise ArgumentError, command_usage("edit") unless @argv.length == 2
|
|
183
|
+
|
|
184
|
+
edit_file_command(@argv[1])
|
|
185
|
+
return
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if @argv.first == "count-tests"
|
|
189
|
+
if help_option_arguments?(@argv[1..] || [])
|
|
190
|
+
print_command_help("count-tests")
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
raise ArgumentError, command_usage("count-tests") unless @argv.length == 1
|
|
194
|
+
|
|
195
|
+
print_test_count
|
|
196
|
+
return
|
|
197
|
+
end
|
|
198
|
+
|
|
167
199
|
if @argv.first == "sysprompt"
|
|
168
200
|
if help_option_arguments?(@argv[1..] || [])
|
|
169
201
|
print_command_help("sysprompt")
|
|
@@ -181,7 +213,7 @@ module Kward
|
|
|
181
213
|
end
|
|
182
214
|
raise ArgumentError, command_usage("rpc") unless @argv.length == 1
|
|
183
215
|
|
|
184
|
-
Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client).run
|
|
216
|
+
Kward::RPC::Server.new(input: @stdin, output: $stdout, client: @client, experimental_workers: @experimental_workers).run
|
|
185
217
|
return
|
|
186
218
|
end
|
|
187
219
|
|
|
@@ -231,53 +263,107 @@ module Kward
|
|
|
231
263
|
run_prompt_or_interactive
|
|
232
264
|
end
|
|
233
265
|
|
|
266
|
+
def edit_file_command(path)
|
|
267
|
+
setup_interactive_prompt
|
|
268
|
+
unless @prompt.respond_to?(:edit_file)
|
|
269
|
+
raise ArgumentError, "The integrated editor requires an interactive terminal."
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
@prompt.edit_file(path, base_dir: Dir.pwd, allow_new: true)
|
|
273
|
+
ensure
|
|
274
|
+
@prompt.close if @prompt.respond_to?(:close) && prompt_interface?
|
|
275
|
+
end
|
|
276
|
+
|
|
234
277
|
def run_prompt_or_interactive
|
|
278
|
+
stdin_input = read_stdin_input
|
|
235
279
|
first_prompt = one_shot_prompt_argument
|
|
236
|
-
if first_prompt
|
|
237
|
-
answer = one_shot(first_prompt)
|
|
238
|
-
puts answer unless answer.empty?
|
|
239
|
-
return
|
|
240
|
-
end
|
|
241
280
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
281
|
+
case resolved_execution_mode(first_prompt: first_prompt, stdin_input: stdin_input)
|
|
282
|
+
when "chat"
|
|
283
|
+
interactive_loop
|
|
284
|
+
when "filter"
|
|
285
|
+
raise ArgumentError, "Filter mode requires stdin input." if stdin_input.nil?
|
|
286
|
+
|
|
287
|
+
answer = one_shot(filter_prompt(instruction: first_prompt, input: stdin_input), filter: true)
|
|
288
|
+
puts answer unless answer.empty?
|
|
289
|
+
when "oneshot"
|
|
290
|
+
input = first_prompt || stdin_input.to_s.strip
|
|
291
|
+
answer = one_shot(input)
|
|
245
292
|
puts answer unless answer.empty?
|
|
246
|
-
return
|
|
247
293
|
end
|
|
248
|
-
|
|
249
|
-
interactive_loop
|
|
250
294
|
end
|
|
251
295
|
|
|
252
|
-
def one_shot(input)
|
|
296
|
+
def one_shot(input, filter: false)
|
|
253
297
|
streamed = false
|
|
254
298
|
assistant_streamed = false
|
|
255
299
|
markdown_chunks = []
|
|
256
300
|
conversation = new_conversation
|
|
301
|
+
apply_filter_system_prompt(conversation) if filter
|
|
257
302
|
agent = Agent.new(
|
|
258
303
|
client: @client,
|
|
259
304
|
tool_registry: ToolRegistry.new(workspace: configured_workspace, prompt: @prompt),
|
|
260
305
|
conversation: conversation
|
|
261
306
|
)
|
|
262
|
-
answer =
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
307
|
+
answer = if filter
|
|
308
|
+
agent.ask(input)
|
|
309
|
+
else
|
|
310
|
+
agent.ask(input) do |event|
|
|
311
|
+
result = render_blocking_turn_event(event, markdown_chunks)
|
|
312
|
+
streamed = true if result
|
|
313
|
+
assistant_streamed = true if result == :assistant_streamed
|
|
314
|
+
end
|
|
266
315
|
end
|
|
267
316
|
flush_markdown_deltas(markdown_chunks) if streamed
|
|
317
|
+
return answer if filter
|
|
318
|
+
|
|
268
319
|
assistant_streamed ? "" : render_markdown_transcript(answer)
|
|
269
320
|
end
|
|
270
321
|
|
|
322
|
+
def resolved_execution_mode(first_prompt:, stdin_input:)
|
|
323
|
+
return @requested_mode unless @requested_mode == "auto"
|
|
324
|
+
return "chat" if stdin_input.nil? && first_prompt.nil?
|
|
325
|
+
return "filter" if !stdin_input.nil? && first_prompt
|
|
271
326
|
|
|
327
|
+
"oneshot"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def filter_prompt(instruction:, input:)
|
|
331
|
+
<<~PROMPT
|
|
332
|
+
Instruction:
|
|
333
|
+
#{instruction}
|
|
334
|
+
|
|
335
|
+
Input:
|
|
336
|
+
#{input}
|
|
337
|
+
PROMPT
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def apply_filter_system_prompt(conversation)
|
|
341
|
+
return unless conversation.system_message
|
|
272
342
|
|
|
343
|
+
conversation.system_message[:content] = [conversation.system_message[:content], filter_system_prompt].compact.join("\n\n")
|
|
344
|
+
end
|
|
273
345
|
|
|
346
|
+
def filter_system_prompt
|
|
347
|
+
<<~PROMPT.strip
|
|
348
|
+
You are being used as a command-line text filter.
|
|
274
349
|
|
|
350
|
+
Transform the provided input according to the user's instruction.
|
|
351
|
+
Return only the transformed output.
|
|
352
|
+
|
|
353
|
+
Do not include explanations, introductions, summaries, Markdown fences, or commentary.
|
|
354
|
+
Do not say what you changed.
|
|
355
|
+
Preserve the input format unless the instruction requires changing it.
|
|
356
|
+
If the input is code, data, markup, or configuration, output only the resulting code/data/markup/configuration.
|
|
357
|
+
PROMPT
|
|
358
|
+
end
|
|
275
359
|
|
|
276
360
|
def interactive_loop(agent: nil)
|
|
277
361
|
setup_interactive_prompt
|
|
278
362
|
session_store = interactive_session_store(agent)
|
|
279
363
|
@resumed_last_session = false
|
|
280
|
-
if session_store &&
|
|
364
|
+
if session_store && @prompt.respond_to?(:update_tabs)
|
|
365
|
+
agent = setup_interactive_tabs(session_store, agent)
|
|
366
|
+
elsif session_store && agent.nil?
|
|
281
367
|
agent = resume_last_session(session_store) || build_new_session_agent(session_store)
|
|
282
368
|
elsif session_store
|
|
283
369
|
@active_session = track_session(session_store.create(provider: current_model_provider, model: current_model_id, reasoning_effort: current_reasoning_effort))
|
|
@@ -290,13 +376,29 @@ module Kward
|
|
|
290
376
|
update_assistant_prompt(agent.conversation)
|
|
291
377
|
@footer_conversation = agent.conversation
|
|
292
378
|
|
|
293
|
-
print_visual_banner unless @resumed_last_session
|
|
379
|
+
print_visual_banner unless @resumed_last_session || @restored_tabs
|
|
294
380
|
render_resumed_last_session_transcript(agent.conversation) if @resumed_last_session
|
|
295
381
|
|
|
296
382
|
@pending_inputs = []
|
|
297
383
|
|
|
298
384
|
loop do
|
|
299
|
-
|
|
385
|
+
if @pending_inputs.empty? && active_tab&.shell
|
|
386
|
+
run_ekwsh_loop(active_tab.shell, tab: active_tab)
|
|
387
|
+
end
|
|
388
|
+
input = @pending_inputs.shift || (active_tab ? poll_active_tab_input : @prompt.ask("You>"))
|
|
389
|
+
if input.is_a?(Hash) && input[:tab_action]
|
|
390
|
+
tab_result = handle_tab_action(input, session_store)
|
|
391
|
+
break if tab_result == PromptInterface::EXIT_INPUT
|
|
392
|
+
agent = active_tab.agent if active_tab
|
|
393
|
+
next
|
|
394
|
+
end
|
|
395
|
+
if input.is_a?(Hash) && input[:reasoning_action]
|
|
396
|
+
conversation = active_tab ? active_tab.agent.conversation : agent.conversation
|
|
397
|
+
cycle_reasoning(conversation, direction: input[:reasoning_action], persist: :debounced)
|
|
398
|
+
agent = active_tab.agent if active_tab
|
|
399
|
+
next
|
|
400
|
+
end
|
|
401
|
+
next if input == :tab_idle
|
|
300
402
|
break if input.nil?
|
|
301
403
|
|
|
302
404
|
display_input = submitted_display_input(input)
|
|
@@ -314,31 +416,57 @@ module Kward
|
|
|
314
416
|
end
|
|
315
417
|
break if ["/exit", "/quit"].include?(command)
|
|
316
418
|
handled, replacement_agent = handle_local_slash_command(command, agent, session_store)
|
|
317
|
-
|
|
419
|
+
if replacement_agent?(replacement_agent)
|
|
420
|
+
agent = active_tab ? replace_active_tab_agent(replacement_agent) : replacement_agent
|
|
421
|
+
end
|
|
318
422
|
end
|
|
319
423
|
next if handled
|
|
424
|
+
request_handled, request_replacement = handle_request_worker_input(command_input, agent, session_store)
|
|
425
|
+
if request_handled
|
|
426
|
+
if replacement_agent?(request_replacement)
|
|
427
|
+
agent = active_tab ? replace_active_tab_agent(request_replacement) : request_replacement
|
|
428
|
+
end
|
|
429
|
+
next
|
|
430
|
+
end
|
|
320
431
|
next if shell_command_input?(command_input) && handle_interactive_shell_command(command_input, agent)
|
|
321
432
|
|
|
433
|
+
flush_pending_reasoning_config(conversation: agent.conversation)
|
|
322
434
|
expanded_input = expand_prompt_template(input)
|
|
323
435
|
display_input = display_input || input if expanded_input
|
|
324
436
|
input = expanded_input || input
|
|
437
|
+
agent = refresh_implementation_writer(agent)
|
|
325
438
|
@footer_conversation = agent.conversation
|
|
326
439
|
begin
|
|
327
440
|
@rewind_return_leaf_id = nil
|
|
328
441
|
auto_name_active_session(display_input || input)
|
|
329
|
-
|
|
442
|
+
@foreground_turn_active = true if @active_worker_role == "implementation"
|
|
443
|
+
if active_tab
|
|
444
|
+
submit_tab_input(active_tab, input, display_input: display_input)
|
|
445
|
+
pending_inputs = []
|
|
446
|
+
else
|
|
447
|
+
pending_inputs = run_interactive_turn(agent, input, display_input: display_input)
|
|
448
|
+
agent = @busy_replacement_agent if replacement_agent?(@busy_replacement_agent)
|
|
449
|
+
@busy_replacement_agent = nil
|
|
450
|
+
end
|
|
330
451
|
pending_inputs.reverse_each { |pending_input| @pending_inputs.unshift(pending_input) }
|
|
331
452
|
rescue StandardError => e
|
|
332
453
|
runtime_output("Error: #{e.message}")
|
|
454
|
+
ensure
|
|
455
|
+
@foreground_turn_active = false if @active_worker_role == "implementation"
|
|
456
|
+
release_implementation_writer if @active_worker_role == "implementation"
|
|
333
457
|
end
|
|
334
458
|
end
|
|
335
459
|
|
|
460
|
+
flush_pending_reasoning_config(conversation: agent.conversation)
|
|
336
461
|
agent.conversation
|
|
337
462
|
rescue Interrupt
|
|
463
|
+
flush_pending_reasoning_config(conversation: agent&.conversation)
|
|
338
464
|
runtime_output("Goodbye.")
|
|
339
465
|
agent&.conversation
|
|
340
466
|
ensure
|
|
341
467
|
begin
|
|
468
|
+
stop_tabs if respond_to?(:stop_tabs, true)
|
|
469
|
+
stop_live_worker_view if respond_to?(:stop_live_worker_view, true)
|
|
342
470
|
@prompt.close if prompt_interface?
|
|
343
471
|
ensure
|
|
344
472
|
cleanup_unused_sessions
|
|
@@ -347,9 +475,13 @@ module Kward
|
|
|
347
475
|
end
|
|
348
476
|
|
|
349
477
|
def piped_prompt
|
|
350
|
-
|
|
478
|
+
read_stdin_input.to_s.strip
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def read_stdin_input
|
|
482
|
+
return nil if @stdin.tty?
|
|
351
483
|
|
|
352
|
-
@stdin.read
|
|
484
|
+
@stdin.read
|
|
353
485
|
end
|
|
354
486
|
|
|
355
487
|
end
|
data/lib/kward/config_files.rb
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
require "digest"
|
|
1
2
|
require "fileutils"
|
|
2
3
|
require "json"
|
|
3
4
|
require "yaml"
|
|
4
5
|
require_relative "private_file"
|
|
6
|
+
require_relative "editor_mode"
|
|
5
7
|
require_relative "prompts/templates"
|
|
6
8
|
require_relative "skills/registry"
|
|
7
9
|
|
|
@@ -64,6 +66,10 @@ module Kward
|
|
|
64
66
|
File.join(config_dir, "cache")
|
|
65
67
|
end
|
|
66
68
|
|
|
69
|
+
def ekwsh_config_path
|
|
70
|
+
File.join(config_dir, "ekwsh.yml")
|
|
71
|
+
end
|
|
72
|
+
|
|
67
73
|
def default_config
|
|
68
74
|
{
|
|
69
75
|
"personas" => JSON.parse(JSON.generate(DEFAULT_PERSONAS)),
|
|
@@ -72,7 +78,16 @@ module Kward
|
|
|
72
78
|
"auto_summary" => false
|
|
73
79
|
},
|
|
74
80
|
"composer" => {
|
|
75
|
-
"busy_help" => true
|
|
81
|
+
"busy_help" => true,
|
|
82
|
+
"tab_keybindings" => "auto"
|
|
83
|
+
},
|
|
84
|
+
"editor" => {
|
|
85
|
+
"mode" => "modern",
|
|
86
|
+
"auto_indent" => true,
|
|
87
|
+
"auto_close_pairs" => true,
|
|
88
|
+
"soft_wrap" => true,
|
|
89
|
+
"bar_cursor" => true,
|
|
90
|
+
"line_numbers" => "absolute"
|
|
76
91
|
},
|
|
77
92
|
"sessions" => {
|
|
78
93
|
"auto_resume" => false
|
|
@@ -101,6 +116,15 @@ module Kward
|
|
|
101
116
|
File.join(cache_dir, "openrouter_models.json")
|
|
102
117
|
end
|
|
103
118
|
|
|
119
|
+
def project_browser_state_path
|
|
120
|
+
File.join(cache_dir, "project_browser_state.json")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def prompt_history_path(cwd, config_dir: self.config_dir)
|
|
124
|
+
key = Digest::SHA256.hexdigest(canonical_workspace_root(cwd))[0, 24]
|
|
125
|
+
File.join(config_dir, "history", "#{key}.jsonl")
|
|
126
|
+
end
|
|
127
|
+
|
|
104
128
|
# @return [String] directory containing structured memory files
|
|
105
129
|
def memory_dir
|
|
106
130
|
File.join(config_dir, "memory")
|
|
@@ -144,6 +168,50 @@ module Kward
|
|
|
144
168
|
PrivateFile.write_json(path, config)
|
|
145
169
|
end
|
|
146
170
|
|
|
171
|
+
def read_ekwsh_config(path = ekwsh_config_path)
|
|
172
|
+
path = File.expand_path(path)
|
|
173
|
+
return { env: {}, aliases: {} } unless File.exist?(path)
|
|
174
|
+
|
|
175
|
+
data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
176
|
+
normalize_ekwsh_config(data)
|
|
177
|
+
rescue Psych::SyntaxError => e
|
|
178
|
+
raise "Invalid ekwsh YAML config: #{path}: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def normalize_ekwsh_config(data)
|
|
182
|
+
data = data.transform_keys(&:to_s) if data.is_a?(Hash)
|
|
183
|
+
settings = data.is_a?(Hash) ? data : {}
|
|
184
|
+
{
|
|
185
|
+
env: normalize_ekwsh_env(settings["env"]),
|
|
186
|
+
aliases: normalize_ekwsh_aliases(settings["aliases"])
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def normalize_ekwsh_env(values)
|
|
191
|
+
return {} unless values.is_a?(Hash)
|
|
192
|
+
|
|
193
|
+
values.each_with_object({}) do |(key, value), result|
|
|
194
|
+
key = key.to_s
|
|
195
|
+
next unless key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
196
|
+
next if value.nil?
|
|
197
|
+
|
|
198
|
+
result[key] = value.to_s
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def normalize_ekwsh_aliases(values)
|
|
203
|
+
return {} unless values.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
values.each_with_object({}) do |(name, command), result|
|
|
206
|
+
name = name.to_s
|
|
207
|
+
command = command.to_s.strip
|
|
208
|
+
next unless name.match?(/\A[A-Za-z0-9_.:-]+\z/)
|
|
209
|
+
next if command.empty?
|
|
210
|
+
|
|
211
|
+
result[name] = command
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
147
215
|
# Merges top-level config values and writes the updated config privately.
|
|
148
216
|
def update_config(values, path = config_path)
|
|
149
217
|
raise "Config values must be an object" unless values.is_a?(Hash)
|
|
@@ -154,6 +222,17 @@ module Kward
|
|
|
154
222
|
config
|
|
155
223
|
end
|
|
156
224
|
|
|
225
|
+
# Merges values into a one-level nested config section and writes privately.
|
|
226
|
+
def update_nested_config(section, values, path = config_path)
|
|
227
|
+
raise "Config values must be an object" unless values.is_a?(Hash)
|
|
228
|
+
|
|
229
|
+
config = read_config(path)
|
|
230
|
+
current = config[section.to_s].is_a?(Hash) ? config[section.to_s].dup : {}
|
|
231
|
+
config[section.to_s] = current.merge(values.transform_keys(&:to_s))
|
|
232
|
+
write_config(config, path)
|
|
233
|
+
config
|
|
234
|
+
end
|
|
235
|
+
|
|
157
236
|
# Removes a top-level config key when it exists.
|
|
158
237
|
def delete_config_key(key, path = config_path)
|
|
159
238
|
config = read_config(path)
|
|
@@ -193,6 +272,49 @@ module Kward
|
|
|
193
272
|
composer["busy_help"] != false
|
|
194
273
|
end
|
|
195
274
|
|
|
275
|
+
# Returns the configured tab keybinding family, or auto when unset/invalid.
|
|
276
|
+
def composer_tab_keybindings(config = read_config)
|
|
277
|
+
composer = config["composer"].is_a?(Hash) ? config["composer"] : {}
|
|
278
|
+
value = composer["tab_keybindings"].to_s.downcase
|
|
279
|
+
%w[auto ctrl alt].include?(value) ? value : "auto"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Returns the built-in TUI editor keymap mode.
|
|
283
|
+
def editor_mode(config = read_config)
|
|
284
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
285
|
+
EditorMode.normalize(editor["mode"])
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Returns whether the built-in TUI editor should auto-indent new lines.
|
|
289
|
+
def editor_auto_indent?(config = read_config)
|
|
290
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
291
|
+
editor["auto_indent"] != false
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Returns whether the built-in TUI editor should auto-close typed pairs.
|
|
295
|
+
def editor_auto_close_pairs?(config = read_config)
|
|
296
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
297
|
+
editor["auto_close_pairs"] != false
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Returns whether the built-in TUI editor should soft-wrap long lines.
|
|
301
|
+
def editor_soft_wrap?(config = read_config)
|
|
302
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
303
|
+
editor["soft_wrap"] != false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Returns whether editable built-in TUI editor buffers should use a bar cursor.
|
|
307
|
+
def editor_bar_cursor?(config = read_config)
|
|
308
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
309
|
+
editor["bar_cursor"] != false
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Returns the built-in TUI editor line-number display mode.
|
|
313
|
+
def editor_line_numbers(config = read_config)
|
|
314
|
+
editor = config["editor"].is_a?(Hash) ? config["editor"] : {}
|
|
315
|
+
EditorMode.normalize_line_numbers(editor["line_numbers"])
|
|
316
|
+
end
|
|
317
|
+
|
|
196
318
|
# Returns whether file tools must stay inside the active workspace root.
|
|
197
319
|
def workspace_guardrails_enabled?(config = read_config)
|
|
198
320
|
tools = config["tools"].is_a?(Hash) ? config["tools"] : {}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Tracks approximate context bytes saved by tool budgeting during one process.
|
|
4
|
+
class ContextBudgetMeter
|
|
5
|
+
Snapshot = Struct.new(:calls, :original_bytes, :returned_bytes, :saved_bytes, :tool_breakdown, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@calls = 0
|
|
10
|
+
@original_bytes = 0
|
|
11
|
+
@returned_bytes = 0
|
|
12
|
+
@tool_breakdown = Hash.new { |hash, key| hash[key] = { calls: 0, originalBytes: 0, returnedBytes: 0, savedBytes: 0 } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record(tool_name:, original_bytes:, returned_bytes:)
|
|
16
|
+
original_bytes = original_bytes.to_i
|
|
17
|
+
returned_bytes = returned_bytes.to_i
|
|
18
|
+
saved_bytes = [original_bytes - returned_bytes, 0].max
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@calls += 1
|
|
21
|
+
@original_bytes += original_bytes
|
|
22
|
+
@returned_bytes += returned_bytes
|
|
23
|
+
entry = @tool_breakdown[tool_name.to_s]
|
|
24
|
+
entry[:calls] += 1
|
|
25
|
+
entry[:originalBytes] += original_bytes
|
|
26
|
+
entry[:returnedBytes] += returned_bytes
|
|
27
|
+
entry[:savedBytes] += saved_bytes
|
|
28
|
+
end
|
|
29
|
+
saved_bytes
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def snapshot
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
Snapshot.new(
|
|
35
|
+
calls: @calls,
|
|
36
|
+
original_bytes: @original_bytes,
|
|
37
|
+
returned_bytes: @returned_bytes,
|
|
38
|
+
saved_bytes: [@original_bytes - @returned_bytes, 0].max,
|
|
39
|
+
tool_breakdown: @tool_breakdown.transform_values(&:dup)
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/kward/conversation.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "set"
|
|
3
|
+
require_relative "context_budget_meter"
|
|
3
4
|
require_relative "image_attachments"
|
|
4
5
|
require_relative "message_access"
|
|
5
6
|
require_relative "plugin_registry"
|
|
@@ -60,6 +61,8 @@ module Kward
|
|
|
60
61
|
attr_reader :last_plugin_prompt_context
|
|
61
62
|
# @return [Hash] original large tool outputs retained outside model context
|
|
62
63
|
attr_reader :tool_output_artifacts
|
|
64
|
+
# @return [ContextBudgetMeter] runtime context savings for this conversation
|
|
65
|
+
attr_reader :context_budget_meter
|
|
63
66
|
|
|
64
67
|
def initialize(system_message: DEFAULT_SYSTEM_MESSAGE, messages: [], read_paths: [], on_append: nil, on_compact: nil, on_tool_execution: nil, on_runtime_update: nil, workspace_root: Dir.pwd, compaction_system_message: DEFAULT_SYSTEM_MESSAGE, provider: nil, model: nil, reasoning_effort: nil, memory_context: nil, session_memories: [], last_memory_retrieval: nil, plugin_registry: nil)
|
|
65
68
|
@workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
|
|
@@ -89,6 +92,7 @@ module Kward
|
|
|
89
92
|
@session_memories = Array(session_memories)
|
|
90
93
|
@last_memory_retrieval = last_memory_retrieval
|
|
91
94
|
@tool_output_artifacts = {}
|
|
95
|
+
@context_budget_meter = ContextBudgetMeter.new
|
|
92
96
|
@messages.concat(transcript_messages)
|
|
93
97
|
@read_paths = Set.new(read_paths)
|
|
94
98
|
@on_append = on_append
|
|
@@ -146,6 +150,10 @@ module Kward
|
|
|
146
150
|
end
|
|
147
151
|
|
|
148
152
|
def store_tool_output_artifact(tool_name:, content:)
|
|
153
|
+
restore_tool_output_artifact(tool_name: tool_name, content: content, created_at: Time.now.utc)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def restore_tool_output_artifact(tool_name:, content:, created_at: nil)
|
|
149
157
|
text = self.class.normalize_tool_content(content)
|
|
150
158
|
id = tool_output_artifact_id_for(tool_name: tool_name, content: text)
|
|
151
159
|
@tool_output_artifacts[id] = {
|
|
@@ -153,7 +161,7 @@ module Kward
|
|
|
153
161
|
tool_name: tool_name,
|
|
154
162
|
content: text,
|
|
155
163
|
bytes: text.bytesize,
|
|
156
|
-
created_at: Time.now.utc
|
|
164
|
+
created_at: created_at || Time.now.utc
|
|
157
165
|
}
|
|
158
166
|
id
|
|
159
167
|
end
|
|
@@ -186,11 +194,11 @@ module Kward
|
|
|
186
194
|
replacement
|
|
187
195
|
end
|
|
188
196
|
|
|
189
|
-
def update_runtime_context!(provider: nil, model:, reasoning_effort:)
|
|
197
|
+
def update_runtime_context!(provider: nil, model:, reasoning_effort:, refresh: true)
|
|
190
198
|
@provider = provider unless provider.to_s.empty?
|
|
191
199
|
@model = model
|
|
192
200
|
@reasoning_effort = reasoning_effort
|
|
193
|
-
refresh_system_message!
|
|
201
|
+
refresh_system_message! if refresh
|
|
194
202
|
end
|
|
195
203
|
|
|
196
204
|
def persist_runtime_context!
|
|
@@ -270,7 +278,7 @@ module Kward
|
|
|
270
278
|
end
|
|
271
279
|
|
|
272
280
|
def prompt_time
|
|
273
|
-
Time.
|
|
281
|
+
Time.now
|
|
274
282
|
end
|
|
275
283
|
|
|
276
284
|
def workspace_agents_mtime
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Normalizes built-in TUI file editor mode names.
|
|
4
|
+
module EditorMode
|
|
5
|
+
MODES = %w[modern emacs vibe].freeze
|
|
6
|
+
DEFAULT = "modern".freeze
|
|
7
|
+
LINE_NUMBER_MODES = %w[absolute relative].freeze
|
|
8
|
+
DEFAULT_LINE_NUMBERS = "absolute".freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def normalize(value)
|
|
13
|
+
text = value.to_s.downcase
|
|
14
|
+
return DEFAULT if text == "default"
|
|
15
|
+
return "vibe" if text == "vi"
|
|
16
|
+
|
|
17
|
+
MODES.include?(text) ? text : DEFAULT
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def normalize_line_numbers(value)
|
|
21
|
+
text = value.to_s.downcase
|
|
22
|
+
LINE_NUMBER_MODES.include?(text) ? text : DEFAULT_LINE_NUMBERS
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|