kward 0.70.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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/pages.yml +1 -1
  3. data/CHANGELOG.md +89 -3
  4. data/Gemfile +2 -0
  5. data/Gemfile.lock +90 -2
  6. data/README.md +34 -6
  7. data/Rakefile +96 -0
  8. data/doc/agent-tools.md +52 -0
  9. data/doc/api.md +92 -0
  10. data/doc/authentication.md +58 -23
  11. data/doc/code-search.md +42 -2
  12. data/doc/configuration.md +102 -13
  13. data/doc/context-budgeting.md +136 -0
  14. data/doc/context-tools.md +83 -0
  15. data/doc/editor.md +394 -0
  16. data/doc/extensibility.md +16 -7
  17. data/doc/files.md +100 -0
  18. data/doc/getting-started.md +25 -18
  19. data/doc/git.md +122 -0
  20. data/doc/memory.md +24 -4
  21. data/doc/personas.md +34 -5
  22. data/doc/plugins.md +74 -3
  23. data/doc/releasing.md +45 -8
  24. data/doc/rpc.md +77 -15
  25. data/doc/session-management.md +254 -0
  26. data/doc/shell.md +286 -0
  27. data/doc/tabs.md +122 -0
  28. data/doc/troubleshooting.md +77 -1
  29. data/doc/usage.md +60 -15
  30. data/doc/web-search.md +12 -4
  31. data/doc/workspace-tools.md +144 -0
  32. data/examples/plugins/space_invaders.rb +377 -0
  33. data/lib/kward/agent.rb +1 -1
  34. data/lib/kward/cli/commands.rb +41 -2
  35. data/lib/kward/cli/git.rb +150 -0
  36. data/lib/kward/cli/interactive_turn.rb +73 -9
  37. data/lib/kward/cli/openrouter_commands.rb +55 -0
  38. data/lib/kward/cli/plugins.rb +54 -4
  39. data/lib/kward/cli/prompt_interface.rb +111 -6
  40. data/lib/kward/cli/rendering.rb +11 -6
  41. data/lib/kward/cli/runtime_helpers.rb +133 -3
  42. data/lib/kward/cli/sessions.rb +262 -13
  43. data/lib/kward/cli/settings.rb +216 -37
  44. data/lib/kward/cli/slash_commands.rb +439 -8
  45. data/lib/kward/cli/tabs.rb +695 -0
  46. data/lib/kward/cli.rb +171 -26
  47. data/lib/kward/compactor.rb +4 -1
  48. data/lib/kward/config_files.rb +125 -5
  49. data/lib/kward/context_budget_meter.rb +44 -0
  50. data/lib/kward/conversation.rb +59 -22
  51. data/lib/kward/editor_mode.rb +25 -0
  52. data/lib/kward/ekwsh.rb +362 -0
  53. data/lib/kward/model/client.rb +37 -50
  54. data/lib/kward/model/context_usage.rb +13 -6
  55. data/lib/kward/model/model_info.rb +92 -16
  56. data/lib/kward/model/payloads.rb +2 -0
  57. data/lib/kward/openrouter_model_cache.rb +120 -0
  58. data/lib/kward/plugin_registry.rb +108 -1
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +82 -0
  61. data/lib/kward/prompt_interface/banner.rb +16 -51
  62. data/lib/kward/prompt_interface/composer_controller.rb +124 -83
  63. data/lib/kward/prompt_interface/composer_renderer.rb +116 -14
  64. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  65. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  66. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  67. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  68. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  69. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  70. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  71. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  72. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  73. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  74. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  77. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  78. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  79. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  82. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  83. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  84. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  85. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  86. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  87. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  88. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  89. data/lib/kward/prompt_interface/key_handler.rb +416 -43
  90. data/lib/kward/prompt_interface/layout.rb +2 -2
  91. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  92. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  93. data/lib/kward/prompt_interface/prompt_renderer.rb +32 -13
  94. data/lib/kward/prompt_interface/question_prompt.rb +122 -82
  95. data/lib/kward/prompt_interface/runtime_state.rb +49 -1
  96. data/lib/kward/prompt_interface/screen.rb +17 -0
  97. data/lib/kward/prompt_interface/selection_prompt.rb +511 -58
  98. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  99. data/lib/kward/prompt_interface/transcript_buffer.rb +13 -16
  100. data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
  101. data/lib/kward/prompt_interface.rb +307 -35
  102. data/lib/kward/prompts/commands.rb +7 -1
  103. data/lib/kward/prompts.rb +4 -2
  104. data/lib/kward/rpc/server.rb +45 -11
  105. data/lib/kward/rpc/session_manager.rb +52 -53
  106. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  107. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  108. data/lib/kward/session_store.rb +67 -4
  109. data/lib/kward/session_tree_nodes.rb +136 -0
  110. data/lib/kward/session_tree_renderer.rb +9 -131
  111. data/lib/kward/tab_store.rb +47 -0
  112. data/lib/kward/telemetry/logger.rb +5 -3
  113. data/lib/kward/text_boundary.rb +25 -0
  114. data/lib/kward/tool_output_compactor.rb +127 -0
  115. data/lib/kward/tools/base.rb +8 -2
  116. data/lib/kward/tools/context_budget_stats.rb +54 -0
  117. data/lib/kward/tools/context_for_task.rb +202 -0
  118. data/lib/kward/tools/read_file.rb +8 -4
  119. data/lib/kward/tools/registry.rb +92 -15
  120. data/lib/kward/tools/retrieve_tool_output.rb +71 -0
  121. data/lib/kward/tools/search/web.rb +2 -2
  122. data/lib/kward/tools/summarize_file_structure.rb +29 -0
  123. data/lib/kward/tools/tool_call.rb +12 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +68 -0
  126. data/lib/kward/workers/live_view.rb +49 -0
  127. data/lib/kward/workers/manager.rb +288 -0
  128. data/lib/kward/workers/store.rb +72 -0
  129. data/lib/kward/workers/tool_policy.rb +23 -0
  130. data/lib/kward/workers/worker.rb +82 -0
  131. data/lib/kward/workers/write_lock.rb +38 -0
  132. data/lib/kward/workers.rb +7 -0
  133. data/lib/kward/workspace.rb +154 -12
  134. data/templates/default/fulldoc/html/css/kward.css +362 -42
  135. data/templates/default/fulldoc/html/full_list.erb +107 -0
  136. data/templates/default/fulldoc/html/js/kward.js +161 -2
  137. data/templates/default/fulldoc/html/setup.rb +8 -0
  138. data/templates/default/kward_navigation.rb +102 -0
  139. data/templates/default/layout/html/layout.erb +43 -10
  140. data/templates/default/layout/html/setup.rb +39 -38
  141. metadata +65 -3
  142. data/lib/kward/resources/avatar_kward_logo.rb +0 -50
  143. data/lib/kward/resources/pixel_logo.rb +0 -232
