kward 0.71.0 → 0.73.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/ci.yml +30 -0
  3. data/CHANGELOG.md +93 -0
  4. data/Gemfile.lock +2 -2
  5. data/README.md +4 -0
  6. data/doc/agent-tools.md +15 -6
  7. data/doc/authentication.md +22 -1
  8. data/doc/code-search.md +42 -2
  9. data/doc/configuration.md +106 -3
  10. data/doc/context-budgeting.md +136 -0
  11. data/doc/context-tools.md +16 -3
  12. data/doc/editor.md +415 -0
  13. data/doc/extensibility.md +16 -7
  14. data/doc/files.md +100 -0
  15. data/doc/getting-started.md +25 -18
  16. data/doc/git.md +123 -0
  17. data/doc/memory.md +24 -4
  18. data/doc/personas.md +34 -5
  19. data/doc/plugins.md +72 -1
  20. data/doc/releasing.md +37 -9
  21. data/doc/rpc.md +75 -5
  22. data/doc/session-management.md +35 -1
  23. data/doc/shell.md +332 -0
  24. data/doc/tabs.md +122 -0
  25. data/doc/troubleshooting.md +77 -1
  26. data/doc/usage.md +79 -7
  27. data/doc/web-search.md +12 -4
  28. data/doc/workspace-tools.md +51 -12
  29. data/examples/plugins/space_invaders.rb +377 -0
  30. data/lib/kward/agent.rb +1 -1
  31. data/lib/kward/ansi.rb +62 -23
  32. data/lib/kward/cli/commands.rb +33 -2
  33. data/lib/kward/cli/git.rb +150 -0
  34. data/lib/kward/cli/interactive_turn.rb +73 -9
  35. data/lib/kward/cli/plugins.rb +54 -4
  36. data/lib/kward/cli/prompt_interface.rb +32 -1
  37. data/lib/kward/cli/rendering.rb +4 -1
  38. data/lib/kward/cli/runtime_helpers.rb +268 -4
  39. data/lib/kward/cli/sessions.rb +2 -2
  40. data/lib/kward/cli/settings.rb +217 -9
  41. data/lib/kward/cli/slash_commands.rb +628 -2
  42. data/lib/kward/cli/tabs.rb +725 -0
  43. data/lib/kward/cli/tool_summaries.rb +6 -0
  44. data/lib/kward/cli.rb +150 -26
  45. data/lib/kward/clipboard.rb +2 -3
  46. data/lib/kward/compactor.rb +7 -19
  47. data/lib/kward/config_files.rb +145 -1
  48. data/lib/kward/context_budget_meter.rb +44 -0
  49. data/lib/kward/conversation.rb +12 -4
  50. data/lib/kward/editor_mode.rb +25 -0
  51. data/lib/kward/ekwsh.rb +559 -0
  52. data/lib/kward/image_attachments.rb +3 -1
  53. data/lib/kward/interactive_pty_runner.rb +151 -0
  54. data/lib/kward/local_command_runner.rb +155 -0
  55. data/lib/kward/local_pty_command_runner.rb +171 -0
  56. data/lib/kward/model/context_usage.rb +2 -2
  57. data/lib/kward/model/payloads.rb +2 -5
  58. data/lib/kward/plugin_registry.rb +61 -0
  59. data/lib/kward/project_files.rb +52 -0
  60. data/lib/kward/prompt_history.rb +84 -0
  61. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  62. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  63. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  64. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  65. data/lib/kward/prompt_interface/editor/auto_indent.rb +510 -0
  66. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  67. data/lib/kward/prompt_interface/editor/controller.rb +1218 -0
  68. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  69. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  70. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  71. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  72. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  73. data/lib/kward/prompt_interface/editor/modes/modern.rb +354 -0
  74. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1812 -0
  75. data/lib/kward/prompt_interface/editor/modes/vibe_insert_readline.rb +166 -0
  76. data/lib/kward/prompt_interface/editor/renderer.rb +244 -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 +1271 -0
  80. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  81. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +422 -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 +288 -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 +451 -57
  90. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  91. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  92. data/lib/kward/prompt_interface/question_prompt.rb +99 -56
  93. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  94. data/lib/kward/prompt_interface/screen.rb +19 -3
  95. data/lib/kward/prompt_interface/selection_prompt.rb +10 -19
  96. data/lib/kward/prompt_interface/slash_overlay.rb +2 -0
  97. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  98. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  99. data/lib/kward/prompt_interface.rb +366 -222
  100. data/lib/kward/prompts/commands.rb +9 -0
  101. data/lib/kward/prompts.rb +2 -0
  102. data/lib/kward/rpc/memory_methods.rb +83 -0
  103. data/lib/kward/rpc/server.rb +169 -83
  104. data/lib/kward/rpc/session_manager.rb +45 -121
  105. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  106. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  107. data/lib/kward/rpc/tool_metadata.rb +11 -0
  108. data/lib/kward/rpc/transcript_normalizer.rb +4 -39
  109. data/lib/kward/scratchpad_runner.rb +56 -0
  110. data/lib/kward/session_diff.rb +20 -3
  111. data/lib/kward/session_naming.rb +11 -0
  112. data/lib/kward/session_store.rb +44 -0
  113. data/lib/kward/session_tree_nodes.rb +136 -0
  114. data/lib/kward/session_tree_renderer.rb +9 -131
  115. data/lib/kward/tab_store.rb +47 -0
  116. data/lib/kward/terminal_keys.rb +84 -0
  117. data/lib/kward/terminal_sequences.rb +42 -0
  118. data/lib/kward/text_boundary.rb +25 -0
  119. data/lib/kward/tools/context_budget_stats.rb +54 -0
  120. data/lib/kward/tools/context_for_task.rb +204 -0
  121. data/lib/kward/tools/read_file.rb +8 -4
  122. data/lib/kward/tools/registry.rb +62 -16
  123. data/lib/kward/tools/tool_call.rb +10 -0
  124. data/lib/kward/version.rb +1 -1
  125. data/lib/kward/workers/git_guard.rb +93 -0
  126. data/lib/kward/workers/job.rb +99 -0
  127. data/lib/kward/workers/live_view.rb +49 -0
  128. data/lib/kward/workers/manager.rb +288 -0
  129. data/lib/kward/workers/queue_runner.rb +166 -0
  130. data/lib/kward/workers/queue_store.rb +112 -0
  131. data/lib/kward/workers/store.rb +72 -0
  132. data/lib/kward/workers/tool_policy.rb +23 -0
  133. data/lib/kward/workers/worker.rb +82 -0
  134. data/lib/kward/workers/write_lock.rb +38 -0
  135. data/lib/kward/workers.rb +10 -0
  136. data/lib/kward/workspace.rb +125 -87
  137. data/templates/default/fulldoc/html/css/kward.css +140 -36
  138. data/templates/default/fulldoc/html/images/kward_screen_1.png +0 -0
  139. data/templates/default/fulldoc/html/setup.rb +1 -0
  140. data/templates/default/kward_navigation.rb +12 -1
  141. data/templates/default/layout/html/layout.erb +23 -34
  142. data/templates/default/layout/html/setup.rb +6 -0
  143. metadata +67 -1
