kward 0.69.1 → 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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +68 -0
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +30 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +43 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +39 -25
  11. data/doc/configuration.md +2 -16
  12. data/doc/context-tools.md +70 -0
  13. data/doc/getting-started.md +3 -1
  14. data/doc/plugins.md +2 -2
  15. data/doc/releasing.md +14 -5
  16. data/doc/rpc.md +3 -11
  17. data/doc/session-management.md +220 -0
  18. data/doc/usage.md +13 -7
  19. data/doc/workspace-tools.md +105 -0
  20. data/lib/kward/cli/commands.rb +8 -0
  21. data/lib/kward/cli/openrouter_commands.rb +55 -0
  22. data/lib/kward/cli/prompt_interface.rb +85 -7
  23. data/lib/kward/cli/rendering.rb +11 -6
  24. data/lib/kward/cli/sessions.rb +454 -15
  25. data/lib/kward/cli/settings.rb +0 -30
  26. data/lib/kward/cli/slash_commands.rb +38 -11
  27. data/lib/kward/cli.rb +14 -0
  28. data/lib/kward/compactor.rb +4 -1
  29. data/lib/kward/config_files.rb +4 -6
  30. data/lib/kward/conversation.rb +49 -5
  31. data/lib/kward/model/client.rb +37 -50
  32. data/lib/kward/model/context_usage.rb +13 -6
  33. data/lib/kward/model/model_info.rb +92 -9
  34. data/lib/kward/model/payloads.rb +2 -0
  35. data/lib/kward/openrouter_model_cache.rb +120 -0
  36. data/lib/kward/plugin_registry.rb +47 -1
  37. data/lib/kward/prompt_interface/banner.rb +16 -51
  38. data/lib/kward/prompt_interface/composer_controller.rb +60 -87
  39. data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
  40. data/lib/kward/prompt_interface/key_handler.rb +31 -10
  41. data/lib/kward/prompt_interface/layout.rb +2 -2
  42. data/lib/kward/prompt_interface/overlay_renderer.rb +24 -0
  43. data/lib/kward/prompt_interface/prompt_renderer.rb +23 -2
  44. data/lib/kward/prompt_interface/question_prompt.rb +34 -42
  45. data/lib/kward/prompt_interface/runtime_state.rb +6 -1
  46. data/lib/kward/prompt_interface/screen.rb +10 -4
  47. data/lib/kward/prompt_interface/selection_prompt.rb +518 -61
  48. data/lib/kward/prompt_interface/slash_overlay.rb +4 -4
  49. data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
  50. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  51. data/lib/kward/prompt_interface.rb +31 -32
  52. data/lib/kward/prompts/commands.rb +6 -3
  53. data/lib/kward/prompts.rb +2 -2
  54. data/lib/kward/rpc/server.rb +3 -8
  55. data/lib/kward/rpc/session_manager.rb +19 -8
  56. data/lib/kward/session_diff.rb +106 -9
  57. data/lib/kward/session_store.rb +23 -4
  58. data/lib/kward/session_tree_renderer.rb +2 -1
  59. data/lib/kward/telemetry/logger.rb +5 -3
  60. data/lib/kward/tool_output_compactor.rb +127 -0
  61. data/lib/kward/tools/base.rb +8 -2
  62. data/lib/kward/tools/registry.rb +37 -6
  63. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  64. data/lib/kward/tools/search/web.rb +2 -2
  65. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  66. data/lib/kward/tools/tool_call.rb +2 -0
  67. data/lib/kward/version.rb +1 -1
  68. data/lib/kward/workspace.rb +58 -2
  69. data/templates/default/fulldoc/html/css/kward.css +570 -78
  70. data/templates/default/fulldoc/html/full_list.erb +107 -0
  71. data/templates/default/fulldoc/html/js/kward.js +259 -97
  72. data/templates/default/fulldoc/html/setup.rb +8 -0
  73. data/templates/default/kward_navigation.rb +91 -0
  74. data/templates/default/layout/html/layout.erb +59 -13
  75. data/templates/default/layout/html/setup.rb +34 -39
  76. metadata +13 -3
  77. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  78. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -0,0 +1,105 @@
