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,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
@@ -24,8 +24,8 @@ module Kward
24
24
  description: @description,
25
25
  parameters: {
26
26
  type: "object",
27
- properties: @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)
@@ -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: content
111
+ content: model_content
95
112
  )
96
113
  conversation.append_tool_execution(tool_call: tool_call, content: content)
97
114
 
98
- content
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
 
@@ -0,0 +1,71 @@
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
+ # Retrieves original tool outputs that were compacted out of model context.
8
+ class RetrieveToolOutput < Base
9
+ DEFAULT_LIMIT = 120
10
+
11
+ # Builds the retrieval tool schema.
12
+ def initialize
13
+ super(
14
+ "retrieve_tool_output",
15
+ "Retrieve original output from a compacted previous tool result.",
16
+ properties: {
17
+ id: { type: "string", description: "Tool output id, for example toolout_abc123." },
18
+ query: { type: "string", description: "Optional case-insensitive text search within the original output." },
19
+ offset: { type: "integer", description: "1-indexed line offset for returned output." },
20
+ limit: { type: "integer", description: "Maximum lines to return; default 120." }
21
+ },
22
+ required: ["id"]
23
+ )
24
+ end
25
+
26
+ # Executes retrieval from the active conversation artifact store.
27
+ def call(args, conversation, cancellation: nil)
28
+ cancellation&.raise_if_cancelled!
29
+ id = argument(args, :id, "").to_s
30
+ return "Error: id is required" if id.empty?
31
+
32
+ artifact = conversation.tool_output_artifacts[id]
33
+ return "Error: unknown tool output id: #{id}" unless artifact
34
+
35
+ content = artifact[:content].to_s
36
+ query = argument(args, :query, "").to_s
37
+ lines = query.empty? ? content.split("\n", -1) : matching_lines(content, query)
38
+ return "No matching lines for #{query.inspect} in #{id}." if lines.empty?
39
+
40
+ slice_lines(id, lines, offset: argument(args, :offset), limit: argument(args, :limit), query: query)
41
+ end
42
+
43
+ private
44
+
45
+ def matching_lines(content, query)
46
+ needle = query.downcase
47
+ content.split("\n", -1).each_with_index.filter_map do |line, index|
48
+ next unless line.downcase.include?(needle)
49
+
50
+ "#{index + 1}: #{line}"
51
+ end
52
+ end
53
+
54
+ def slice_lines(id, lines, offset:, limit:, query:)
55
+ start_index = [offset.to_i - 1, 0].max
56
+ return "Error: offset #{offset} is beyond output (#{lines.length} lines total)" if start_index >= lines.length
57
+
58
+ line_limit = limit.to_i.positive? ? limit.to_i : DEFAULT_LIMIT
59
+ selected = lines[start_index, line_limit] || []
60
+ header = "[Retrieved tool output #{id}"
61
+ header << " matching #{query.inspect}" unless query.empty?
62
+ header << ": lines #{start_index + 1}-#{start_index + selected.length} of #{lines.length}]"
63
+ output = "#{header}\n#{selected.join("\n")}".rstrip
64
+ if start_index + selected.length < lines.length
65
+ output << "\n\n[#{lines.length - start_index - selected.length} more lines. Use offset=#{start_index + selected.length + 1} to continue.]"
66
+ end
67
+ output
68
+ end
69
+ end
70
+ end
71
+ end
@@ -619,8 +619,8 @@ module Kward
619
619
  return web_config[snake] if web_config.key?(snake)
620
620
  return web_config[camel] if web_config.key?(camel)
621
621
  return config[prefixed] if config.key?(prefixed)
622
- return config[snake] if config.key?(snake)
623
- return config[camel] if config.key?(camel)
622
+ return config[snake] if key.to_s != "provider" && config.key?(snake)
623
+ return config[camel] if key.to_s != "provider" && config.key?(camel)
624
624
 
625
625
  nil
626
626
  end