@@ -0,0 +1,82 @@
1
+ require "securerandom"
2
+ require "time"
3
+ require_relative "../config_files"
4
+ require_relative "../cancellation"
5
+
6
+ module Kward
7
+ module Workers
8
+ # Runtime record for one independent unit of agent work.
9
+ class Worker
10
+ STATUSES = %w[idle queued running ready failed cancelled archived].freeze
11
+
12
+ def initialize(id: SecureRandom.hex(4), title:, role:, workspace_root: Dir.pwd, status: "idle", prompt: nil, conversation: nil, session: nil, cancellation: Cancellation.new, created_at: Time.now.utc)
13
+ @id = id
14
+ @title = title.to_s
15
+ @role = role.to_s
16
+ @workspace_root = ConfigFiles.canonical_workspace_root(workspace_root)
17
+ @status = status.to_s
18
+ @prompt = prompt.to_s
19
+ @conversation = conversation
20
+ @session = session
21
+ @cancellation = cancellation
22
+ @created_at = created_at
23
+ @updated_at = created_at
24
+ @started_at = nil
25
+ @finished_at = nil
26
+ @report = nil
27
+ @error = nil
28
+ @thread = nil
29
+ @event_history = []
30
+ @event_queue = Queue.new
31
+ end
32
+
33
+ attr_reader :id, :title, :role, :workspace_root, :prompt, :conversation, :session, :cancellation, :created_at, :updated_at, :started_at, :finished_at, :report, :error, :thread, :event_history, :event_queue
34
+ attr_writer :conversation, :session, :thread
35
+
36
+ def status
37
+ @status
38
+ end
39
+
40
+ def update_status(status, error: nil, report: nil)
41
+ @status = status.to_s
42
+ @error = error unless error.nil?
43
+ @report = report unless report.nil?
44
+ now = Time.now.utc
45
+ @updated_at = now
46
+ @started_at ||= now if @status == "running"
47
+ @finished_at = now if %w[ready failed cancelled archived].include?(@status)
48
+ self
49
+ end
50
+
51
+ def record_event(event)
52
+ @event_history << event
53
+ @event_queue << event
54
+ end
55
+
56
+ def to_h
57
+ {
58
+ "id" => id,
59
+ "title" => title,
60
+ "role" => role,
61
+ "status" => status,
62
+ "prompt" => prompt,
63
+ "workspace_root" => workspace_root,
64
+ "session_id" => session&.id,
65
+ "session_path" => session&.path,
66
+ "created_at" => timestamp(created_at),
67
+ "updated_at" => timestamp(updated_at),
68
+ "started_at" => timestamp(started_at),
69
+ "finished_at" => timestamp(finished_at),
70
+ "report" => report,
71
+ "error" => error
72
+ }
73
+ end
74
+
75
+ private
76
+
77
+ def timestamp(value)
78
+ value&.utc&.iso8601(3)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,38 @@
1
+ require "thread"
2
+
3
+ module Kward
4
+ module Workers
5
+ # Cooperative ownership guard for workspace-mutating worker tools.
6
+ class WriteLock
7
+ def initialize
8
+ @owner_id = nil
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ attr_reader :owner_id
13
+
14
+ def acquire(owner_id)
15
+ owner = owner_id.to_s
16
+ return false if owner.empty?
17
+
18
+ @mutex.synchronize do
19
+ return true if @owner_id == owner
20
+ return false if @owner_id
21
+
22
+ @owner_id = owner
23
+ true
24
+ end
25
+ end
26
+
27
+ def owned_by?(owner_id)
28
+ @mutex.synchronize { @owner_id == owner_id.to_s }
29
+ end
30
+
31
+ def release(owner_id)
32
+ @mutex.synchronize do
33
+ @owner_id = nil if @owner_id == owner_id.to_s
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,10 @@
1
+ require_relative "workers/git_guard"
2
+ require_relative "workers/job"
3
+ require_relative "workers/live_view"
4
+ require_relative "workers/manager"
5
+ require_relative "workers/queue_runner"
6
+ require_relative "workers/queue_store"
7
+ require_relative "workers/store"
8
+ require_relative "workers/tool_policy"
9
+ require_relative "workers/write_lock"
10
+ require_relative "workers/worker"
@@ -1,6 +1,5 @@
1
- require "open3"
2
1
  require "pathname"