1
+ # Workspace tools
2
+
3
+ Workspace tools let Kward inspect and change the local project. They are the tools behind prompts such as:
4
+
5
+ ```text
6
+ Find where configuration is loaded.
7
+ Add a focused test for this behavior.
8
+ Run the related test file.
9
+ ```
10
+
11
+ Kward normally chooses these tools itself. You do not need to know their exact names to use them, but understanding the boundaries helps explain why Kward sometimes reads before editing or asks before running broad checks.
12
+
13
+ ## Guardrails
14
+
15
+ Workspace tools use the active workspace as their boundary. File paths are workspace-relative by default, and file tools are guarded so Kward does not edit arbitrary unread files.
16
+
17
+ Important behavior:
18
+
19
+ - Existing files must be read in the current conversation before `write_file` or `edit_file` can change them.
20
+ - Reads are bounded to avoid pulling very large files into context by accident.
21
+ - Edits use exact text replacement, so accidental partial or fuzzy changes fail instead of guessing.
22
+ - Shell commands run as your operating-system user from the workspace. They are powerful and should be treated like commands you run yourself.
23
+
24
+ ## Reading the workspace
25
+
26
+ ### `list_directory`
27
+
28
+ Lists entries in a workspace-relative directory. Kward uses it to discover project structure before reading specific files.
29
+
30
+ Arguments:
31
+
32
+ - `path`: workspace-relative directory.
33
+
34
+ ### `read_file`
35
+
36
+ Reads a workspace text file. Output is capped, and Kward can continue with line offsets when it needs more detail.
37
+
38
+ Arguments:
39
+
40
+ - `path`: workspace-relative file path.
41
+ - `offset`: optional 1-indexed start line.
42
+ - `limit`: optional maximum number of lines.
43
+
44
+ A successful read marks the resolved file path as read for the conversation, allowing later edits to that file.
45
+
46
+ ### `summarize_file_structure`
47
+
48
+ Returns a compact outline of classes, modules, methods, and functions in a source file. Kward uses it when a file may be too large to read fully at first.
49
+
50
+ Arguments:
51
+
52
+ - `path`: workspace-relative source file path.
53
+
54
+ This tool saves tokens by letting Kward identify relevant entry points before requesting exact line ranges with `read_file`.
55
+
56
+ ## Changing files
57
+
58
+ ### `write_file`
59
+
60
+ Writes complete file content. Existing files must be read first. New files can be created when the path is inside the workspace.
61
+
62
+ Arguments:
63
+
64
+ - `path`: workspace-relative file path.
65
+ - `content`: complete file content.
66
+
67
+ Use full writes when replacing generated content or creating a new file. For small edits to existing files, Kward should usually prefer `edit_file`.
68
+
69
+ ### `edit_file`
70
+
71
+ Applies one or more exact replacements to a file that has already been read.
72
+
73
+ Arguments:
74
+
75
+ - `path`: workspace-relative file path.
76
+ - `edits`: array of replacements:
77
+ - `old_text`: unique exact text to replace.
78
+ - `new_text`: replacement text.
79
+
80
+ Each `old_text` must match exactly once, and replacements must not overlap. This keeps edits deterministic and avoids broad fuzzy rewriting.
81
+
82
+ ## Running commands
83
+
84
+ ### `run_shell_command`
85
+
86
+ Runs a shell command from the workspace root.
87
+
88
+ Arguments:
89
+
90
+ - `command`: command to run.
91
+ - `timeout_seconds`: optional timeout, default 30 seconds.
92
+
93
+ Kward uses shell commands for tests, linters, build checks, and simple repository inspection. Command output is bounded and may be compacted before it is sent back into model context, while the original output remains available in the session record.
94
+
95
+ ## Token behavior
96
+
97
+ Workspace tools are intentionally incremental:
98
+
99
+ 1. list directories to find likely files,
100
+ 2. summarize large source files before reading everything,
101
+ 3. read focused line ranges,
102
+ 4. make exact edits,
103
+ 5. run focused verification commands.
104
+
105
+ This keeps the model's context window focused on relevant evidence instead of flooding it with entire repositories or long command output.
@@ -53,6 +53,7 @@ module Kward
53
53
  #{command.call("kward init")} Install starter prompts and PRINCIPLES.md
