kward 0.71.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/Gemfile.lock +2 -2
  4. data/README.md +4 -0
  5. data/doc/agent-tools.md +15 -6
  6. data/doc/authentication.md +22 -1
  7. data/doc/code-search.md +42 -2
  8. data/doc/configuration.md +106 -3
  9. data/doc/context-budgeting.md +136 -0
  10. data/doc/context-tools.md +16 -3
  11. data/doc/editor.md +394 -0
  12. data/doc/extensibility.md +16 -7
  13. data/doc/files.md +100 -0
  14. data/doc/getting-started.md +25 -18
  15. data/doc/git.md +122 -0
  16. data/doc/memory.md +24 -4
  17. data/doc/personas.md +34 -5
  18. data/doc/plugins.md +72 -1
  19. data/doc/releasing.md +37 -9
  20. data/doc/rpc.md +74 -4
  21. data/doc/session-management.md +35 -1
  22. data/doc/shell.md +286 -0
  23. data/doc/tabs.md +122 -0
  24. data/doc/troubleshooting.md +77 -1
  25. data/doc/usage.md +53 -7
  26. data/doc/web-search.md +12 -4
  27. data/doc/workspace-tools.md +51 -12
  28. data/examples/plugins/space_invaders.rb +377 -0
  29. data/lib/kward/agent.rb +1 -1
  30. data/lib/kward/cli/commands.rb +33 -2
  31. data/lib/kward/cli/git.rb +150 -0
  32. data/lib/kward/cli/interactive_turn.rb +73 -9
  33. data/lib/kward/cli/plugins.rb +54 -4
  34. data/lib/kward/cli/prompt_interface.rb +32 -1
  35. data/lib/kward/cli/runtime_helpers.rb +133 -3
  36. data/lib/kward/cli/sessions.rb +2 -2
  37. data/lib/kward/cli/settings.rb +218 -9
  38. data/lib/kward/cli/slash_commands.rb +415 -2
  39. data/lib/kward/cli/tabs.rb +695 -0
  40. data/lib/kward/cli.rb +158 -26
  41. data/lib/kward/config_files.rb +123 -1
  42. data/lib/kward/context_budget_meter.rb +44 -0
  43. data/lib/kward/conversation.rb +12 -4
  44. data/lib/kward/editor_mode.rb +25 -0
  45. data/lib/kward/ekwsh.rb +362 -0
  46. data/lib/kward/plugin_registry.rb +61 -0
  47. data/lib/kward/project_files.rb +52 -0
  48. data/lib/kward/prompt_history.rb +82 -0
  49. data/lib/kward/prompt_interface/composer_controller.rb +69 -1
  50. data/lib/kward/prompt_interface/composer_renderer.rb +109 -13
  51. data/lib/kward/prompt_interface/composer_state.rb +96 -27
  52. data/lib/kward/prompt_interface/editor/auto_close_pairs.rb +123 -0
  53. data/lib/kward/prompt_interface/editor/auto_indent.rb +509 -0
  54. data/lib/kward/prompt_interface/editor/buffer.rb +109 -0
  55. data/lib/kward/prompt_interface/editor/controller.rb +1018 -0
  56. data/lib/kward/prompt_interface/editor/endwise.rb +321 -0
  57. data/lib/kward/prompt_interface/editor/file_marker.rb +40 -0
  58. data/lib/kward/prompt_interface/editor/indent_navigation.rb +61 -0
  59. data/lib/kward/prompt_interface/editor/kill_ring.rb +78 -0
  60. data/lib/kward/prompt_interface/editor/modes/emacs.rb +259 -0
  61. data/lib/kward/prompt_interface/editor/modes/modern.rb +353 -0
  62. data/lib/kward/prompt_interface/editor/modes/vibe.rb +1962 -0
  63. data/lib/kward/prompt_interface/editor/renderer.rb +243 -0
  64. data/lib/kward/prompt_interface/editor/search.rb +76 -0
  65. data/lib/kward/prompt_interface/editor/selections.rb +120 -0
  66. data/lib/kward/prompt_interface/editor/state.rb +1249 -0
  67. data/lib/kward/prompt_interface/editor/status_text.rb +23 -0
  68. data/lib/kward/prompt_interface/editor/syntax_highlighter.rb +420 -0
  69. data/lib/kward/prompt_interface/editor/undo_history.rb +46 -0
  70. data/lib/kward/prompt_interface/editor/vibe_state.rb +44 -0
  71. data/lib/kward/prompt_interface/file_overlay.rb +211 -0
  72. data/lib/kward/prompt_interface/git_prompt.rb +299 -0
  73. data/lib/kward/prompt_interface/interactive/controller.rb +186 -0
  74. data/lib/kward/prompt_interface/interactive/renderer.rb +71 -0
  75. data/lib/kward/prompt_interface/interactive/state.rb +62 -0
  76. data/lib/kward/prompt_interface/key_handler.rb +387 -35
  77. data/lib/kward/prompt_interface/overlay_renderer.rb +21 -2
  78. data/lib/kward/prompt_interface/project_browser.rb +524 -0
  79. data/lib/kward/prompt_interface/question_prompt.rb +98 -50
  80. data/lib/kward/prompt_interface/runtime_state.rb +43 -0
  81. data/lib/kward/prompt_interface/screen.rb +16 -0
  82. data/lib/kward/prompt_interface/selection_prompt.rb +7 -13
  83. data/lib/kward/prompt_interface/stream_state.rb +7 -0
  84. data/lib/kward/prompt_interface/transcript_buffer.rb +6 -0
  85. data/lib/kward/prompt_interface.rb +286 -8
  86. data/lib/kward/prompts/commands.rb +5 -0
  87. data/lib/kward/prompts.rb +2 -0
  88. data/lib/kward/rpc/server.rb +42 -3
  89. data/lib/kward/rpc/session_manager.rb +35 -47
  90. data/lib/kward/rpc/session_tree_rows.rb +9 -115
  91. data/lib/kward/rpc/tool_event_normalizer.rb +1 -1
  92. data/lib/kward/session_store.rb +44 -0
  93. data/lib/kward/session_tree_nodes.rb +136 -0
  94. data/lib/kward/session_tree_renderer.rb +9 -131
  95. data/lib/kward/tab_store.rb +47 -0
  96. data/lib/kward/text_boundary.rb +25 -0
  97. data/lib/kward/tools/context_budget_stats.rb +54 -0
  98. data/lib/kward/tools/context_for_task.rb +202 -0
  99. data/lib/kward/tools/read_file.rb +8 -4
  100. data/lib/kward/tools/registry.rb +62 -16
  101. data/lib/kward/tools/tool_call.rb +10 -0
  102. data/lib/kward/version.rb +1 -1
  103. data/lib/kward/workers/git_guard.rb +68 -0
  104. data/lib/kward/workers/live_view.rb +49 -0
  105. data/lib/kward/workers/manager.rb +288 -0
  106. data/lib/kward/workers/store.rb +72 -0
  107. data/lib/kward/workers/tool_policy.rb +23 -0
  108. data/lib/kward/workers/worker.rb +82 -0
  109. data/lib/kward/workers/write_lock.rb +38 -0
  110. data/lib/kward/workers.rb +7 -0
  111. data/lib/kward/workspace.rb +110 -24
  112. data/templates/default/fulldoc/html/css/kward.css +107 -36
  113. data/templates/default/kward_navigation.rb +12 -1
  114. data/templates/default/layout/html/layout.erb +4 -2
  115. data/templates/default/layout/html/setup.rb +6 -0
  116. metadata +53 -1
