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.
- checksums.yaml +4 -4
- data/.github/workflows/pages.yml +1 -1
- data/CHANGELOG.md +68 -0
- data/Gemfile +2 -0
- data/Gemfile.lock +90 -2
- data/README.md +30 -6
- data/Rakefile +96 -0
- data/doc/agent-tools.md +43 -0
- data/doc/api.md +92 -0
- data/doc/authentication.md +39 -25
- data/doc/configuration.md +2 -16
- data/doc/context-tools.md +70 -0
- data/doc/getting-started.md +3 -1
- data/doc/plugins.md +2 -2
- data/doc/releasing.md +14 -5
- data/doc/rpc.md +3 -11
- data/doc/session-management.md +220 -0
- data/doc/usage.md +13 -7
- data/doc/workspace-tools.md +105 -0
- data/lib/kward/cli/commands.rb +8 -0
- data/lib/kward/cli/openrouter_commands.rb +55 -0
- data/lib/kward/cli/prompt_interface.rb +85 -7
- data/lib/kward/cli/rendering.rb +11 -6
- data/lib/kward/cli/sessions.rb +454 -15
- data/lib/kward/cli/settings.rb +0 -30
- data/lib/kward/cli/slash_commands.rb +38 -11
- data/lib/kward/cli.rb +14 -0
- data/lib/kward/compactor.rb +4 -1
- data/lib/kward/config_files.rb +4 -6
- data/lib/kward/conversation.rb +49 -5
- data/lib/kward/model/client.rb +37 -50
- data/lib/kward/model/context_usage.rb +13 -6
- data/lib/kward/model/model_info.rb +92 -9
- data/lib/kward/model/payloads.rb +2 -0
- data/lib/kward/openrouter_model_cache.rb +120 -0
- data/lib/kward/plugin_registry.rb +47 -1
- data/lib/kward/prompt_interface/banner.rb +16 -51
- data/lib/kward/prompt_interface/composer_controller.rb +60 -87
- data/lib/kward/prompt_interface/composer_renderer.rb +7 -1
- data/lib/kward/prompt_interface/key_handler.rb +31 -10
- data/lib/kward/prompt_interface/layout.rb +2 -2
- data/lib/kward/prompt_interface/overlay_renderer.rb +24 -0
- data/lib/kward/prompt_interface/prompt_renderer.rb +23 -2
- data/lib/kward/prompt_interface/question_prompt.rb +34 -42
- data/lib/kward/prompt_interface/runtime_state.rb +6 -1
- data/lib/kward/prompt_interface/screen.rb +10 -4
- data/lib/kward/prompt_interface/selection_prompt.rb +518 -61
- data/lib/kward/prompt_interface/slash_overlay.rb +4 -4
- data/lib/kward/prompt_interface/transcript_buffer.rb +7 -16
- data/lib/kward/prompt_interface/transcript_renderer.rb +3 -3
- data/lib/kward/prompt_interface.rb +31 -32
- data/lib/kward/prompts/commands.rb +6 -3
- data/lib/kward/prompts.rb +2 -2
- data/lib/kward/rpc/server.rb +3 -8
- data/lib/kward/rpc/session_manager.rb +19 -8
- data/lib/kward/session_diff.rb +106 -9
- data/lib/kward/session_store.rb +23 -4
- data/lib/kward/session_tree_renderer.rb +2 -1
- data/lib/kward/telemetry/logger.rb +5 -3
- data/lib/kward/tool_output_compactor.rb +127 -0
- data/lib/kward/tools/base.rb +8 -2
- data/lib/kward/tools/registry.rb +37 -6
- data/lib/kward/tools/retrieve_tool_output.rb +71 -0
- data/lib/kward/tools/search/web.rb +2 -2
- data/lib/kward/tools/summarize_file_structure.rb +29 -0
- data/lib/kward/tools/tool_call.rb +2 -0
- data/lib/kward/version.rb +1 -1
- data/lib/kward/workspace.rb +58 -2
- data/templates/default/fulldoc/html/css/kward.css +570 -78
- data/templates/default/fulldoc/html/full_list.erb +107 -0
- data/templates/default/fulldoc/html/js/kward.js +259 -97
- data/templates/default/fulldoc/html/setup.rb +8 -0
- data/templates/default/kward_navigation.rb +91 -0
- data/templates/default/layout/html/layout.erb +59 -13
- data/templates/default/layout/html/setup.rb +34 -39
- metadata +13 -3
- data/lib/kward/resources/avatar_kward_logo.rb +0 -50
- 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
|
data/lib/kward/tools/base.rb
CHANGED
|
@@ -24,8 +24,8 @@ module Kward
|
|
|
24
24
|
description: @description,
|
|
25
25
|
parameters: {
|
|
26
26
|
type: "object",
|
|
27
|
-
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)
|
data/lib/kward/tools/registry.rb
CHANGED
|
@@ -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:
|
|
111
|
+
content: model_content
|
|
95
112
|
)
|
|
96
113
|
conversation.append_tool_execution(tool_call: tool_call, content: content)
|
|
97
114
|
|
|
98
|
-
|
|
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
data/lib/kward/workspace.rb
CHANGED
|
@@ -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 =
|
|
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?
|