@@ -20,7 +20,7 @@ module Kward
20
20
  MAX_FILE_BYTES = 256 * 1024
21
21
  MAX_READ_OUTPUT_BYTES = 50 * 1024
22
22
  MAX_READ_OUTPUT_LINES = 2_000
23
- MAX_COMMAND_OUTPUT_BYTES = 20 * 1024
23
+ MAX_COMMAND_OUTPUT_BYTES = 128 * 1024
24
24
  MAX_EDIT_DIFF_BYTES = 8 * 1024
25
25
  DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
26
26
 
@@ -54,7 +54,7 @@ module Kward
54
54
  # The returned string is user/model-facing and includes continuation notices
55
55
  # when output is truncated. Errors are returned as `"Error: ..."` strings so
56
56
  # tool calls can be persisted in the conversation without raising.
57
- def read_file(path, offset: nil, limit: nil)
57
+ def read_file(path, offset: nil, limit: nil, mode: nil, max_bytes: nil)
58
58
  resolved = workspace_path(path)
59
59
  return "Error: not a file: #{path}" unless File.file?(resolved)
60
60
 
@@ -64,7 +64,40 @@ module Kward
64
64
  content = File.read(resolved)
65
65
  return "Error: not a text file: #{path}" if binary_content?(content)
66
66
 
67
- read_file_slice(content, offset: offset, limit: limit)
67
+ read_mode = normalize_read_mode(mode)
68
+ return read_mode if read_mode.is_a?(String)
69
+
70
+ output_budget = read_output_budget(max_bytes)
71
+ return output_budget if output_budget.is_a?(String)
72
+
73
+ case read_mode
74
+ when :outline
75
+ file_structure_summary(path, content)
76
+ when :preview
77
+ read_file_slice(content, offset: offset, limit: limit || 120, max_bytes: output_budget)
78
+ when :range
79
+ read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget)
80
+ when :full
81
+ read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget)
82
+ else
83
+ large_file_outline_response(path, content, offset: offset, limit: limit) || read_file_slice(content, offset: offset, limit: limit, max_bytes: output_budget)
84
+ end
85
+ rescue SecurityError, Errno::ENOENT => e
86
+ "Error: #{e.message}"
87
+ end
88
+
89
+ # Returns a compact outline of recognizable source-code declarations.
90
+ def summarize_file_structure(path)
91
+ resolved = workspace_path(path)
92
+ return "Error: not a file: #{path}" unless File.file?(resolved)
93
+
94
+ size = File.size(resolved)
95
+ return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
96
+
97
+ content = File.read(resolved)
98
+ return "Error: not a text file: #{path}" if binary_content?(content)
99
+
100
+ file_structure_summary(path, content)
68
101
  rescue SecurityError, Errno::ENOENT => e