@@ -1,7 +1,7 @@
1
1
  require_relative "message_access"
2
2
  require_relative "message_text"
3
+ require_relative "session_tree_nodes"
3
4
  require_relative "session_tree_tool_display"
4
- require_relative "tools/tool_call"
5
5
 
6
6
  # Namespace for the Kward CLI agent runtime.
7
7
  module Kward
@@ -13,13 +13,14 @@ module Kward
13
13
  end
14
14
 
15
15
  def items
16
- active_path = session_tree_active_path(@roots, @current_leaf_id)
17
- tool_calls_by_id = session_tree_tool_calls(@roots)
18
- visible_roots = @roots.flat_map { |root| visible_session_tree_nodes(root) }
16
+ tree_nodes = SessionTreeNodes.new(roots: @roots, current_leaf: @current_leaf_id)
17
+ active_path = tree_nodes.active_path
18
+ tool_calls_by_id = tree_nodes.tool_calls
19
+ visible_roots = tree_nodes.visible_roots
19
20
  multiple_roots = visible_roots.length > 1
20
21
  result = []
21
22
 
22
- stack = visible_roots.sort_by { |root| session_tree_contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
23
+ stack = visible_roots.sort_by { |root| tree_nodes.contains_active_path?(root, active_path) ? 0 : 1 }.each_with_index.map do |root, index|
23
24
  [root, multiple_roots ? 1 : 0, multiple_roots, multiple_roots, index == visible_roots.length - 1, [], multiple_roots]
24
25
  end.reverse
25
26
 
@@ -33,7 +34,7 @@ module Kward
33
34
  label: session_tree_label(entry, node[:source], prefix, active_path.include?(entry["id"].to_s), tool_calls_by_id)
34
35
  }