3
- require "timeout"
2
+ require_relative "local_command_runner"
4
3
  require_relative "session_diff"
5
4
 
6
5
  # Namespace for the Kward CLI agent runtime.
@@ -23,6 +22,7 @@ module Kward
23
22
  MAX_COMMAND_OUTPUT_BYTES = 128 * 1024
24
23
  MAX_EDIT_DIFF_BYTES = 8 * 1024
25
24
  DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
25
+ EXPECTED_FILE_ERRORS = [SecurityError, Errno::ENOENT, Errno::EACCES, Errno::EPERM, Errno::EISDIR, Errno::ENOTDIR].freeze
26
26
 
27
27
  # Creates an object for workspace filesystem and shell operations.
28
28
  def initialize(root: Dir.pwd, max_file_bytes: MAX_FILE_BYTES, max_read_output_bytes: MAX_READ_OUTPUT_BYTES, max_read_output_lines: MAX_READ_OUTPUT_LINES, max_command_output_bytes: MAX_COMMAND_OUTPUT_BYTES, guardrails: true)
@@ -45,7 +45,7 @@ module Kward
45
45
  Dir.children(resolved).sort.map do |entry|
46
46
  File.directory?(File.join(resolved, entry)) ? "#{entry}/" : entry
47
47
  end.join("\n")