@@ -0,0 +1,29 @@
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
+ # Returns a compact symbol outline for a workspace source file.
8
+ class SummarizeFileStructure < Base
9
+ # Builds the tool schema and stores the execution dependency.
10
+ def initialize(workspace:)
11
+ @workspace = workspace
12
+ super(
13
+ "summarize_file_structure",
14
+ "Return a compact outline of classes, modules, methods, and functions in a workspace source file.",
15
+ properties: {
16
+ path: { type: "string", description: "Workspace-relative source file path." }
17
+ },
18
+ required: ["path"]
19
+ )
20
+ end
21
+
22
+ # Executes the structure summary tool.
23
+ def call(args, _conversation, cancellation: nil)
24
+ cancellation&.raise_if_cancelled!
25
+ @workspace.summarize_file_structure(argument(args, :path, ""))
26
+ end
27
+ end
28
+ end
29
+ end
@@ -17,6 +17,8 @@ module Kward
17
17
  "run_shell_command" => "bash",
18
18
  "list_directory" => "list_directory",
19
19
  "code_search" => "code_search",
20
+ "summarize_file_structure" => "summarize_file_structure",
21
+ "retrieve_tool_output" => "retrieve_tool_output",
20
22
  "web_search" => "web_search",
21
23
  "fetch_content" => "fetch_content",
22
24
  "fetch_raw" => "fetch_raw",
data/lib/kward/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # Namespace for the Kward CLI agent runtime.
2
2
  module Kward
3
3
  # Current gem version.
4
- VERSION = "0.69.1"
4
+ VERSION = "0.71.0"
5
5
  end
@@ -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
 
@@ -64,7 +64,27 @@ 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
+ large_file_outline_response(path, content, offset: offset, limit: limit) || read_file_slice(content, offset: offset, limit: limit)
68
+ rescue SecurityError, Errno::ENOENT => e
69
+ "Error: #{e.message}"
70
+ end
71
+
72
+ # Returns a compact outline of recognizable source-code declarations.
73
+ def summarize_file_structure(path)
74
+ resolved = workspace_path(path)
75
+ return "Error: not a file: #{path}" unless File.file?(resolved)
76
+
77
+ size = File.size(resolved)
78
+ return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
79
+
80
+ content = File.read(resolved)
81
+ return "Error: not a text file: #{path}" if binary_content?(content)
82
+
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")
68
88
  rescue SecurityError, Errno::ENOENT => e
69
89
  "Error: #{e.message}"
70
90
  end
@@ -196,6 +216,42 @@ module Kward
196
216
  Pathname.new(path).relative_path_from(@root).to_s
197
217
  end
198
218
 
219
+ def large_file_outline_response(path, content, offset:, limit:)
220
+ return nil unless offset.nil? && limit.nil?
221
+ lines = content.split("\n", -1)
222
+ return nil unless lines.length > @max_read_output_lines || content.bytesize > @max_read_output_bytes
223
+
224
+ outline = source_outline(lines)
225
+ return nil if outline.empty?
226
+
227
+ preview_limit = [120, @max_read_output_lines].min
228
+ preview = lines.first(preview_limit).join("\n")
229
+ [
230
+ "File has #{lines.length} lines (#{content.bytesize} bytes). Showing an outline and the first #{preview_limit} lines to reduce model context.",
231
+ "",
232
+ "Outline:",
233
+ outline.join("\n"),
234
+ "",
235
+ "First #{preview_limit} lines:",
236
+ preview,
237
+ "",
238
+ "[Use read_file with offset=#{preview_limit + 1} and limit to continue, or request a specific section from the outline.]"
239
+ ].join("\n")
240
+ end
241
+
242
+ def source_outline(lines)
243
+ outline = []
244
+ 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+/)
247
+
248
+ indent = line[/\A\s*/].to_s.length
249
+ outline << "line #{index + 1}: #{' ' * [indent / 2, 6].min}#{stripped}"
250
+ break if outline.length >= 80
251
+ end
252
+ outline
253
+ end
254
+
199
255
  def read_file_slice(content, offset:, limit:)
200
256
  lines = content.split("\n", -1)
201
257
  lines = [""] if lines.empty?