35
36
 
36
- children = node[:children].sort_by { |child| session_tree_contains_active_path?(child, active_path) ? 0 : 1 }
37
+ children = node[:children].sort_by { |child| tree_nodes.contains_active_path?(child, active_path) ? 0 : 1 }
37
38
  multiple_children = children.length > 1
38
39
  child_indent = if multiple_children
39
40
  indent + 1
@@ -55,52 +56,6 @@ module Kward
55
56
 
56
57
  private
57
58
 
58
- def visible_session_tree_nodes(node)
59
- results = {}
60
- stack = [[node, false, {}]]
61
-
62
- until stack.empty?
63
- current, visited, seen = stack.pop
64
- node_key = current.object_id
65
- next if seen[node_key]
66
-
67
- if visited
68
- children = Array(current["children"]).flat_map { |child| results[child.object_id] || [] }
69
- results[node_key] = if hidden_session_tree_entry?(current["entry"] || {})
70
- children
71
- else
72
- [{ source: current, children: children }]
73
- end
74
- else
75
- branch_seen = seen.merge(node_key => true)
76
- stack << [current, true, seen]
77
- Array(current["children"]).reverse_each { |child| stack << [child, false, branch_seen] unless branch_seen[child.object_id] }
78
- end
79
- end
80
-
81
- results[node.object_id] || []
82
- end
83
-
84
- def hidden_session_tree_entry?(entry)
85
- return false if @current_leaf_id && entry["id"].to_s == @current_leaf_id.to_s
86
- return false unless entry["type"] == "message"
87
-
88
- message = entry["message"]
89
- return false unless message.is_a?(Hash) && message_role(message) == "assistant"
90
-
91
- content = message_content(message)
92
- content_tool_calls = content.is_a?(Array) && content.any? { |part| session_tree_content_part_value(part, :type) == "toolCall" }
93
- (content_tool_calls && !session_tree_text_content?(content)) || (!message_tool_calls(message).empty? && full_message_text(message).empty?)
94
- end
95
-
96
- def session_tree_text_content?(content)
97
- Array(content).any? { |part| session_tree_content_part_value(part, :type) == "text" && session_tree_content_part_value(part, :text).to_s.strip != "" }
98
- end
99
-
100
- def session_tree_content_part_value(part, key)
101
- MessageAccess.value(part, key)
102
- end
103
-
104
59
  def session_tree_label(entry, node, prefix, active_path, tool_calls_by_id)