69
102
  "Error: #{e.message}"
70
103
  end
@@ -196,7 +229,115 @@ module Kward
196
229
  Pathname.new(path).relative_path_from(@root).to_s
197
230
  end
198
231
 
199
- def read_file_slice(content, offset:, limit:)
232
+ def large_file_outline_response(path, content, offset:, limit:)
233
+ return nil unless offset.nil? && limit.nil?
234
+ lines = content.split("\n", -1)
235
+ return nil unless lines.length > @max_read_output_lines || content.bytesize > @max_read_output_bytes
236
+
237
+ outline = source_outline(lines)
238
+ return nil if outline.empty?
239
+
240
+ preview_limit = [120, @max_read_output_lines].min
241
+ preview = lines.first(preview_limit).join("\n")
242
+ [
243
+ "File has #{lines.length} lines (#{content.bytesize} bytes). Showing an outline and the first #{preview_limit} lines to reduce model context.",
244
+ "",
245
+ "Outline:",
246
+ outline.join("\n"),
247
+ "",
248
+ "First #{preview_limit} lines:",
249
+ preview,
250
+ "",
251
+ "[Use read_file with mode=\"range\", offset=#{preview_limit + 1}, and limit to continue; mode=\"outline\" for only the outline; or request a specific section from the outline.]"
252
+ ].join("\n")
253
+ end
254
+
255
+ def file_structure_summary(path, content)
256
+ lines = content.split("\n", -1)
257
+ outline = source_outline(lines)
258
+ return "No recognizable source structure found in #{path}." if outline.empty?
259
+
260
+ (["# File structure: #{path}", "- Lines: #{lines.length}", "- Bytes: #{content.bytesize}", "", "## Outline"] + outline).join("\n")
261
+ end
262
+
263
+ def source_outline(lines)
264
+ entries = source_outline_entries(lines)
265
+ entries.first(80).map do |entry|
266
+ range = entry[:end_line] && entry[:end_line] != entry[:line] ? " (range #{entry[:line]}-#{entry[:end_line]}, #{entry[:kind]})" : " (#{entry[:kind]})"
267
+ "line #{entry[:line]}: #{' ' * [entry[:indent] / 2, 6].min}#{entry[:signature]}#{range}"
268
+ end
269
+ end
270
+
271
+ def source_outline_entries(lines)
272
+ candidates = []
273
+ lines.each_with_index do |line, index|
274
+ declaration = source_declaration(line.strip)
275
+ next unless declaration
276
+
277
+ candidates << declaration.merge(line: index + 1, indent: line[/\A\s*/].to_s.length)
278
+ end
279
+ candidates.each_with_index do |entry, index|
280
+ following = candidates[(index + 1)..]&.find { |candidate| candidate[:indent] <= entry[:indent] }
281
+ entry[:end_line] = following ? following[:line] - 1 : last_content_line(lines)
282
+ end
283
+ candidates
284
+ end
285
+
286
+ def source_declaration(stripped)
287
+ case stripped
288
+ when /\A(module)\s+(.+)/
289
+ { kind: "module", signature: stripped }
290
+ when /\A(class)\s+(.+)/
291
+ { kind: "class", signature: stripped }
292
+ when /\A(async\s+def|def)\s+(.+)/
293
+ { kind: "function", signature: stripped }
294
+ when /\A(export\s+)?(async\s+)?function\s+(.+)/
295
+ { kind: "function", signature: stripped }
296
+ when /\A(async\s+)?(?:get\s+|set\s+)?(?:constructor|[A-Za-z_$][\w$]*)\s*\([^;]*\)\s*(?::\s*[^{}]+)?\s*(?:\{\}|\{|=>)?\z/
297
+ { kind: "method", signature: stripped } unless stripped.match?(/\A(if|for|while|switch|catch)\b/)
298
+ when /\A(export\s+)?(class|interface|type|enum)\s+(.+)/
299
+ { kind: Regexp.last_match(2), signature: stripped }
300
+ when /\A(?:export\s+)?(?:const|let|var)\s+\w+\s*=.*=>/
301
+ { kind: "function", signature: stripped }
302
+ when /\Afunc\s+(.+)/
303
+ { kind: "function", signature: stripped }
304
+ when /\Atype\s+\w+\s+(struct|interface)\b/
305
+ { kind: Regexp.last_match(1), signature: stripped }
306
+ when /\A(pub\s+)?(async\s+)?fn\s+(.+)/
307
+ { kind: "function", signature: stripped }
308
+ when /\A(pub\s+)?(struct|enum|trait|impl)\b(.+)?/
309
+ { kind: Regexp.last_match(2), signature: stripped }
310
+ when /\A(?:public|private|protected|internal|static|final|abstract|async|override|virtual|sealed|readonly|partial|\s)+\s*(class|interface|enum|record)\s+(.+)/
311
+ { kind: Regexp.last_match(1), signature: stripped }
312
+ when /\A(?:public|private|protected|internal|static|final|abstract|async|override|virtual|sealed|readonly|partial|\s)+\s*\S[^{;=]*\w+\s*\([^;]*\)\s*(?:\{|=>)?\z/
313
+ { kind: "method", signature: stripped }
314
+ end
315
+ end
316
+
317
+ def last_content_line(lines)
318
+ index = lines.rindex { |line| !line.strip.empty? }
319
+ index ? index + 1 : lines.length
320
+ end
321
+
322
+ def normalize_read_mode(mode)
323
+ return nil if mode.nil? || mode.to_s.empty?
324
+
325
+ value = mode.to_s.downcase
326
+ return value.to_sym if %w[preview outline range full].include?(value)
327
+
328
+ "Error: mode must be one of preview, outline, range, full"
329
+ end
330
+
331
+ def read_output_budget(max_bytes)
332
+ return @max_read_output_bytes if max_bytes.nil?
333
+
334
+ value = max_bytes.to_i
335
+ return "Error: max_bytes must be positive" unless value.positive?
336
+
337
+ [value, @max_read_output_bytes].min
338
+ end
339
+
340
+ def read_file_slice(content, offset:, limit:, max_bytes: @max_read_output_bytes)
200
341
  lines = content.split("\n", -1)
201
342
  lines = [""] if lines.empty?
202
343
  start_index = read_start_index(offset)
@@ -207,7 +348,7 @@ module Kward
207
348
 
208
349
  selected_end = user_limit ? [start_index + user_limit, lines.length].min : lines.length
209
350
  selected_lines = lines[start_index...selected_end]
210
- truncated = truncate_read_lines(selected_lines)
351
+ truncated = truncate_read_lines(selected_lines, max_bytes: max_bytes)
211
352
  return truncated[:error] if truncated[:error]
212
353
 
213
354
  output = truncated[:content]
@@ -216,7 +357,8 @@ module Kward
216
357
  start_index: start_index,
217
358
  output_lines: truncated[:line_count],
218
359
  total_lines: lines.length,
219
- truncated_by: truncated[:truncated_by]
360
+ truncated_by: truncated[:truncated_by],
361
+ max_bytes: max_bytes
220
362
  )
221
363
  elsif user_limit && selected_end < lines.length
222
364
  output << "\n\n[#{lines.length - selected_end} more lines in file. Use offset=#{selected_end + 1} to continue.]"
@@ -244,11 +386,11 @@ module Kward
244
386
  value
245
387
  end
246
388
 
247
- def truncate_read_lines(lines)
389
+ def truncate_read_lines(lines, max_bytes: @max_read_output_bytes)
248
390
  first_line = lines.first.to_s
249
- if first_line.bytesize > @max_read_output_bytes
391
+ if first_line.bytesize > max_bytes
250
392
  return {
251
- error: "Error: first line is #{first_line.bytesize} bytes, exceeds #{@max_read_output_bytes} byte read limit. Use run_shell_command with sed/head to inspect smaller chunks."
393
+ error: "Error: first line is #{first_line.bytesize} bytes, exceeds #{max_bytes} byte read limit. Use run_shell_command with sed/head to inspect smaller chunks."
252
394
  }
253
395
  end
254
396
 
@@ -263,7 +405,7 @@ module Kward
263
405
 
264
406
  separator_bytes = output_lines.empty? ? 0 : 1
265
407
  next_bytes = line.bytesize + separator_bytes
266
- if bytes + next_bytes > @max_read_output_bytes
408
+ if bytes + next_bytes > max_bytes
267
409
  truncated_by = "bytes"
268
410
  break
269
411
  end
@@ -280,10 +422,10 @@ module Kward
280
422
  }
281
423
  end
282
424
 
283
- def read_truncation_notice(start_index:, output_lines:, total_lines:, truncated_by:)
425
+ def read_truncation_notice(start_index:, output_lines:, total_lines:, truncated_by:, max_bytes: @max_read_output_bytes)
284
426
  end_line = start_index + output_lines
285
427
  next_offset = end_line + 1
286
- detail = truncated_by == "lines" ? "#{@max_read_output_lines} line limit" : "#{@max_read_output_bytes} byte limit"
428
+ detail = truncated_by == "lines" ? "#{@max_read_output_lines} line limit" : "#{max_bytes} byte limit"
287
429
  "\n\n[Showing lines #{start_index + 1}-#{end_line} of #{total_lines} (#{detail}). Use offset=#{next_offset} to continue.]"
288
430
  end
289
431