kward 0.70.0 → 0.71.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/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +48 -2
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +30 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +43 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +39 -25
- data/doc/configuration.md +1 -15
- data/doc/context-tools.md +70 -0
- data/doc/plugins.md +2 -2
- data/doc/releasing.md +14 -5
- data/doc/rpc.md +3 -11
- data/doc/session-management.md +220 -0
- data/doc/usage.md +7 -8
- data/doc/workspace-tools.md +105 -0
- data/lib/kward/cli/commands.rb +8 -0
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/prompt_interface.rb +80 -6
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/sessions.rb +260 -11
- data/lib/kward/cli/settings.rb +0 -30
- data/lib/kward/cli/slash_commands.rb +24 -6
- data/lib/kward/cli.rb +13 -0
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +4 -6
- data/lib/kward/conversation.rb +49 -20
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -16
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +47 -1
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +60 -87
- data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
- data/lib/kward/prompt_interface/key_handler.rb +31 -10
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
- data/lib/kward/prompt_interface/question_prompt.rb +34 -42
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +1 -0
- data/lib/kward/prompt_interface/selection_prompt.rb +513 -54
- data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +22 -28
- data/lib/kward/prompts/commands.rb +2 -1
- data/lib/kward/prompts.rb +2 -2
- data/lib/kward/rpc/server.rb +3 -8
- data/lib/kward/rpc/session_manager.rb +17 -6
- data/lib/kward/session_store.rb +23 -4
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/registry.rb +37 -6
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +2 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +58 -2
- data/templates/default/fulldoc/html/css/kward.css +256 -7
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +161 -2
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +91 -0
- data/templates/default/layout/html/layout.erb +39 -8
- data/templates/default/layout/html/setup.rb +33 -38
- metadata +13 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- data/lib/kward/resources/pixel_logo.rb +0 -232
|
@@ -10,7 +10,6 @@ module Kward
|
|
|
10
10
|
@limit = limit
|
|
11
11
|
@text = +""
|
|
12
12
|
@display_rows_cache_width = nil
|
|
13
|
-
@display_rows_cache_banner_count = nil
|
|
14
13
|
@display_rows_cache = nil
|
|
15
14
|
end
|
|
16
15
|
|
|
@@ -42,30 +41,23 @@ module Kward
|
|
|
42
41
|
@text
|
|
43
42
|
end
|
|
44
43
|
|
|
45
|
-
def viewport_text(row_count, width
|
|
46
|
-
viewport_rows(row_count, width
|
|
44
|
+
def viewport_text(row_count, width)
|
|
45
|
+
viewport_rows(row_count, width).join("\n")
|
|
47
46
|
end
|
|
48
47
|
|
|
49
|
-
def viewport_rows(row_count, width
|
|
48
|
+
def viewport_rows(row_count, width)
|
|
50
49
|
return [] unless row_count.positive?
|
|
51
50
|
|
|
52
|
-
rows = display_rows(width
|
|
51
|
+
rows = display_rows(width).last(row_count)
|
|
53
52
|
rows = ([""] * (row_count - rows.length)) + rows if rows.length < row_count
|
|
54
53
|
rows
|
|
55
54
|
end
|
|
56
55
|
|
|
57
|
-
def display_rows(width
|
|
58
|
-
if @display_rows_cache_width == width && @
|
|
59
|
-
return @display_rows_cache
|
|
60
|
-
end
|
|
56
|
+
def display_rows(width)
|
|
57
|
+
return @display_rows_cache if @display_rows_cache_width == width && @display_rows_cache
|
|
61
58
|
|
|
62
|
-
rows = []
|
|
63
|
-
visual_banner_count.times { rows.concat(banner_rows.call(width)) }
|
|
64
|
-
rows << "" if visual_banner_count.positive? && @text.empty?
|
|
65
|
-
rows.concat(text_display_rows(width))
|
|
66
59
|
@display_rows_cache_width = width
|
|
67
|
-
@
|
|
68
|
-
@display_rows_cache = rows
|
|
60
|
+
@display_rows_cache = text_display_rows(width)
|
|
69
61
|
end
|
|
70
62
|
|
|
71
63
|
def text_display_rows(width)
|
|
@@ -77,7 +69,6 @@ module Kward
|
|
|
77
69
|
|
|
78
70
|
def invalidate_display_rows_cache
|
|
79
71
|
@display_rows_cache_width = nil
|
|
80
|
-
@display_rows_cache_banner_count = nil
|
|
81
72
|
@display_rows_cache = nil
|
|
82
73
|
end
|
|
83
74
|
end
|
|
@@ -11,7 +11,7 @@ module Kward
|
|
|
11
11
|
prepare_transcript_output_locked unless @restoring_transcript
|
|
12
12
|
if label && @stream_state.block != label
|
|
13
13
|
ensure_transcript_block_separator_locked
|
|
14
|
-
write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))}
|
|
14
|
+
write_transcript_text_locked("#{colored("#{transcript_label(label)}>", *label_styles(label))} ")
|
|
15
15
|
@stream_state.start_block(label)
|
|
16
16
|
end
|
|
17
17
|
write_transcript_text_locked(delta) unless delta.empty?
|
|
@@ -82,11 +82,11 @@ module Kward
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def transcript_renderable?
|
|
85
|
-
|
|
85
|
+
!@transcript_buffer.empty?
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
def transcript_display_rows(width)
|
|
89
|
-
@transcript_buffer.display_rows(width
|
|
89
|
+
@transcript_buffer.display_rows(width)
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def transcript_text_display_rows(width)
|
|
@@ -41,7 +41,6 @@ module Kward
|
|
|
41
41
|
FOOTER_REFRESH_INTERVAL = 1.0
|
|
42
42
|
COMPOSER_MAX_INPUT_ROWS = 6
|
|
43
43
|
TRANSCRIPT_BUFFER_LIMIT = 200_000
|
|
44
|
-
BANNER_LOGO_PIXELS = Banner::LOGO_PIXELS
|
|
45
44
|
BANNER_MESSAGE = Banner::MESSAGE
|
|
46
45
|
|
|
47
46
|
include SlashOverlay
|
|
@@ -70,6 +69,8 @@ module Kward
|
|
|
70
69
|
EXIT_INPUT = :exit_input
|
|
71
70
|
CANCEL_INPUT = :cancel_input
|
|
72
71
|
SELECT_CANCEL = :select_cancel
|
|
72
|
+
SELECT_CONTINUE = :select_continue
|
|
73
|
+
SELECT_ACTION_MINIMUM_BUSY_SECONDS = 1.0
|
|
73
74
|
|
|
74
75
|
# Submitted input string carrying optional display text for transcripts.
|
|
75
76
|
class SubmittedInput < String
|
|
@@ -81,7 +82,7 @@ module Kward
|
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
|
|
84
|
-
def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil,
|
|
85
|
+
def initialize(input: $stdin, output: $stdout, slash_commands: [], overlay_settings: nil, footer: nil, composer_status: nil, busy_help: true, attachment_badges: nil, attachment_parser: nil, banner_message: nil)
|
|
85
86
|
@input_io = input
|
|
86
87
|
@output_io = output
|
|
87
88
|
@reader = TTY::Reader.new(input: input, output: output, interrupt: :error)
|
|
@@ -105,7 +106,6 @@ module Kward
|
|
|
105
106
|
@last_composer_rows = []
|
|
106
107
|
@cursor_rendered_row = 0
|
|
107
108
|
@transcript_buffer = TranscriptBuffer.new(limit: TRANSCRIPT_BUFFER_LIMIT)
|
|
108
|
-
@visual_banner_count = 0
|
|
109
109
|
@transcript_viewport_rows = 0
|
|
110
110
|
@restoring_transcript = false
|
|
111
111
|
@pending_keys = []
|
|
@@ -128,7 +128,7 @@ module Kward
|
|
|
128
128
|
@busy_help = busy_help
|
|
129
129
|
@attachment_badges = attachment_badges
|
|
130
130
|
@attachment_parser = attachment_parser
|
|
131
|
-
@banner = Banner.new(message: banner_message,
|
|
131
|
+
@banner = Banner.new(message: banner_message, screen_height: method(:screen_height))
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
def start(render: true)
|
|
@@ -203,7 +203,6 @@ module Kward
|
|
|
203
203
|
@output_io.print(SYNCHRONIZED_OUTPUT_ENABLE)
|
|
204
204
|
clear_prompt_for_output_locked unless @rendered_rows.zero? && @last_composer_rows.empty?
|
|
205
205
|
@transcript_buffer.clear
|
|
206
|
-
@visual_banner_count = 0
|
|
207
206
|
@transcript_viewport_rows = 0
|
|
208
207
|
@stream_state.finish_block
|
|
209
208
|
@stream_state.reset
|
|
@@ -275,23 +274,15 @@ module Kward
|
|
|
275
274
|
[overlay_card_width(screen_width) - 6, 1].max
|
|
276
275
|
end
|
|
277
276
|
|
|
278
|
-
def select(message, choices, title: "Sessions", custom: false, initial_index: 0)
|
|
277
|
+
def select(message, choices, title: "Sessions", custom: false, initial_index: 0, action_keys: {}, action_handlers: {})
|
|
279
278
|
return nil if choices.empty? && !custom
|
|
280
279
|
|
|
281
280
|
start
|
|
282
281
|
@mutex.synchronize do
|
|
283
|
-
|
|
284
|
-
self.composer_input = ""
|
|
285
|
-
self.composer_cursor = 0
|
|
286
|
-
@composer.clear_attachments
|
|
287
|
-
@pending_keys.clear
|
|
288
|
-
@asking = true
|
|
289
|
-
@busy = false
|
|
290
|
-
@queued_count = 0
|
|
282
|
+
prepare_modal_input_locked(message, clear_attachments: true)
|
|
291
283
|
choice_labels = choices.map(&:to_s)
|
|
292
284
|
selection_index = choice_labels.empty? ? 0 : [[initial_index.to_i, 0].max, choice_labels.length - 1].min
|
|
293
|
-
@select_state = { choices: choice_labels, selection_index: selection_index, title: title.to_s, custom: custom }
|
|
294
|
-
reset_history_navigation
|
|
285
|
+
@select_state = { choices: choice_labels, selection_index: selection_index, title: title.to_s, custom: custom, action_keys: normalized_select_action_keys(action_keys), search_active: false }
|
|
295
286
|
render_prompt_locked
|
|
296
287
|
end
|
|
297
288
|
|
|
@@ -305,12 +296,20 @@ module Kward
|
|
|
305
296
|
render_prompt_locked if resized || footer_refreshed
|
|
306
297
|
else
|
|
307
298
|
result = handle_select_key(key)
|
|
308
|
-
|
|
299
|
+
result = drain_pending_select_keys_locked(result)
|
|
300
|
+
render_prompt_locked unless result.is_a?(String) || select_action_result?(result) || result == SELECT_CANCEL
|
|
309
301
|
end
|
|
310
302
|
end
|
|
311
303
|
|
|
312
|
-
if
|
|
313
|
-
|
|
304
|
+
if select_action_result?(result) && select_action_handler(result, action_handlers)
|
|
305
|
+
action_result = run_select_action(result, select_action_handler(result, action_handlers))
|
|
306
|
+
next if action_result == SELECT_CONTINUE
|
|
307
|
+
|
|
308
|
+
return action_result
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
if result.is_a?(String) || select_action_result?(result) || result == SELECT_CANCEL
|
|
312
|
+
finish_select_prompt(render: !select_deferred_finish_render?(result))
|
|
314
313
|
return result == SELECT_CANCEL ? nil : result
|
|
315
314
|
end
|
|
316
315
|
|
|
@@ -430,20 +429,16 @@ module Kward
|
|
|
430
429
|
end
|
|
431
430
|
end
|
|
432
431
|
|
|
433
|
-
def print_visual_banner
|
|
432
|
+
def print_visual_banner(message = nil)
|
|
434
433
|
@mutex.synchronize do
|
|
435
434
|
width, height = screen_size
|
|
436
|
-
rows = banner_rows(width)
|
|
435
|
+
rows = banner_rows(width, message: message)
|
|
437
436
|
return if rows.empty?
|
|
438
437
|
|
|
439
438
|
with_synchronized_output_locked do
|
|
440
439
|
prepare_transcript_output_locked
|
|
441
|
-
rows.
|
|
442
|
-
|
|
443
|
-
write_visual_transcript_text_locked("\n")
|
|
444
|
-
end
|
|
445
|
-
@visual_banner_count += 1
|
|
446
|
-
invalidate_transcript_display_rows_cache
|
|
440
|
+
write_transcript_text_locked(rows.join("\n"))
|
|
441
|
+
write_transcript_text_locked("\n")
|
|
447
442
|
remember_transcript_viewport_locked(height)
|
|
448
443
|
@stream_state.finish_block
|
|
449
444
|
restore_composer_cursor_locked
|
|
@@ -487,7 +482,6 @@ module Kward
|
|
|
487
482
|
def clear_transcript
|
|
488
483
|
@mutex.synchronize do
|
|
489
484
|
@transcript_buffer.clear
|
|
490
|
-
@visual_banner_count = 0
|
|
491
485
|
@transcript_viewport_rows = 0
|
|
492
486
|
@stream_state.finish_block
|
|
493
487
|
@stream_state.reset
|
|
@@ -11,7 +11,9 @@ module Kward
|
|
|
11
11
|
{ name: "sessions", description: "Open the saved sessions picker.", argument_hint: "[path]" },
|
|
12
12
|
{ name: "resume", description: "Alias for /sessions.", argument_hint: "[path]" },
|
|
13
13
|
{ name: "name", description: "Name or clear the current session.", argument_hint: "[name]" },
|
|
14
|
+
{ name: "rename", description: "Rename the current session.", argument_hint: "<name>" },
|
|
14
15
|
{ name: "clone", description: "Clone the current session.", argument_hint: "" },
|
|
16
|
+
{ name: "fork", description: "Fork from an earlier prompt into a new session.", argument_hint: "" },
|
|
15
17
|
{ name: "rewind", description: "Revisit an earlier prompt and try a different direction.", argument_hint: "" },
|
|
16
18
|
{ name: "tree", description: "Inspect and navigate the full technical session tree.", argument_hint: "" },
|
|
17
19
|
{ name: "copy", description: "Copy clean session text to the clipboard.", argument_hint: "[last|transcript]" },
|
|
@@ -21,7 +23,6 @@ module Kward
|
|
|
21
23
|
{ name: "settings", description: "Configure prompt overlays.", argument_hint: "" },
|
|
22
24
|
{ name: "login", description: "Log in with an OAuth provider.", argument_hint: "" },
|
|
23
25
|
{ name: "model", description: "Select the default model.", argument_hint: "" },
|
|
24
|
-
{ name: "openrouter/catalog", description: "List the full OpenRouter model catalog.", argument_hint: "" },
|
|
25
26
|
{ name: "reasoning", description: "Select reasoning effort.", argument_hint: "" },
|
|
26
27
|
{ name: "reload", description: "Reload installed plugins.", argument_hint: "" },
|
|
27
28
|
{ name: "status", description: "Show the current status message.", argument_hint: "" },
|
data/lib/kward/prompts.rb
CHANGED
|
@@ -32,9 +32,9 @@ module Kward
|
|
|
32
32
|
|
|
33
33
|
def base_prompt
|
|
34
34
|
<<~PROMPT.strip
|
|
35
|
-
You are Kward, a concise practical CLI coding agent.
|
|
35
|
+
You are Kward, a concise practical CLI coding agent. Use tools to understand and modify software projects. Inspect files before changing them, make the smallest correct change, preserve existing style, and summarize what changed. Be honest about limitations.
|
|
36
36
|
|
|
37
|
-
For web research, use web_search to discover sources,
|
|
37
|
+
For web research, use web_search to discover sources, fetch_content for important human-readable pages, and fetch_raw for machine-readable resources such as JSON, YAML, XML, RSS, OpenAPI specs, and plain text. Prefer official or primary sources and cite or mention the URLs you relied on.
|
|
38
38
|
PROMPT
|
|
39
39
|
end
|
|
40
40
|
|
data/lib/kward/rpc/server.rb
CHANGED
|
@@ -49,7 +49,7 @@ module Kward
|
|
|
49
49
|
"sessions/tree", "sessions/tree/setLabel", "sessions/tree/navigate",
|
|
50
50
|
"sessions/export", "sessions/delete", "sessions/close", "sessions/transcript"
|
|
51
51
|
].freeze
|
|
52
|
-
MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"
|
|
52
|
+
MODEL_METHODS = ["models/list", "models/current", "models/set", "reasoning/set"].freeze
|
|
53
53
|
AUTH_METHODS = [
|
|
54
54
|
"auth/status", "auth/providers", "auth/loginWithApiKey", "auth/logoutProvider",
|
|
55
55
|
"auth/loginWithOAuth", "auth/startOpenAILogin", "auth/submitOpenAICode", "auth/loginStatus"
|
|
@@ -166,8 +166,6 @@ module Kward
|
|
|
166
166
|
prompts_expand(params)
|
|
167
167
|
when "models/list"
|
|
168
168
|
models_list
|
|
169
|
-
when "openrouter/catalog"
|
|
170
|
-
openrouter_catalog
|
|
171
169
|
when "models/current"
|
|
172
170
|
models_current
|
|
173
171
|
when "models/set"
|
|
@@ -362,7 +360,8 @@ module Kward
|
|
|
362
360
|
supported: true,
|
|
363
361
|
methods: MODEL_METHODS,
|
|
364
362
|
fields: ["provider", "id", "name", "reasoning", "reasoningEffort", "contextWindow"],
|
|
365
|
-
scopedModels: false
|
|
363
|
+
scopedModels: false,
|
|
364
|
+
openRouterRefresh: { supported: false, reason: "cliOnlyCacheRefresh" }
|
|
366
365
|
},
|
|
367
366
|
runtime: {
|
|
368
367
|
supported: true,
|
|
@@ -458,10 +457,6 @@ module Kward
|
|
|
458
457
|
{ models: @session_manager.available_models }
|
|
459
458
|
end
|
|
460
459
|
|
|
461
|
-
def openrouter_catalog
|
|
462
|
-
{ models: @session_manager.openrouter_catalog }
|
|
463
|
-
end
|
|
464
|
-
|
|
465
460
|
def config_update(params)
|
|
466
461
|
config = @config_manager.update(params.fetch("values"))
|
|
467
462
|
@session_manager.refresh_client_config
|
|
@@ -485,11 +485,6 @@ module Kward
|
|
|
485
485
|
normalized
|
|
486
486
|
end
|
|
487
487
|
|
|
488
|
-
def openrouter_catalog
|
|
489
|
-
models = @client.respond_to?(:openrouter_catalog) ? Array(@client.openrouter_catalog) : []
|
|
490
|
-
models.map { |model| normalize_model(model) }
|
|
491
|
-
end
|
|
492
|
-
|
|
493
488
|
def current_model
|
|
494
489
|
provider = @client.respond_to?(:current_provider) ? @client.current_provider : nil
|
|
495
490
|
model = @client.respond_to?(:current_model) ? @client.current_model : nil
|
|
@@ -503,7 +498,7 @@ module Kward
|
|
|
503
498
|
model = rpc_session.conversation.model || current[:id]
|
|
504
499
|
reasoning_effort = rpc_session.conversation.reasoning_effort || current_reasoning_effort
|
|
505
500
|
reasoning_effort = nil unless ModelInfo.reasoning_supported?(provider, model)
|
|
506
|
-
context_window =
|
|
501
|
+
context_window = context_window_for(provider, model)
|
|
507
502
|
normalize_model(
|
|
508
503
|
provider: provider,
|
|
509
504
|
id: model,
|
|
@@ -666,6 +661,11 @@ module Kward
|
|
|
666
661
|
end
|
|
667
662
|
|
|
668
663
|
def normalize_model(model)
|
|
664
|
+
unless model.key?(:contextWindow) || model.key?("contextWindow")
|
|
665
|
+
provider = model[:provider] || model["provider"]
|
|
666
|
+
id = model[:id] || model["id"] || model[:model] || model["model"]
|
|
667
|
+
model = model.merge(contextWindow: context_window_for(provider, id))
|
|
668
|
+
end
|
|
669
669
|
ModelInfo.normalize(
|
|
670
670
|
model,
|
|
671
671
|
current_provider: (@client.current_provider if @client.respond_to?(:current_provider)),
|
|
@@ -674,6 +674,17 @@ module Kward
|
|
|
674
674
|
)
|
|
675
675
|
end
|
|
676
676
|
|
|
677
|
+
def context_window_for(provider, model)
|
|
678
|
+
provider = ModelInfo.provider_label(provider)
|
|
679
|
+
return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
|
|
680
|
+
|
|
681
|
+
if @client.respond_to?(:current_context_window) && @client.respond_to?(:current_provider) && @client.respond_to?(:current_model)
|
|
682
|
+
return @client.current_context_window if provider == @client.current_provider && model == @client.current_model
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
ModelInfo.context_window(provider, model)
|
|
686
|
+
end
|
|
687
|
+
|
|
677
688
|
def active_persona_label(rpc_session)
|
|
678
689
|
ConfigFiles.active_persona_label(
|
|
679
690
|
workspace_root: rpc_session.workspace_root,
|
data/lib/kward/session_store.rb
CHANGED
|
@@ -496,9 +496,29 @@ module Kward
|
|
|
496
496
|
|
|
497
497
|
def session_header(records, path)
|
|
498
498
|
header = records.find { |record| record["type"] == "session" }
|
|
499
|
-
|
|
499
|
+
return header if header && header["id"].to_s != ""
|
|
500
500
|
|
|
501
|
-
|
|
501
|
+
recovered = recovered_session_header(records, path)
|
|
502
|
+
return recovered if recovered
|
|
503
|
+
|
|
504
|
+
raise "Invalid Kward session file: #{path}"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def recovered_session_header(records, path)
|
|
508
|
+
return nil unless records.any? { |record| ["message", "session_info", "system_prompt", "memory_state"].include?(record["type"]) }
|
|
509
|
+
|
|
510
|
+
basename = File.basename(path)
|
|
511
|
+
match = basename.match(/\A(?<timestamp>\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z)_(?<id>[0-9a-fA-F-]{36})\.jsonl\z/)
|
|
512
|
+
return nil unless match
|
|
513
|
+
|
|
514
|
+
timestamp = match[:timestamp].tr("-", ":").sub(/\A(\d{4}):(\d{2}):(\d{2})T/, "\\1-\\2-\\3T")
|
|
515
|
+
{
|
|
516
|
+
"type" => "session",
|
|
517
|
+
"version" => VERSION,
|
|
518
|
+
"id" => match[:id],
|
|
519
|
+
"timestamp" => timestamp,
|
|
520
|
+
"cwd" => @cwd
|
|
521
|
+
}
|
|
502
522
|
end
|
|
503
523
|
|
|
504
524
|
def session_named?(session)
|
|
@@ -756,8 +776,7 @@ module Kward
|
|
|
756
776
|
|
|
757
777
|
def session_info(path)
|
|
758
778
|
records = records_from_file(path)
|
|
759
|
-
header = records
|
|
760
|
-
return nil unless header && header["id"].to_s != ""
|
|
779
|
+
header = session_header(records, path)
|
|
761
780
|
|
|
762
781
|
messages = restored_messages(records)
|
|
763
782
|
name = session_name(records)
|
|
@@ -8,13 +8,14 @@ require_relative "../rpc/redactor"
|
|
|
8
8
|
module Kward
|
|
9
9
|
# Append-only JSONL telemetry logger with secret-conscious error payloads.
|
|
10
10
|
class TelemetryLogger
|
|
11
|
-
CATEGORIES = %w[tokens performance tools errors].freeze
|
|
11
|
+
CATEGORIES = %w[tokens performance tools errors compaction].freeze
|
|
12
12
|
ENV_KEYS = {
|
|
13
13
|
"enabled" => "KWARD_LOGGING",
|
|
14
14
|
"tokens" => "KWARD_LOGGING_TOKENS",
|
|
15
15
|
"performance" => "KWARD_LOGGING_PERFORMANCE",
|
|
16
16
|
"tools" => "KWARD_LOGGING_TOOLS",
|
|
17
|
-
"errors" => "KWARD_LOGGING_ERRORS"
|
|
17
|
+
"errors" => "KWARD_LOGGING_ERRORS",
|
|
18
|
+
"compaction" => "KWARD_LOGGING_COMPACTION"
|
|
18
19
|
}.freeze
|
|
19
20
|
DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
|
20
21
|
|
|
@@ -101,7 +102,8 @@ module Kward
|
|
|
101
102
|
"tokens" => truthy?(logging["tokens"]),
|
|
102
103
|
"performance" => truthy?(logging["performance"]),
|
|
103
104
|
"tools" => truthy?(logging["tools"]),
|
|
104
|
-
"errors" => truthy?(logging["errors"])
|
|
105
|
+
"errors" => truthy?(logging["errors"]),
|
|
106
|
+
"compaction" => truthy?(logging["compaction"])
|
|
105
107
|
}
|
|
106
108
|
rescue StandardError
|
|
107
109
|
CATEGORIES.each_with_object({ "enabled" => false }) { |category, result| result[category] = false }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Namespace for the Kward CLI agent runtime.
|
|
2
|
+
module Kward
|
|
3
|
+
# Deterministically trims large tool outputs before they are appended to the
|
|
4
|
+
# model-facing transcript.
|
|
5
|
+
#
|
|
6
|
+
# The original output is still handed to session/tool-execution persistence by
|
|
7
|
+
# ToolRegistry; this object only decides what the next model call sees. Keep it
|
|
8
|
+
# conservative: small outputs and short errors are more valuable verbatim than
|
|
9
|
+
# compacted.
|
|
10
|
+
class ToolOutputCompactor
|
|
11
|
+
MIN_BYTES = 12 * 1024
|
|
12
|
+
ERROR_OUTPUT_MAX_BYTES = 8 * 1024
|
|
13
|
+
HEAD_LINES = 40
|
|
14
|
+
TAIL_LINES = 40
|
|
15
|
+
ERROR_CONTEXT_LINES = 2
|
|
16
|
+
|
|
17
|
+
ERROR_PATTERN = /\b(error|fatal|failed|failure|exception|traceback|panic|segmentation fault|assertion)\b/i.freeze
|
|
18
|
+
TEST_PATTERN = /(^\s*\d+\)\s|\b(\d+\s+(tests?|examples?|runs?|assertions?|failures?|errors?|skips?)|finished in|failures?:|seed\s+\d+)\b)/i.freeze
|
|
19
|
+
SEARCH_PATTERN = /(^\#{1,6}\s+\S+|^[-*]\s+\S+|\S+:\d+:|https?:\/\/\S+)/.freeze
|
|
20
|
+
|
|
21
|
+
def compact(tool_name, content, artifact_id: nil)
|
|
22
|
+
text = normalize(content)
|
|
23
|
+
return text unless text.bytesize > MIN_BYTES
|
|
24
|
+
return text if error_output?(text) && text.bytesize <= ERROR_OUTPUT_MAX_BYTES
|
|
25
|
+
|
|
26
|
+
compacted = tool_name.to_s == "run_shell_command" ? compact_shell_output(text) : compact_lines(text)
|
|
27
|
+
return text if compacted == text
|
|
28
|
+
return text if compacted.bytesize >= text.bytesize
|
|
29
|
+
|
|
30
|
+
artifact_id = yield if artifact_id.nil? && block_given?
|
|
31
|
+
header = compacted_header(tool_name, text, compacted, artifact_id: artifact_id)
|
|
32
|
+
candidate = "#{header}\n\n#{compacted}"
|
|
33
|
+
candidate.bytesize < text.bytesize ? candidate : text
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def normalize(content)
|
|
39
|
+
return content unless content.is_a?(String)
|
|
40
|
+
|
|
41
|
+
Conversation.normalize_tool_content(content)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def error_output?(text)
|
|
45
|
+
text.match?(ERROR_PATTERN)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def compact_shell_output(text)
|
|
49
|
+
sections = shell_sections(text)
|
|
50
|
+
return compact_lines(text) if sections.empty?
|
|
51
|
+
|
|
52
|
+
sections.map do |heading, body|
|
|
53
|
+
next heading if body.empty?
|
|
54
|
+
|
|
55
|
+
"#{heading}\n#{compact_lines(body)}"
|
|
56
|
+
end.join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def shell_sections(text)
|
|
60
|
+
parts = text.split(/\n(?=STDOUT:\n|STDERR:\n)/)
|
|
61
|
+
return [] if parts.length < 2
|
|
62
|
+
|
|
63
|
+
parts.map do |part|
|
|
64
|
+
heading, body = part.split("\n", 2)
|
|
65
|
+
[heading, body.to_s]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def compact_lines(text)
|
|
70
|
+
lines = text.split("\n", -1)
|
|
71
|
+
selected = selected_line_indexes(lines)
|
|
72
|
+
return text if selected.length >= lines.length
|
|
73
|
+
|
|
74
|
+
render_selected_lines(lines, selected)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def selected_line_indexes(lines)
|
|
78
|
+
indexes = []
|
|
79
|
+
indexes.concat((0...[HEAD_LINES, lines.length].min).to_a)
|
|
80
|
+
indexes.concat(priority_context_indexes(lines))
|
|
81
|
+
|
|
82
|
+
tail_start = [lines.length - TAIL_LINES, 0].max
|
|
83
|
+
indexes.concat((tail_start...lines.length).to_a)
|
|
84
|
+
indexes.uniq.sort
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def priority_context_indexes(lines)
|
|
88
|
+
indexes = []
|
|
89
|
+
lines.each_with_index do |line, index|
|
|
90
|
+
next unless line.match?(ERROR_PATTERN) || line.match?(TEST_PATTERN) || line.match?(SEARCH_PATTERN)
|
|
91
|
+
|
|
92
|
+
first = [index - ERROR_CONTEXT_LINES, 0].max
|
|
93
|
+
last = [index + ERROR_CONTEXT_LINES, lines.length - 1].min
|
|
94
|
+
indexes.concat((first..last).to_a)
|
|
95
|
+
end
|
|
96
|
+
indexes
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def render_selected_lines(lines, selected)
|
|
100
|
+
output = []
|
|
101
|
+
previous = nil
|
|
102
|
+
selected.each do |index|
|
|
103
|
+
if previous && index > previous + 1
|
|
104
|
+
output << "[... omitted lines #{previous + 2}-#{index} ...]"
|
|
105
|
+
end
|
|
106
|
+
output << lines[index]
|
|
107
|
+
previous = index
|
|
108
|
+
end
|
|
109
|
+
output.join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def compacted_header(tool_name, original, compacted, artifact_id: nil)
|
|
113
|
+
[
|
|
114
|
+
"[Tool output compacted by Kward: #{original.bytesize} bytes -> #{compacted.bytesize} bytes]",
|
|
115
|
+
"Tool: #{tool_name}",
|
|
116
|
+
"Preserved first #{HEAD_LINES} lines, last #{TAIL_LINES} lines, and error/failure/search context.",
|
|
117
|
+
retrieval_instruction(artifact_id)
|
|
118
|
+
].join("\n")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def retrieval_instruction(artifact_id)
|
|
122
|
+
return "Full output is retained outside model context." if artifact_id.to_s.empty?
|
|
123
|
+
|
|
124
|
+
"Full output id: #{artifact_id}. Use retrieve_tool_output to inspect it."
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/kward/tools/base.rb
CHANGED
|
@@ -24,8 +24,8 @@ module Kward
|
|
|
24
24
|
description: @description,
|
|
25
25
|
parameters: {
|
|
26
26
|
type: "object",
|
|
27
|
-
properties:
|
|
28
|
-
required: @required,
|
|
27
|
+
properties: sorted_properties,
|
|
28
|
+
required: @required.sort,
|
|
29
29
|
additionalProperties: false
|
|
30
30
|
}
|
|
31
31
|
}
|
|
@@ -34,6 +34,12 @@ module Kward
|
|
|
34
34
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
|
+
def sorted_properties
|
|
38
|
+
@properties.keys.sort_by(&:to_s).each_with_object({}) do |key, result|
|
|
39
|
+
result[key] = @properties[key]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
37
43
|
# Reads a tool argument while accepting symbol or string keys from restored calls.
|
|
38
44
|
def argument(args, key, default = nil)
|
|
39
45
|
return args[key] if args.key?(key)
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "../config_files"
|
|
2
|
+
require_relative "../conversation"
|
|
2
3
|
require_relative "ask_user_question"
|
|
3
4
|
require_relative "code_search"
|
|
4
5
|
require_relative "edit_file"
|
|
@@ -8,12 +9,16 @@ require_relative "list_directory"
|
|
|
8
9
|
require_relative "read_file"
|
|
9
10
|
require_relative "read_skill"
|
|
10
11
|
require_relative "run_shell_command"
|
|
12
|
+
require_relative "summarize_file_structure"
|
|
13
|
+
require_relative "retrieve_tool_output"
|
|
11
14
|
require_relative "web_search"
|
|
12
15
|
require_relative "write_file"
|
|
13
16
|
require_relative "search/code"
|
|
14
17
|
require_relative "search/web"
|
|
15
18
|
require_relative "search/web_fetch"
|
|
16
19
|
require_relative "tool_call"
|
|
20
|
+
require_relative "../telemetry/logger"
|
|
21
|
+
require_relative "../tool_output_compactor"
|
|
17
22
|
require_relative "../workspace"
|
|
18
23
|
|
|
19
24
|
# Namespace for the Kward CLI agent runtime.
|
|
@@ -53,7 +58,7 @@ module Kward
|
|
|
53
58
|
# @param web_search_enabled [Boolean, nil] override for web search exposure
|
|
54
59
|
# @param skills [Array<ConfigFiles::Skill>, nil] override discovered skills
|
|
55
60
|
# @param ask_user_question_enabled [Boolean, nil] override question exposure
|
|
56
|
-
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil)
|
|
61
|
+
def initialize(workspace: Workspace.new, prompt: nil, web_search: WebSearch.new, web_fetch: WebFetch.new, code_search: CodeSearch.new, web_search_enabled: nil, skills: nil, ask_user_question_enabled: nil, tool_output_compactor: ToolOutputCompactor.new, telemetry_logger: TelemetryLogger.new)
|
|
57
62
|
@workspace = workspace
|
|
58
63
|
@prompt = prompt
|
|
59
64
|
@web_search = web_search
|
|
@@ -62,6 +67,8 @@ module Kward
|
|
|
62
67
|
@skills = skills
|
|
63
68
|
@web_search_enabled = web_search_enabled
|
|
64
69
|
@ask_user_question_enabled = ask_user_question_enabled
|
|
70
|
+
@tool_output_compactor = tool_output_compactor
|
|
71
|
+
@telemetry_logger = telemetry_logger
|
|
65
72
|
@tools = build_tools.freeze
|
|
66
73
|
@schemas = build_schema_tools.map(&:schema).freeze
|
|
67
74
|
end
|
|
@@ -87,26 +94,48 @@ module Kward
|
|
|
87
94
|
else
|
|
88
95
|
"Unknown tool: #{name}"
|
|
89
96
|
end
|
|
90
|
-
|
|
97
|
+
content = Conversation.normalize_tool_content(content)
|
|
98
|
+
duplicate_id = conversation.tool_output_artifact_id_for(tool_name: name, content: content)
|
|
99
|
+
if conversation.tool_output_artifacts.key?(duplicate_id)
|
|
100
|
+
content = "[Same as previous tool output #{duplicate_id}; not repeated. Use retrieve_tool_output to inspect it.]"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
artifact_id = nil
|
|
104
|
+
model_content = @tool_output_compactor.compact(name, content) do
|
|
105
|
+
artifact_id ||= conversation.store_tool_output_artifact(tool_name: name, content: content)
|
|
106
|
+
end
|
|
107
|
+
log_tool_output_compaction(name, artifact_id: artifact_id, before: content, after: model_content) if model_content != content
|
|
91
108
|
conversation.append_tool(
|
|
92
109
|
tool_call_id: tool_call["id"] || tool_call[:id],
|
|
93
110
|
name: name,
|
|
94
|
-
content:
|
|
111
|
+
content: model_content
|
|
95
112
|
)
|
|
96
113
|
conversation.append_tool_execution(tool_call: tool_call, content: content)
|
|
97
114
|
|
|
98
|
-
|
|
115
|
+
model_content
|
|
99
116
|
end
|
|
100
117
|
|
|
101
118
|
private
|
|
102
119
|
|
|
120
|
+
def log_tool_output_compaction(name, artifact_id:, before:, after:)
|
|
121
|
+
@telemetry_logger.log(
|
|
122
|
+
"compaction",
|
|
123
|
+
"tool_output",
|
|
124
|
+
"tool_name" => name,
|
|
125
|
+
"artifact_id" => artifact_id,
|
|
126
|
+
"bytes_before" => before.bytesize,
|
|
127
|
+
"bytes_after" => after.bytesize,
|
|
128
|
+
"bytes_saved" => before.bytesize - after.bytesize
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
103
132
|
def build_tools
|
|
104
133
|
all_tools.to_h { |tool| [tool.name, tool] }
|
|
105
134
|
end
|
|
106
135
|
|
|
107
136
|
def build_schema_tools
|
|
108
137
|
tools = @tools.values_at(
|
|
109
|
-
"list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search"
|
|
138
|
+
"list_directory", "read_file", "write_file", "edit_file", "run_shell_command", "code_search", "summarize_file_structure", "retrieve_tool_output"
|
|
110
139
|
)
|
|
111
140
|
tools.concat(@tools.values_at("web_search", "fetch_content", "fetch_raw")) if web_search_available?
|
|
112
141
|
tools << @tools["read_skill"] if skills_available?
|
|
@@ -131,7 +160,9 @@ module Kward
|
|
|
131
160
|
Tools::WriteFile.new(workspace: @workspace),
|
|
132
161
|
Tools::EditFile.new(workspace: @workspace),
|
|
133
162
|
Tools::RunShellCommand.new(workspace: @workspace),
|
|
134
|
-
Tools::CodeSearch.new(code_search: @code_search)
|
|
163
|
+
Tools::CodeSearch.new(code_search: @code_search),
|
|
164
|
+
Tools::SummarizeFileStructure.new(workspace: @workspace),
|
|
165
|
+
Tools::RetrieveToolOutput.new
|
|
135
166
|
]
|
|
136
167
|
end
|
|
137
168
|
|