105
60
  label = node["label"] || entry["resolvedLabel"]
106
61
  label = label.to_s.strip
@@ -152,70 +107,6 @@ module Kward
152
107
  end.join
153
108
  end
154
109
 
155
- def session_tree_contains_active_path?(node, active_path)
156
- stack = [node]
157
- seen = {}
158
- until stack.empty?
159
- current = stack.pop
160
- next if seen[current.object_id]
161
-
162
- seen[current.object_id] = true
163
- entry_id = (current[:source]["entry"] || {})["id"].to_s
164
- return true if active_path.include?(entry_id)
165
-
166
- stack.concat(current[:children])
167
- end
168
- false
169
- end
170
-
171
- def session_tree_active_path(roots, leaf_id)
172
- by_id = session_tree_entries_by_id(roots)
173
- ids = []
174
- entry = by_id[leaf_id.to_s]
175
- seen = {}
176
- while entry && !seen[entry["id"].to_s]
177
- seen[entry["id"].to_s] = true
178
- ids << entry["id"].to_s
179
- entry = by_id[entry["parentId"].to_s]
180
- end
181
- ids
182
- end
183
-
184
- def session_tree_entries_by_id(roots)
185
- roots.each_with_object({}) do |root, map|
186
- stack = [root]
187
- seen = {}
188
- until stack.empty?
189
- node = stack.pop
190
- next if seen[node.object_id]
191
-
192
- seen[node.object_id] = true
193
- entry = node["entry"] || {}
194
- map[entry["id"].to_s] = entry unless entry["id"].to_s.empty?
195
- stack.concat(Array(node["children"]))
196
- end
197
- end
198
- end
199
-
200
- def session_tree_tool_calls(roots)
201
- roots.each_with_object({}) do |root, tool_calls|
202
- stack = [root]
203
- seen = {}
204
- until stack.empty?
205
- node = stack.pop
206
- next if seen[node.object_id]
207
-
208
- seen[node.object_id] = true
209
- entry = node["entry"] || {}
210
- message = entry["message"]
211
- if entry["type"] == "message" && message.is_a?(Hash) && message_role(message) == "assistant"
212
- message_tool_calls(message).each { |tool_call| tool_calls[tool_call_id(tool_call).to_s] = tool_call }
213
- end
214
- stack.concat(Array(node["children"]))
215
- end
216
- end
217
- end
218
-
219
110
  def session_tree_tool_display(message, tool_calls_by_id)
220
111
  tool_call = tool_calls_by_id[session_tree_message_tool_call_id(message).to_s]
221
112
  return SessionTreeToolDisplay.label(tool_call) if tool_call
@@ -233,12 +124,11 @@ module Kward
233
124
  end
234
125
 
235
126
  def display_message_text(message)
236
- truncate_session_tree_text(full_message_text(message))
127
+ SessionTreeNodes.truncate_text(full_message_text(message))
237
128
  end
238
129
 
239
130
  def truncate_session_tree_text(text)
240
- normalized = text.to_s.gsub(/\s+/, " ").strip
241
- normalized.length > 120 ? "#{normalized.slice(0, 117)}..." : normalized
131
+ SessionTreeNodes.truncate_text(text)
242
132
  end
243
133
 
244
134
  def full_message_text(message)
@@ -249,10 +139,6 @@ module Kward
249
139
  MessageAccess.role(message)
250
140
  end
251
141
 
252
- def message_content(message)
253
- MessageAccess.content(message)
254
- end
255
-
256
142
  def message_name(message)
257
143
  MessageAccess.name(message)
258
144
  end
@@ -261,13 +147,5 @@ module Kward
261
147
  MessageAccess.tool_call_id(message)
262
148
  end
263
149
 