48
- rescue SecurityError, Errno::ENOENT => e
48
+ rescue *EXPECTED_FILE_ERRORS => e
49
49
  "Error: #{e.message}"
50
50
  end
51
51
 
@@ -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,8 +64,25 @@ module Kward
64
64
  content = File.read(resolved)
65
65
  return "Error: not a text file: #{path}" if binary_content?(content)
66
66
 
67
- large_file_outline_response(path, content, offset: offset, limit: limit) || read_file_slice(content, offset: offset, limit: limit)
68
- rescue SecurityError, Errno::ENOENT => e
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 *EXPECTED_FILE_ERRORS => e
69
86
  "Error: #{e.message}"
70
87
  end
71
88
 
@@ -80,12 +97,8 @@ module Kward
80
97
  content = File.read(resolved)
81
98
  return "Error: not a text file: #{path}" if binary_content?(content)
82
99
 
83
- lines = content.split("\n", -1)
84
- outline = source_outline(lines)
85
- return "No recognizable source structure found in #{path}." if outline.empty?
86
-
87
- (["# File structure: #{path}", "- Lines: #{lines.length}", "- Bytes: #{content.bytesize}", "", "## Outline"] + outline).join("\n")
88
- rescue SecurityError, Errno::ENOENT => e
100
+ file_structure_summary(path, content)
101
+ rescue *EXPECTED_FILE_ERRORS => e
89
102
  "Error: #{e.message}"
90
103
  end
91
104
 
@@ -107,7 +120,7 @@ module Kward
107
120
  output = "Wrote #{content.bytesize} bytes to #{path}"
108
121
  output << "\n#{truncated_diff(path, old_content, content)}" if old_content && old_content != content
109
122
  output
110
- rescue SecurityError, Errno::ENOENT => e
123
+ rescue *EXPECTED_FILE_ERRORS => e
111
124
  "Error: #{e.message}"
112
125
  end
113
126
 
@@ -130,7 +143,7 @@ module Kward
130
143
 
131
144
  File.write(resolved, result[:content])
132
145
  "Edited #{path}: replaced #{result[:count]} block(s)\n#{truncated_diff(path, content, result[:content])}"
133
- rescue SecurityError, Errno::ENOENT => e
146
+ rescue *EXPECTED_FILE_ERRORS => e
134
147
  "Error: #{e.message}"
135
148
  end
136
149
 
@@ -147,24 +160,14 @@ module Kward
147
160
  timeout_seconds = DEFAULT_COMMAND_TIMEOUT_SECONDS if timeout_seconds <= 0
148
161
  cancellation&.raise_if_cancelled!
149
162
 