54
54
  #{command.call("kward doctor")} Check local Kward setup
55
55
  #{command.call("kward sysprompt")} Inspect the effective system prompt
56
+ #{command.call("kward openrouter refresh")} Refresh cached OpenRouter models
56
57
  #{command.call("kward pan")} Start Pan mode web UI
57
58
  #{command.call("kward rpc")} Start the experimental JSON-RPC backend
58
59
 
@@ -65,6 +66,7 @@ module Kward
65
66
  #{command.call("doctor")} Check local Kward setup
66
67
  #{command.call("sysprompt")} [--raw] Inspect the effective system prompt
67
68
  #{command.call("stats tokens")} [range] [options] Export local token telemetry as CSV
69
+ #{command.call("openrouter refresh|list")} Refresh or list cached OpenRouter models
68
70
  #{command.call("pan")} Start Pan mode web UI
69
71
  #{command.call("rpc")} Run the JSON-RPC backend for UI clients
70
72
 
@@ -78,6 +80,7 @@ module Kward
78
80
  #{command.call("kward")} #{option.call('"Review this diff"')}
79
81
  #{command.call("git diff | kward")} #{option.call('"Review this diff"')}
80
82
  #{command.call("kward login openrouter")}
83
+ #{command.call("kward openrouter refresh")}
81
84
  #{command.call("kward stats tokens today --bucket hour")}
82
85
 
83
86
  Command names take precedence. Anything else is sent as a one-shot prompt.
@@ -126,6 +129,11 @@ module Kward
126
129
  description: "Export local token telemetry as CSV.",
127
130
  examples: ["kward stats tokens today", "kward stats tokens today --bucket hour", "kward stats tokens week --output tokens.csv"]
128
131
  },