264
- def message_tool_calls(message)
265
- MessageAccess.tool_calls(message)
266
- end
267
-
268
- def tool_call_id(tool_call)
269
- ToolCall.id(tool_call)
270
- end
271
-
272
150
  end
273
151
  end
@@ -0,0 +1,47 @@
1
+ require "digest"
2
+ require "json"
3
+ require_relative "config_files"
4
+ require_relative "private_file"
5
+
6
+ # Namespace for the Kward CLI agent runtime.
7
+ module Kward
8
+ # Persists the terminal UI's open session tabs per workspace.
9
+ class TabStore
10
+ def initialize(config_dir: ConfigFiles.config_dir, cwd: Dir.pwd)
11
+ @config_dir = config_dir
12
+ @cwd = File.expand_path(cwd)
13
+ end
14
+
15
+ def load
16
+ return { "session_paths" => [], "active_index" => 0 } unless File.file?(path)
17
+
18
+ data = JSON.parse(File.read(path))
19
+ paths = Array(data["session_paths"]).map(&:to_s).reject(&:empty?)
20
+ labels = Array(data["labels"]).map(&:to_s)
21
+ active_index = data["active_index"].to_i
22
+ { "session_paths" => paths, "labels" => labels, "active_index" => active_index }
23
+ rescue JSON::ParserError
24
+ { "session_paths" => [], "active_index" => 0 }
25
+ end
26
+
27
+ def save(session_paths:, active_index:, labels: [])
28
+ paths = Array(session_paths).map(&:to_s).reject(&:empty?)
29
+ PrivateFile.write_json(path, {
30
+ "cwd" => @cwd,
31
+ "session_paths" => paths,
32
+ "labels" => Array(labels).map(&:to_s),
33
+ "active_index" => [[active_index.to_i, 0].max, [paths.length - 1, 0].max].min
34
+ })
35
+ end
36
+
37
+ def path
38
+ File.join(@config_dir, "tabs", "#{workspace_key}.json")
39
+ end
40
+
41
+ private
42
+
43
+ def workspace_key
44
+ Digest::SHA256.hexdigest(@cwd)[0, 24]
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,25 @@
1
+ # Namespace for the Kward CLI agent runtime.
2
+ module Kward
3
+ # Small text navigation helpers shared by composer and editor buffers.
4
+ module TextBoundary
5
+ module_function
6
+
7
+ def previous_word_boundary(text, index)
8
+ cursor = index.to_i
9
+ cursor -= 1 while cursor.positive? && word_separator?(text[cursor - 1])
10
+ cursor -= 1 while cursor.positive? && !word_separator?(text[cursor - 1])
11
+ cursor
12
+ end
13
+
14
+ def next_word_boundary(text, index)
15
+ cursor = index.to_i
16
+ cursor += 1 while cursor < text.length && word_separator?(text[cursor])
17
+ cursor += 1 while cursor < text.length && !word_separator?(text[cursor])
18
+ cursor
19
+ end
20
+
21
+ def word_separator?(char)
22
+ char.to_s.match?(/\s/)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,54 @@
1
+ require_relative "base"
2
+
3
+ # Namespace for the Kward CLI agent runtime.
4
+ module Kward
5
+ # Model-callable tool wrappers and their argument schemas.
6
+ module Tools
7
+ # Reports approximate context budget savings for the current process.
8
+ class ContextBudgetStats < Base
9
+ def initialize(context_budget_meter: nil)
10
+ @context_budget_meter = context_budget_meter
11
+ super(
12
+ "context_budget_stats",
13
+ "Return approximate per-session context budget savings from tool output compaction and deduplication.",
14
+ properties: {},
15
+ required: []
16
+ )
17
+ end
18
+
19
+ def call(_args, conversation, cancellation: nil)
20
+ cancellation&.raise_if_cancelled!
21
+ meter = conversation.respond_to?(:context_budget_meter) ? conversation.context_budget_meter : @context_budget_meter
22
+ return "Error: context budget stats are unavailable" unless meter
23
+
24
+ snapshot = meter.snapshot
25
+ lines = [
26
+ "# Context budget stats",
27
+ "- Calls: #{snapshot.calls}",
28
+ "- Original bytes: #{snapshot.original_bytes}",
29
+ "- Returned bytes: #{snapshot.returned_bytes}",
30
+ "- Saved bytes: #{snapshot.saved_bytes}",
31
+ "- Estimated tokens saved: #{estimated_tokens(snapshot.saved_bytes)}"
32
+ ]
33
+ lines.concat(tool_breakdown_lines(snapshot.tool_breakdown))
34
+ lines.join("\n")
35
+ end
36
+
37
+ private
38
+
39
+ def tool_breakdown_lines(tool_breakdown)
40
+ return [] if tool_breakdown.empty?
41
+
42
+ lines = ["", "## By tool"]
43
+ tool_breakdown.sort_by { |tool, data| [-data[:savedBytes], tool] }.each do |tool, data|
44
+ lines << "- #{tool}: #{data[:calls]} call(s), #{data[:savedBytes]} bytes saved, #{data[:returnedBytes]}/#{data[:originalBytes]} bytes returned"
45
+ end
46
+ lines
47
+ end
48
+
49
+ def estimated_tokens(bytes)
50
+ (bytes.to_i / 4.0).ceil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,202 @@
1
+ require "pathname"
2
+ require_relative "../workspace"
3
+ require_relative "base"
4
+
5
+ # Namespace for the Kward CLI agent runtime.
6
+ module Kward
7
+ # Model-callable tool wrappers and their argument schemas.
8
+ module Tools
9
+ # Builds a compact, task-shaped bundle from likely workspace files.
10
+ class ContextForTask < Base
11
+ DEFAULT_BUDGET = 4_000
12
+ MAX_BUDGET = 20_000
13
+ MAX_FILES = 8
14
+ MAX_MATCHES_PER_FILE = 8
15
+ DEFAULT_EXTENSIONS = %w[.rb .js .jsx .ts .tsx .py .go .rs .java .cs .cpp .c .h .hpp .md .yml .yaml .json .toml].freeze
16
+ SKIP_DIRECTORIES = %w[.git .yardoc _yardoc node_modules vendor tmp log coverage dist build .bundle].freeze
17
+
18
+ # Builds the tool schema and stores the execution dependency.
19
+ def initialize(workspace:)
20
+ @workspace = workspace
21
+ super(
22
+ "context_for_task",
23
+ "Build focused workspace context for a task from outlines and matching excerpts within a byte budget.",
24
+ properties: {
25
+ budget: { type: "integer", description: "Approximate byte budget for the returned context. Default 4000, maximum 20000." },
26
+ paths: { type: "array", items: { type: "string" }, description: "Optional workspace-relative files or directories to focus." },
27
+ task: { type: "string", description: "Task or question to gather context for." }
28
+ },
29
+ required: ["task"]
30
+ )
31
+ end
32
+
33
+ # Executes focused context retrieval.
34
+ def call(args, _conversation, cancellation: nil)
35
+ cancellation&.raise_if_cancelled!
36
+ task = argument(args, :task, "").to_s.strip
37
+ return "Error: task is required" if task.empty?
38
+
39
+ budget = normalized_budget(argument(args, :budget))
40
+ return budget if budget.is_a?(String)
41
+
42
+ focus_paths = Array(argument(args, :paths, [])).map(&:to_s).reject(&:empty?)
43
+ files = candidate_files(focus_paths, cancellation: cancellation)
44
+ return "No readable candidate files found for focused context." if files.empty?
45
+
46
+ terms = search_terms(task)
47
+ ranked = rank_files(files, terms)
48
+ render_context(task: task, budget: budget, terms: terms, ranked: ranked, cancellation: cancellation)
49
+ rescue SecurityError, Errno::ENOENT => e
50
+ "Error: #{e.message}"
51
+ end
52
+
53
+ private
54
+
55
+ def normalized_budget(value)
56
+ return DEFAULT_BUDGET if value.nil?
57
+
58
+ budget = value.to_i
59
+ return "Error: budget must be positive" unless budget.positive?
60
+
61
+ [budget, MAX_BUDGET].min
62
+ end
63
+
64
+ def candidate_files(paths, cancellation:)
65
+ roots = paths.empty? ? [@workspace.root.to_s] : paths.map { |path| @workspace.resolved_path(path) }
66
+ files = roots.flat_map do |path|
67
+ cancellation&.raise_if_cancelled!
68
+ File.directory?(path) ? files_under(path, cancellation: cancellation) : [path]
69
+ end
70
+ files.uniq.select { |path| readable_context_file?(path) }.first(MAX_FILES * 8)
71
+ end
72
+
73
+ def files_under(root, cancellation:)
74
+ files = []
75
+ stack = [root]
76
+ until stack.empty? || files.length >= MAX_FILES * 8
77
+ cancellation&.raise_if_cancelled!
78
+ current = stack.pop
79
+ next if skipped_directory?(current)
80
+
81
+ entries = Dir.children(current).sort.map { |entry| File.join(current, entry) }
82
+ entries.each do |entry|
83
+ if File.directory?(entry)
84
+ stack << entry
85
+ else
86
+ files << entry if readable_context_file?(entry)
87
+ end
88
+ end
89
+ end
90
+ files
91
+ end
92
+
93
+ def readable_context_file?(path)
94
+ return false unless File.file?(path)
95
+ return false if File.size(path) > Workspace::MAX_FILE_BYTES
96
+ return false unless DEFAULT_EXTENSIONS.include?(File.extname(path)) || File.basename(path) == "Gemfile"
97
+
98
+ sample = File.open(path, "rb") { |file| file.read(4096).to_s }
99
+ !sample.include?("\x00")
100
+ rescue Errno::ENOENT, Errno::EACCES
101
+ false
102
+ end
103
+
104
+ def skipped_directory?(path)
105
+ SKIP_DIRECTORIES.include?(File.basename(path))
106
+ end
107
+
108
+ def search_terms(task)
109
+ task.scan(/[A-Za-z_][A-Za-z0-9_]{2,}/).map(&:downcase).reject { |term| stopword?(term) }.uniq.first(20)
110
+ end
111
+
112
+ def stopword?(term)
113
+ %w[the and for with from this that into when where what why how fix add update change implement review debug explain failing failure error issue].include?(term)
114
+ end
115
+
116
+ def rank_files(files, terms)
117
+ scored = files.map do |path|
118
+ content = File.read(path)
119
+ relative = relative_path(path)
120
+ score = score_file(relative, content, terms)
121
+ { path: path, relative: relative, content: content, score: score }
122
+ rescue Errno::ENOENT, Errno::EACCES
123
+ nil
124
+ end.compact
125
+ filtered = terms.empty? ? scored : scored.select { |file| file[:score].positive? }
126
+ filtered.sort_by { |file| [-file[:score], file[:relative]] }.first(MAX_FILES)
127
+ end
128
+
129
+ def score_file(relative, content, terms)
130
+ haystack = "#{relative}\n#{content}".downcase
131
+ terms.sum { |term| haystack.scan(term).length } + (terms.any? { |term| relative.downcase.include?(term) } ? 5 : 0)
132
+ end
133
+
134
+ def render_context(task:, budget:, terms:, ranked:, cancellation:)
135
+ lines = ["# Focused context", "- Task: #{task}", "- Budget: #{budget} bytes", "- Search terms: #{terms.empty? ? '(none)' : terms.join(', ')}", ""]
136
+ used = lines.join("\n").bytesize
137
+
138
+ ranked.each do |file|
139
+ cancellation&.raise_if_cancelled!
140
+ section = file_section(file, terms)
141
+ break if used + section.bytesize > budget && lines.length > 5
142
+
143
+ if used + section.bytesize > budget
144
+ remaining = budget - used
145
+ break if remaining < 200
146
+
147
+ section = section.byteslice(0, remaining).to_s.scrub << "\n[Context budget reached.]"
148
+ end
149
+ lines << section
150
+ used += section.bytesize
151
+ end
152
+
153
+ lines.join("\n")
154
+ end
155
+
156
+ def file_section(file, terms)
157
+ outline = @workspace.summarize_file_structure(file[:relative])
158
+ matches = matching_excerpt(file[:content], terms)
159
+ parts = ["## #{file[:relative]}", "- Score: #{file[:score]}"]
160
+ parts << outline unless outline.start_with?("No recognizable source structure")
161
+ parts << matches unless matches.empty?
162
+ parts.join("\n") << "\n"
163
+ end
164
+
165
+ def matching_excerpt(content, terms)
166
+ return "" if terms.empty?
167
+
168
+ lines = content.split("\n", -1)
169
+ indexes = matching_indexes(lines, terms)
170
+ return "" if indexes.empty?
171
+
172
+ selected = indexes.flat_map { |index| ([index - 2, 0].max..[index + 2, lines.length - 1].min).to_a }.uniq.sort
173
+ render_excerpt(lines, selected)
174
+ end
175
+
176
+ def matching_indexes(lines, terms)
177
+ indexes = []
178
+ lines.each_with_index do |line, index|
179
+ lower = line.downcase
180
+ indexes << index if terms.any? { |term| lower.include?(term) }
181
+ break if indexes.length >= MAX_MATCHES_PER_FILE
182
+ end
183
+ indexes
184
+ end
185
+
186
+ def render_excerpt(lines, selected)
187
+ output = ["### Matching excerpts"]
188
+ previous = nil
189
+ selected.each do |index|
190
+ output << "..." if previous && index > previous + 1
191
+ output << "%4d: %s" % [index + 1, lines[index]]
192
+ previous = index
193
+ end
194
+ output.join("\n")
195
+ end
196
+
197
+ def relative_path(path)
198
+ Pathname.new(path).relative_path_from(@workspace.root).to_s
199
+ end
200
+ end
201
+ end
202
+ end
@@ -11,11 +11,13 @@ module Kward
11
11
  @workspace = workspace