150
- Open3.popen3(command, chdir: @root.to_s) do |stdin, stdout, stderr, wait_thread|
151
- stdin.close
152
- stdout_reader = Thread.new { stdout.read }
153
- stderr_reader = Thread.new { stderr.read }
154
- cancellation&.on_cancel { terminate_process(wait_thread.pid) }
155
- status = wait_for_process(wait_thread, timeout_seconds, cancellation)
156
-
157
- output = +"Exit status: #{status.exitstatus}\n"
158
- output << "\nSTDOUT:\n#{stdout_reader.value}" unless stdout_reader.value.empty?
159
- output << "\nSTDERR:\n#{stderr_reader.value}" unless stderr_reader.value.empty?
160
- truncate_output(output)
161
- rescue Timeout::Error
162
- terminate_process(wait_thread.pid)
163
- "Error: command timed out after #{timeout_seconds} seconds"
164
- ensure
165
- stdout_reader&.kill if stdout_reader&.alive?
166
- stderr_reader&.kill if stderr_reader&.alive?
167
- end
163
+ result = LocalCommandRunner.new(timeout_seconds: timeout_seconds, max_output_bytes: @max_command_output_bytes).run(command, cwd: @root.to_s, cancellation: cancellation)
164
+ return "Error: command timed out after #{timeout_seconds} seconds" if result.timed_out
165
+
166
+ output = +"Exit status: #{result.exit_status}\n"
167
+ output << "\nSTDOUT:\n#{result.stdout}" unless result.stdout.empty?
168
+ output << "\nSTDERR:\n#{result.stderr}" unless result.stderr.empty?
169
+ output << "\n... truncated to #{@max_command_output_bytes} bytes" if result.truncated
170
+ truncate_output(output)
168
171
  rescue Errno::ENOENT, ArgumentError => e
169
172
  "Error: #{e.message}"
170
173
  end
@@ -235,24 +238,96 @@ module Kward
235
238
  "First #{preview_limit} lines:",
236
239
  preview,
237
240
  "",
238
- "[Use read_file with offset=#{preview_limit + 1} and limit to continue, or request a specific section from the outline.]"
241
+ "[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.]"
239
242
  ].join("\n")
240
243
  end
241
244
 
245
+ def file_structure_summary(path, content)
246
+ lines = content.split("\n", -1)
247
+ outline = source_outline(lines)
248
+ return "No recognizable source structure found in #{path}." if outline.empty?
249
+
250
+ (["# File structure: #{path}", "- Lines: #{lines.length}", "- Bytes: #{content.bytesize}", "", "## Outline"] + outline).join("\n")
251
+ end
252
+
242
253
  def source_outline(lines)
243
- outline = []
254
+ entries = source_outline_entries(lines)
255
+ entries.first(80).map do |entry|
256
+ range = entry[:end_line] && entry[:end_line] != entry[:line] ? " (range #{entry[:line]}-#{entry[:end_line]}, #{entry[:kind]})" : " (#{entry[:kind]})"
257
+ "line #{entry[:line]}: #{' ' * [entry[:indent] / 2, 6].min}#{entry[:signature]}#{range}"
258
+ end
259
+ end
260
+
261
+ def source_outline_entries(lines)
262
+ candidates = []
244
263
  lines.each_with_index do |line, index|
245
- stripped = line.strip
246
- next unless stripped.match?(/\A(class|module|def)\s+/) || stripped.match?(/\A(function|async function)\s+/) || stripped.match?(/\A(export\s+)?(class|interface|type)\s+/)
264
+ declaration = source_declaration(line.strip)
265
+ next unless declaration
247
266
 
248
- indent = line[/\A\s*/].to_s.length
249
- outline << "line #{index + 1}: #{' ' * [indent / 2, 6].min}#{stripped}"
250
- break if outline.length >= 80
267
+ candidates << declaration.merge(line: index + 1, indent: line[/\A\s*/].to_s.length)
251
268
  end