132
+ "openrouter" => {
133
+ usage: "kward openrouter refresh|list",
134
+ description: "Refresh or list cached text-capable OpenRouter models available to your API key.",
135
+ examples: ["kward openrouter refresh", "kward openrouter --refresh", "kward openrouter list"]
136
+ },
129
137
  "pan" => {
130
138
  usage: "kward pan",
131
139
  description: "Start Pan mode, a minimal LAN web UI with a prompt textarea and transcript.",
@@ -0,0 +1,55 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
4
+ class CLI
5
+ # OpenRouter cache management commands for the terminal CLI flow.
6
+ module OpenRouterCommands
7
+ private
8
+
9
+ def handle_openrouter_command(arguments)
10
+ case arguments
11
+ when ["refresh"], ["--refresh"]
12
+ refresh_openrouter_models
13
+ when ["list"], ["--list"]
14
+ list_openrouter_models
15
+ else
16
+ raise ArgumentError, command_usage("openrouter")
17
+ end
18
+ end
19
+
20
+ def refresh_openrouter_models
21
+ cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
22
+ data = cache.refresh
23
+ count = Array(data["models"]).length
24
+ @client.reload_config if @client.respond_to?(:reload_config)
25
+ @prompt.say("Refreshed #{count} OpenRouter text model#{count == 1 ? "" : "s"} for this key.")
26
+ @prompt.say("Cached at: #{cache.path}")
27
+ rescue StandardError => e
28
+ warn e.message
29
+ exit 1
30
+ end
31
+
32
+ def list_openrouter_models
33
+ cache = OpenRouterModelCache.new(api_key: configured_openrouter_api_key, path: openrouter_models_cache_path)
34
+ data = cache.read
35
+ unless data
36
+ @prompt.say("No OpenRouter model cache found. Run `kward openrouter refresh` first.")
37
+ return
38
+ end
39
+
40
+ models = Array(data["models"])
41
+ lines = ["OpenRouter models cached at #{data["refreshed_at"]}:"]
42
+ lines.concat(models.map { |model| model["id"].to_s }.reject(&:empty?))
43
+ @prompt.say(lines.join("\n"))
44
+ end
45
+
46
+ def configured_openrouter_api_key
47
+ ENV["OPENROUTER_API_KEY"].to_s.empty? ? ConfigFiles.config_value(ConfigFiles.read_config, "openrouter_api_key").to_s : ENV["OPENROUTER_API_KEY"].to_s
48
+ end
49
+
50
+ def openrouter_models_cache_path
51
+ File.join(File.dirname(ConfigFiles.config_path), "cache", "openrouter_models.json")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,3 +1,5 @@
1
+ require "open3"
2
+
1
3
  # Namespace for the Kward CLI agent runtime.
2
4
  module Kward
3
5
  # Command-line frontend that coordinates terminal interaction, sessions, tools, and model turns.
@@ -13,7 +15,6 @@ module Kward
13
15
  prompt_interface = load_prompt_interface
14
16
  return unless prompt_interface
15
17
 
16
- banner_enabled = ConfigFiles.banner_enabled?
17
18
  @prompt = prompt_interface.new(
18
19
  slash_commands: slash_command_entries,
19
20
  overlay_settings: ConfigFiles.overlay_settings,
@@ -22,10 +23,13 @@ module Kward
22
23
  busy_help: ConfigFiles.composer_busy_help?,
23
24
  attachment_badges: method(:composer_attachment_badges),
24
25
  attachment_parser: method(:composer_attachment_parser),
25
- banner_pixels: banner_enabled ? Kward::PromptInterface::BANNER_LOGO_PIXELS : nil,
26
- banner_message: banner_enabled ? Kward::PromptInterface::BANNER_MESSAGE : nil
26
+ banner_message: Kward::PromptInterface::BANNER_MESSAGE
27
27
  )
28
- @prompt.start
28
+ if @prompt.method(:start).parameters.any? { |kind, name| [:key, :keyreq].include?(kind) && name == :render }
29
+ @prompt.start(render: false)
30
+ else
31
+ @prompt.start
32
+ end
29
33
  end
30
34
 
31
35
  def load_prompt_interface
@@ -46,9 +50,80 @@ module Kward
46
50
  @prompt.respond_to?(:start_stream_block) && @prompt.respond_to?(:write_delta)
47
51
  end
48
52
 
49
- # Writes the visual banner output for the terminal CLI flow.
53
+ # Writes the startup info screen output for the terminal CLI flow.
50
54
  def print_visual_banner
51
- @prompt.print_visual_banner if @prompt.respond_to?(:print_visual_banner)
55
+ return unless @prompt.respond_to?(:print_visual_banner)
56
+
57
+ @prompt.print_visual_banner(startup_info_screen)
58
+ end
59
+
60
+ def startup_info_screen
61
+ [
62
+ startup_status_line,
63
+ "",
64
+ startup_info_line("Workspace", startup_workspace_label),
65
+ startup_info_line("Branch", startup_branch_value),
66
+ startup_info_line("Plugins", startup_plugins_value),
67
+ "",
68
+ startup_brand_line
69
+ ].join("\n")
70
+ end
71
+
72
+ def startup_workspace_label
73
+ root = File.expand_path(current_workspace_root)
74
+ home = begin
75
+ Dir.home
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ if home && (root == home || root.start_with?("#{home}/"))
80
+ relative = root.delete_prefix(home).sub(%r{\A/}, "")
81
+ return "~" if relative.empty?
82
+ return "~/#{relative}" unless relative.include?("/")
83
+ end
84
+
85
+ parent = File.basename(File.dirname(root))
86
+ name = File.basename(root)
87
+ parent.empty? || parent == "." ? name : "#{parent}/#{name}"
88
+ end
89
+
90
+ def startup_branch_value
91
+ git_root = startup_git_root(current_workspace_root)
92
+ return "not a repository" if git_root.to_s.empty?
93
+
94
+ branch = startup_git_output(%w[git branch --show-current], root: git_root)
95
+ branch = startup_git_output(%w[git rev-parse --short HEAD], root: git_root) if branch.empty?
96
+ branch.empty? ? "unknown" : branch
97
+ end
98
+
99
+ def startup_plugins_value
100
+ filenames = plugin_registry.paths.map { |path| File.basename(path) }
101
+ filenames.empty? ? "none" : filenames.join(", ")
102
+ end
103
+
104
+ def startup_status_line
105
+ "#{ANSI.colorize("●", :green, enabled: @color_enabled)} Kward v#{Kward::VERSION} is online."
106
+ end
107
+
108
+ def startup_info_line(label, value)
109
+ "#{ANSI.colorize(label.ljust(12), :gray, enabled: @color_enabled)}#{ANSI.colorize(value, :cyan, enabled: @color_enabled)}"
110
+ end
111
+
112
+ def startup_brand_line
113
+ ANSI.colorize(Kward::PromptInterface::BANNER_MESSAGE, :bold, enabled: @color_enabled)
114
+ end
115
+
116
+ def startup_git_root(root)
117
+ startup_git_output(%w[git rev-parse --show-toplevel], root: root)
118
+ end
119
+
120
+ def startup_git_output(command, root:)
121
+ output, status = Open3.capture2e(*command, chdir: root.to_s)
122
+ return "" unless status.success?
123
+
124
+ output.lines.first.to_s.strip
125
+ rescue StandardError
126
+ ""
52
127
  end
53
128
 
54
129
  def prompt_footer_renderer
@@ -103,7 +178,10 @@ module Kward
103
178
  def composer_context_window(provider = nil, model = nil)
104
179
  provider ||= current_footer_conversation.provider || (@client.respond_to?(:current_provider) ? @client.current_provider : "Codex")
105
180
  model ||= current_footer_conversation.model || (@client.respond_to?(:current_model) ? @client.current_model : ModelInfo::DEFAULT_OPENAI_MODEL)
106
- ModelInfo.context_window(ModelInfo.provider_label(provider), model)
181
+ provider = ModelInfo.provider_label(provider)
182
+ return @client.context_window(provider, model) if @client.respond_to?(:context_window) && @client.method(:context_window).arity != 0
183
+
184
+ ModelInfo.context_window(provider, model)
107
185
  end
108
186
 
109
187
  def composer_context_usage(provider, model)
@@ -59,7 +59,7 @@ module Kward
59
59
  if prompt_interface?
60
60
  print_tool_result(tool_call, content, line_limit: INTERACTIVE_TOOL_OUTPUT_LINE_LIMIT)
61
61
  else
62
- @prompt.say("\n#{colored("Tool>", *tool_label_styles(content))}\n#{summary}\n")
62
+ @prompt.say("\n#{colored("Tool>", *tool_label_styles(content))} #{tool_summary_display_text(summary)}\n")
63
63
  end
64
64
  end
65
65
 
@@ -71,7 +71,7 @@ module Kward
71
71
  print_block_delta(label, rendered)
72
72
  finish_stream_block
73
73
  else
74
- @prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))}\n#{rendered}\n")
74
+ @prompt.say("\n#{colored("#{transcript_label(label)}>", *label_styles(label))} #{rendered}\n")
75
75
  end
76
76
  end
77
77
 
@@ -298,8 +298,9 @@ module Kward
298
298
  def print_tool_result(tool_call, content, line_limit: nil)
299
299
  summary = tool_result_summary(tool_call, content)
300
300
  summary = limit_tool_output_lines(summary, line_limit) if line_limit
301
+ display_summary = tool_summary_display_text(summary)
301
302
  if prompt_interface?
302
- summary = summary.end_with?("\n") ? summary : "#{summary}\n"
303
+ summary = display_summary.end_with?("\n") ? display_summary : "#{display_summary}\n"
303
304
  if @prompt.respond_to?(:write_stream_block)
304
305
  @prompt.write_stream_block("Tool", summary, finish: true)
305
306
  else
@@ -309,18 +310,22 @@ module Kward
309
310
  end
310
311
  else
311
312
  start_stream_block(tool_stream_label(content))
312
- print summary
313
- puts unless summary.end_with?("\n")
313
+ print display_summary
314
+ puts unless display_summary.end_with?("\n")
314
315
  $stdout.flush
315
316
  @stream_block = nil
316
317
  end
317
318
  end
318
319
 
320
+ def tool_summary_display_text(summary)
321
+ summary.to_s.sub("\n", "\n\n")
322
+ end
323
+
319
324
  def start_stream_block(label)
320
325
  return if @stream_block == label
321
326
 
322
327
  puts if @stream_block
323
- puts "\n#{colored("#{transcript_label(label)}>", *label_styles(label))}"
328
+ print "\n#{colored("#{transcript_label(label)}>", *label_styles(label))} "
324
329
  @stream_block = label
325
330
  end
326
331