12
12
  super(
13
13
  "read_file",
14
- "Read a workspace text file. Output is capped; use offset/limit to continue.",
14
+ "Read a workspace text file. Output is capped; use offset/limit or mode to control context budget.",
15
15
  properties: {
16
16
  path: { type: "string", description: "Workspace-relative path." },
17
- offset: { type: "integer", description: "1-indexed start line." },
18
- limit: { type: "integer", description: "Maximum lines to return." }
17
+ limit: { type: "integer", description: "Maximum lines to return." },
18
+ max_bytes: { type: "integer", description: "Optional byte budget for this read, capped by Kward's workspace read limit." },
19
+ mode: { type: "string", enum: %w[preview outline range full], description: "Context mode. preview returns a short slice, outline returns source declarations, range reads offset/limit, full reads until Kward's cap." },
20
+ offset: { type: "integer", description: "1-indexed start line." }
19
21
  },
20
22
  required: ["path"]
21
23
  )
@@ -26,7 +28,9 @@ module Kward
26
28
  path = argument(args, :path, "")
27
29
  offset = argument(args, :offset)
28
30
  limit = argument(args, :limit)
29
- content = @workspace.read_file(path, offset: offset, limit: limit)
31
+ mode = argument(args, :mode)
32
+ max_bytes = argument(args, :max_bytes)
33
+ content = @workspace.read_file(path, offset: offset, limit: limit, mode: mode, max_bytes: max_bytes)
30
34
  conversation.mark_read(@workspace.resolved_path(path)) unless content.start_with?("Error:")
31
35
  content
32
36
  end