252
- outline
269
+ candidates.each_with_index do |entry, index|
270
+ following = candidates[(index + 1)..]&.find { |candidate| candidate[:indent] <= entry[:indent] }
271
+ entry[:end_line] = following ? following[:line] - 1 : last_content_line(lines)
272
+ end
273
+ candidates
253
274
  end
254
275
 
255
- def read_file_slice(content, offset:, limit:)
276
+ def source_declaration(stripped)
277
+ case stripped
278
+ when /\A(module)\s+(.+)/
279
+ { kind: "module", signature: stripped }
280
+ when /\A(class)\s+(.+)/
281
+ { kind: "class", signature: stripped }
282
+ when /\A(async\s+def|def)\s+(.+)/
283
+ { kind: "function", signature: stripped }
284
+ when /\A(export\s+)?(async\s+)?function\s+(.+)/
285
+ { kind: "function", signature: stripped }
286
+ when /\A(async\s+)?(?:get\s+|set\s+)?(?:constructor|[A-Za-z_$][\w$]*)\s*\([^;]*\)\s*(?::\s*[^{}]+)?\s*(?:\{\}|\{|=>)?\z/
287
+ { kind: "method", signature: stripped } unless stripped.match?(/\A(if|for|while|switch|catch)\b/)
288
+ when /\A(export\s+)?(class|interface|type|enum)\s+(.+)/
289
+ { kind: Regexp.last_match(2), signature: stripped }
290
+ when /\A(?:export\s+)?(?:const|let|var)\s+\w+\s*=.*=>/
291
+ { kind: "function", signature: stripped }
292
+ when /\Afunc\s+(.+)/
293
+ { kind: "function", signature: stripped }
294
+ when /\Atype\s+\w+\s+(struct|interface)\b/
295
+ { kind: Regexp.last_match(1), signature: stripped }
296
+ when /\A(pub\s+)?(async\s+)?fn\s+(.+)/
297
+ { kind: "function", signature: stripped }
298
+ when /\A(pub\s+)?(struct|enum|trait|impl)\b(.+)?/
299
+ { kind: Regexp.last_match(2), signature: stripped }
300
+ when /\A(?:public|private|protected|internal|static|final|abstract|async|override|virtual|sealed|readonly|partial|\s)+\s*(class|interface|enum|record)\s+(.+)/
301
+ { kind: Regexp.last_match(1), signature: stripped }
302
+ when /\A(?:public|private|protected|internal|static|final|abstract|async|override|virtual|sealed|readonly|partial|\s)+\s*\S[^{;=]*\w+\s*\([^;]*\)\s*(?:\{|=>)?\z/
303
+ { kind: "method", signature: stripped }
304
+ end
305
+ end
306
+
307
+ def last_content_line(lines)
308
+ index = lines.rindex { |line| !line.strip.empty? }
309
+ index ? index + 1 : lines.length
310
+ end
311
+
312
+ def normalize_read_mode(mode)
313
+ return nil if mode.nil? || mode.to_s.empty?
314
+
315
+ value = mode.to_s.downcase
316
+ return value.to_sym if %w[preview outline range full].include?(value)
317
+
318
+ "Error: mode must be one of preview, outline, range, full"
319
+ end
320
+
321
+ def read_output_budget(max_bytes)
322
+ return @max_read_output_bytes if max_bytes.nil?
323
+
324
+ value = max_bytes.to_i
325
+ return "Error: max_bytes must be positive" unless value.positive?
326
+
327
+ [value, @max_read_output_bytes].min
328
+ end
329
+
330
+ def read_file_slice(content, offset:, limit:, max_bytes: @max_read_output_bytes)
256
331
  lines = content.split("\n", -1)
257
332
  lines = [""] if lines.empty?
258
333
  start_index = read_start_index(offset)
@@ -263,7 +338,7 @@ module Kward
263
338
 
264
339
  selected_end = user_limit ? [start_index + user_limit, lines.length].min : lines.length
265
340
  selected_lines = lines[start_index...selected_end]
266
- truncated = truncate_read_lines(selected_lines)
341
+ truncated = truncate_read_lines(selected_lines, max_bytes: max_bytes)
267
342
  return truncated[:error] if truncated[:error]
268
343
 
269
344
  output = truncated[:content]
@@ -272,7 +347,8 @@ module Kward
272
347
  start_index: start_index,
273
348
  output_lines: truncated[:line_count],
274
349
  total_lines: lines.length,
275
- truncated_by: truncated[:truncated_by]
350
+ truncated_by: truncated[:truncated_by],
351
+ max_bytes: max_bytes
276
352
  )
277
353
  elsif user_limit && selected_end < lines.length
278
354
  output << "\n\n[#{lines.length - selected_end} more lines in file. Use offset=#{selected_end + 1} to continue.]"
@@ -300,11 +376,11 @@ module Kward
300
376
  value
301
377
  end
302
378
 
303
- def truncate_read_lines(lines)
379
+ def truncate_read_lines(lines, max_bytes: @max_read_output_bytes)
304
380
  first_line = lines.first.to_s
305
- if first_line.bytesize > @max_read_output_bytes
381
+ if first_line.bytesize > max_bytes
306
382
  return {
307
- 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."
383
+ 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."
308
384
  }
309
385
  end
310
386
 
@@ -319,7 +395,7 @@ module Kward
319
395
 
320
396
  separator_bytes = output_lines.empty? ? 0 : 1
321
397
  next_bytes = line.bytesize + separator_bytes
322
- if bytes + next_bytes > @max_read_output_bytes
398
+ if bytes + next_bytes > max_bytes
323
399
  truncated_by = "bytes"
324
400
  break
325
401
  end
@@ -336,10 +412,10 @@ module Kward
336
412
  }
337
413
  end
338
414
 
339
- def read_truncation_notice(start_index:, output_lines:, total_lines:, truncated_by:)
415
+ def read_truncation_notice(start_index:, output_lines:, total_lines:, truncated_by:, max_bytes: @max_read_output_bytes)
340
416
  end_line = start_index + output_lines
341
417
  next_offset = end_line + 1
342
- detail = truncated_by == "lines" ? "#{@max_read_output_lines} line limit" : "#{@max_read_output_bytes} byte limit"
418
+ detail = truncated_by == "lines" ? "#{@max_read_output_lines} line limit" : "#{max_bytes} byte limit"
343
419
  "\n\n[Showing lines #{start_index + 1}-#{end_line} of #{total_lines} (#{detail}). Use offset=#{next_offset} to continue.]"
344
420
  end
345
421
 
@@ -436,43 +512,5 @@ module Kward
436
512
  output.byteslice(0, @max_command_output_bytes) << "\n... truncated to #{@max_command_output_bytes} bytes"
437
513
  end
438
514
 
439
- def wait_for_process(wait_thread, timeout_seconds, cancellation)
440
- deadline = Time.now + timeout_seconds
441
- loop do
442
- cancellation&.raise_if_cancelled!
443
- if wait_thread.join(0.05)
444
- cancellation&.raise_if_cancelled!
445
- return wait_thread.value
446
- end
447
- raise Timeout::Error if Time.now >= deadline
448
- end
449
- end
450
-
451
- def terminate_process(pid)
452
- return unless signal_process("TERM", pid)
453
-
454
- deadline = Time.now + 0.2
455
- while Time.now < deadline
456
- return unless process_running?(pid)
457
-
458
- sleep 0.02
459
- end
460
-
461
- signal_process("KILL", pid)
462
- end
463
-
464
- def process_running?(pid)
465
- Process.kill(0, pid)
466
- true
467
- rescue Errno::ESRCH
468
- false
469
- end
470
-
471
- def signal_process(signal, pid)
472
- Process.kill(signal, pid)
473
- true
474
- rescue Errno::ESRCH
475
- false
476
- end
477
515
  end
478
516
  end