kward 0.66.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +90 -0
- data/LICENSE +21 -0
- data/README.md +101 -0
- data/Rakefile +20 -0
- data/doc/authentication.md +105 -0
- data/doc/code-search.md +56 -0
- data/doc/configuration.md +310 -0
- data/doc/extensibility.md +186 -0
- data/doc/getting-started.md +127 -0
- data/doc/memory.md +192 -0
- data/doc/plugins.md +223 -0
- data/doc/releasing.md +36 -0
- data/doc/rpc.md +635 -0
- data/doc/usage.md +179 -0
- data/doc/web-search.md +28 -0
- data/exe/kward +5 -0
- data/kward.gemspec +33 -0
- data/lib/kward/agent.rb +234 -0
- data/lib/kward/ansi.rb +276 -0
- data/lib/kward/auth/file.rb +11 -0
- data/lib/kward/auth/github_oauth.rb +222 -0
- data/lib/kward/auth/openai_oauth.rb +323 -0
- data/lib/kward/auth/openrouter_api_key.rb +40 -0
- data/lib/kward/cancellation.rb +54 -0
- data/lib/kward/cli.rb +2122 -0
- data/lib/kward/clipboard.rb +84 -0
- data/lib/kward/compactor.rb +998 -0
- data/lib/kward/config_files.rb +564 -0
- data/lib/kward/conversation.rb +148 -0
- data/lib/kward/events.rb +13 -0
- data/lib/kward/export_path.rb +28 -0
- data/lib/kward/image_attachments.rb +331 -0
- data/lib/kward/markdown_transcript.rb +72 -0
- data/lib/kward/memory/manager.rb +652 -0
- data/lib/kward/message_access.rb +42 -0
- data/lib/kward/model/chat_invocation.rb +23 -0
- data/lib/kward/model/client.rb +875 -0
- data/lib/kward/model/context_overflow.rb +55 -0
- data/lib/kward/model/context_usage.rb +104 -0
- data/lib/kward/model/model_info.rb +188 -0
- data/lib/kward/model/retry_message.rb +11 -0
- data/lib/kward/model/stream_parser.rb +205 -0
- data/lib/kward/pan/index.html.erb +143 -0
- data/lib/kward/pan/server.rb +397 -0
- data/lib/kward/plugin_registry.rb +327 -0
- data/lib/kward/private_file.rb +18 -0
- data/lib/kward/prompt_interface.rb +2437 -0
- data/lib/kward/prompts/commands.rb +50 -0
- data/lib/kward/prompts/templates.rb +60 -0
- data/lib/kward/prompts.rb +58 -0
- data/lib/kward/resources/avatar_kward_logo.rb +48 -0
- data/lib/kward/resources/pixel_logo.rb +230 -0
- data/lib/kward/rpc/auth_manager.rb +265 -0
- data/lib/kward/rpc/config_manager.rb +58 -0
- data/lib/kward/rpc/prompt_bridge.rb +104 -0
- data/lib/kward/rpc/redactor.rb +47 -0
- data/lib/kward/rpc/server.rb +639 -0
- data/lib/kward/rpc/session_manager.rb +1122 -0
- data/lib/kward/rpc/tool_event_normalizer.rb +68 -0
- data/lib/kward/rpc/tool_metadata.rb +80 -0
- data/lib/kward/rpc/transcript_normalizer.rb +307 -0
- data/lib/kward/rpc/transport.rb +58 -0
- data/lib/kward/session_diff.rb +125 -0
- data/lib/kward/session_store.rb +493 -0
- data/lib/kward/skills/registry.rb +76 -0
- data/lib/kward/starter_pack_installer.rb +110 -0
- data/lib/kward/steering.rb +56 -0
- data/lib/kward/telemetry/logger.rb +195 -0
- data/lib/kward/telemetry/stats.rb +466 -0
- data/lib/kward/tools/ask_user_question.rb +107 -0
- data/lib/kward/tools/base.rb +45 -0
- data/lib/kward/tools/code_search.rb +65 -0
- data/lib/kward/tools/edit_file.rb +41 -0
- data/lib/kward/tools/list_directory.rb +21 -0
- data/lib/kward/tools/read_file.rb +30 -0
- data/lib/kward/tools/read_skill.rb +27 -0
- data/lib/kward/tools/registry.rb +117 -0
- data/lib/kward/tools/run_shell_command.rb +28 -0
- data/lib/kward/tools/search/code.rb +445 -0
- data/lib/kward/tools/search/web.rb +747 -0
- data/lib/kward/tools/tool_call.rb +87 -0
- data/lib/kward/tools/web_search.rb +48 -0
- data/lib/kward/tools/write_file.rb +29 -0
- data/lib/kward/transcript_export.rb +40 -0
- data/lib/kward/version.rb +4 -0
- data/lib/kward/workspace.rb +377 -0
- data/lib/kward.rb +6 -0
- data/lib/main.rb +3 -0
- metadata +232 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
module ToolCall
|
|
5
|
+
TOOL_NAME_MAP = {
|
|
6
|
+
"read_file" => "read",
|
|
7
|
+
"edit_file" => "edit",
|
|
8
|
+
"write_file" => "write",
|
|
9
|
+
"run_shell_command" => "bash",
|
|
10
|
+
"list_directory" => "list_directory",
|
|
11
|
+
"web_search" => "web_search",
|
|
12
|
+
"read_skill" => "read_skill",
|
|
13
|
+
"ask_user_question" => "ask_user_question"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def id(tool_call)
|
|
19
|
+
value(tool_call, :id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def name(tool_call)
|
|
23
|
+
value(function(tool_call), :name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def display_name(tool_call)
|
|
27
|
+
raw_name = name(tool_call)
|
|
28
|
+
normalized_name(raw_name) || raw_name || "unknown_tool"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def arguments(tool_call)
|
|
32
|
+
parse_arguments(raw_arguments(tool_call))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def raw_arguments(tool_call)
|
|
36
|
+
value(function(tool_call), :arguments)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def function(tool_call)
|
|
40
|
+
value(tool_call, :function) || {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalized_name(name)
|
|
44
|
+
TOOL_NAME_MAP[name.to_s]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parse_arguments(arguments)
|
|
48
|
+
return {} if arguments.nil? || (arguments.respond_to?(:empty?) && arguments.empty?)
|
|
49
|
+
return arguments if arguments.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
JSON.parse(arguments.to_s)
|
|
52
|
+
rescue JSON::ParserError
|
|
53
|
+
{}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def camelize_args(args)
|
|
57
|
+
return {} unless args.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
args.each_with_object({}) do |(key, item), result|
|
|
60
|
+
result[camelize_key(key)] = camelize_value(item)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def value(object, key)
|
|
65
|
+
return nil unless object.respond_to?(:key?)
|
|
66
|
+
return object[key] if object.key?(key)
|
|
67
|
+
return object[key.to_s] if object.key?(key.to_s)
|
|
68
|
+
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def camelize_value(item)
|
|
73
|
+
case item
|
|
74
|
+
when Hash
|
|
75
|
+
camelize_args(item)
|
|
76
|
+
when Array
|
|
77
|
+
item.map { |entry| camelize_value(entry) }
|
|
78
|
+
else
|
|
79
|
+
item
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def camelize_key(key)
|
|
84
|
+
key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }.to_sym
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
module Tools
|
|
5
|
+
class WebSearch < Base
|
|
6
|
+
def initialize(web_search:)
|
|
7
|
+
@web_search = web_search
|
|
8
|
+
super(
|
|
9
|
+
"web_search",
|
|
10
|
+
"Search the live web with bounded results.",
|
|
11
|
+
properties: {
|
|
12
|
+
queries: {
|
|
13
|
+
type: "array",
|
|
14
|
+
description: "1-4 distinct queries; avoid near-duplicates.",
|
|
15
|
+
items: { type: "string" },
|
|
16
|
+
minItems: 1,
|
|
17
|
+
maxItems: 4
|
|
18
|
+
},
|
|
19
|
+
max_results: {
|
|
20
|
+
type: "integer",
|
|
21
|
+
description: "Results per query; default 5, max 20."
|
|
22
|
+
},
|
|
23
|
+
provider: {
|
|
24
|
+
type: "string",
|
|
25
|
+
enum: %w[auto exa perplexity gemini legacy duckduckgo],
|
|
26
|
+
description: "Provider override; default auto."
|
|
27
|
+
},
|
|
28
|
+
recency_filter: {
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: %w[day week month year],
|
|
31
|
+
description: "Recency filter."
|
|
32
|
+
},
|
|
33
|
+
domain_filter: {
|
|
34
|
+
type: "array",
|
|
35
|
+
description: "Domains to include; prefix '-' to exclude.",
|
|
36
|
+
items: { type: "string" }
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
required: ["queries"]
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(args, _conversation, cancellation: nil)
|
|
44
|
+
@web_search.search(args)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require_relative "base"
|
|
2
|
+
|
|
3
|
+
module Kward
|
|
4
|
+
module Tools
|
|
5
|
+
class WriteFile < Base
|
|
6
|
+
def initialize(workspace:)
|
|
7
|
+
@workspace = workspace
|
|
8
|
+
super(
|
|
9
|
+
"write_file",
|
|
10
|
+
"Write a workspace file. Existing files must be read first.",
|
|
11
|
+
properties: {
|
|
12
|
+
path: { type: "string", description: "Workspace-relative path." },
|
|
13
|
+
content: { type: "string", description: "Complete file content." }
|
|
14
|
+
},
|
|
15
|
+
required: ["path", "content"]
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(args, conversation, cancellation: nil)
|
|
20
|
+
path = argument(args, :path, "")
|
|
21
|
+
content = argument(args, :content, "")
|
|
22
|
+
|
|
23
|
+
result = @workspace.write_file(path, content, read_paths: conversation.read_paths)
|
|
24
|
+
conversation.refresh_system_message! if agents_file_changed?(@workspace, path, result)
|
|
25
|
+
result
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require_relative "markdown_transcript"
|
|
3
|
+
|
|
4
|
+
module Kward
|
|
5
|
+
class TranscriptExport
|
|
6
|
+
SUPPORTED_FORMATS = ["markdown", "html"].freeze
|
|
7
|
+
|
|
8
|
+
def self.format(value)
|
|
9
|
+
format = value.to_s.strip.downcase
|
|
10
|
+
format = "markdown" if format.empty? || format == "md"
|
|
11
|
+
raise ArgumentError, "Unsupported export format: #{value}" unless SUPPORTED_FORMATS.include?(format)
|
|
12
|
+
|
|
13
|
+
format
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.content(conversation, format: "markdown")
|
|
17
|
+
markdown = MarkdownTranscript.new(conversation).render
|
|
18
|
+
return markdown if format(format) == "markdown"
|
|
19
|
+
|
|
20
|
+
html(markdown)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.html(markdown)
|
|
24
|
+
escaped = CGI.escapeHTML(markdown)
|
|
25
|
+
<<~HTML
|
|
26
|
+
<!doctype html>
|
|
27
|
+
<html>
|
|
28
|
+
<head>
|
|
29
|
+
<meta charset="utf-8">
|
|
30
|
+
<title>Kward Session</title>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<pre>#{escaped}</pre>
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
HTML
|
|
37
|
+
end
|
|
38
|
+
private_class_method :html
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "pathname"
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "session_diff"
|
|
5
|
+
|
|
6
|
+
module Kward
|
|
7
|
+
class Workspace
|
|
8
|
+
MAX_FILE_BYTES = 256 * 1024
|
|
9
|
+
MAX_READ_OUTPUT_BYTES = 50 * 1024
|
|
10
|
+
MAX_READ_OUTPUT_LINES = 2_000
|
|
11
|
+
MAX_COMMAND_OUTPUT_BYTES = 20 * 1024
|
|
12
|
+
MAX_EDIT_DIFF_BYTES = 8 * 1024
|
|
13
|
+
DEFAULT_COMMAND_TIMEOUT_SECONDS = 30
|
|
14
|
+
|
|
15
|
+
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)
|
|
16
|
+
@root = Pathname.new(root).realpath
|
|
17
|
+
@max_file_bytes = max_file_bytes
|
|
18
|
+
@max_read_output_bytes = max_read_output_bytes
|
|
19
|
+
@max_read_output_lines = max_read_output_lines
|
|
20
|
+
@max_command_output_bytes = max_command_output_bytes
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :root
|
|
24
|
+
|
|
25
|
+
def list_directory(path)
|
|
26
|
+
resolved = workspace_path(path)
|
|
27
|
+
return "Error: not a directory: #{path}" unless File.directory?(resolved)
|
|
28
|
+
|
|
29
|
+
Dir.children(resolved).sort.map do |entry|
|
|
30
|
+
File.directory?(File.join(resolved, entry)) ? "#{entry}/" : entry
|
|
31
|
+
end.join("\n")
|
|
32
|
+
rescue SecurityError, Errno::ENOENT => e
|
|
33
|
+
"Error: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def read_file(path, offset: nil, limit: nil)
|
|
37
|
+
resolved = workspace_path(path)
|
|
38
|
+
return "Error: not a file: #{path}" unless File.file?(resolved)
|
|
39
|
+
|
|
40
|
+
size = File.size(resolved)
|
|
41
|
+
return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
|
|
42
|
+
|
|
43
|
+
read_file_slice(File.read(resolved), offset: offset, limit: limit)
|
|
44
|
+
rescue SecurityError, Errno::ENOENT => e
|
|
45
|
+
"Error: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def write_file(path, content, read_paths:)
|
|
49
|
+
resolved = workspace_write_path(path)
|
|
50
|
+
|
|
51
|
+
if File.exist?(resolved) && !read_paths.include?(resolved)
|
|
52
|
+
return "Error: existing file must be read before writing: #{path}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if block_given? && !yield(relative_path(resolved), content.bytesize)
|
|
56
|
+
return "Declined: write_file was not approved for #{path}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
old_content = File.exist?(resolved) ? File.read(resolved) : nil
|
|
60
|
+
File.write(resolved, content)
|
|
61
|
+
output = "Wrote #{content.bytesize} bytes to #{path}"
|
|
62
|
+
output << "\n#{truncated_diff(path, old_content, content)}" if old_content && old_content != content
|
|
63
|
+
output
|
|
64
|
+
rescue SecurityError, Errno::ENOENT => e
|
|
65
|
+
"Error: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def edit_file(path, edits, read_paths:)
|
|
69
|
+
resolved = workspace_path(path)
|
|
70
|
+
return "Error: not a file: #{path}" unless File.file?(resolved)
|
|
71
|
+
return "Error: existing file must be read before editing: #{path}" unless read_paths.include?(resolved)
|
|
72
|
+
|
|
73
|
+
size = File.size(resolved)
|
|
74
|
+
return "Error: file too large: #{path} is #{size} bytes; limit is #{@max_file_bytes} bytes" if size > @max_file_bytes
|
|
75
|
+
|
|
76
|
+
content = File.read(resolved)
|
|
77
|
+
result = apply_edits(path, content, edits)
|
|
78
|
+
return result[:error] if result[:error]
|
|
79
|
+
|
|
80
|
+
File.write(resolved, result[:content])
|
|
81
|
+
"Edited #{path}: replaced #{result[:count]} block(s)\n#{truncated_diff(path, content, result[:content])}"
|
|
82
|
+
rescue SecurityError, Errno::ENOENT => e
|
|
83
|
+
"Error: #{e.message}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def run_shell_command(command, timeout_seconds: DEFAULT_COMMAND_TIMEOUT_SECONDS, cancellation: nil)
|
|
87
|
+
command = command.to_s.strip
|
|
88
|
+
return "Error: command is required" if command.empty?
|
|
89
|
+
|
|
90
|
+
timeout_seconds = timeout_seconds.to_i
|
|
91
|
+
timeout_seconds = DEFAULT_COMMAND_TIMEOUT_SECONDS if timeout_seconds <= 0
|
|
92
|
+
cancellation&.raise_if_cancelled!
|
|
93
|
+
|
|
94
|
+
Open3.popen3(command, chdir: @root.to_s) do |stdin, stdout, stderr, wait_thread|
|
|
95
|
+
stdin.close
|
|
96
|
+
stdout_reader = Thread.new { stdout.read }
|
|
97
|
+
stderr_reader = Thread.new { stderr.read }
|
|
98
|
+
cancellation&.on_cancel { terminate_process(wait_thread.pid) }
|
|
99
|
+
status = wait_for_process(wait_thread, timeout_seconds, cancellation)
|
|
100
|
+
|
|
101
|
+
output = +"Exit status: #{status.exitstatus}\n"
|
|
102
|
+
output << "\nSTDOUT:\n#{stdout_reader.value}" unless stdout_reader.value.empty?
|
|
103
|
+
output << "\nSTDERR:\n#{stderr_reader.value}" unless stderr_reader.value.empty?
|
|
104
|
+
truncate_output(output)
|
|
105
|
+
rescue Timeout::Error
|
|
106
|
+
terminate_process(wait_thread.pid)
|
|
107
|
+
"Error: command timed out after #{timeout_seconds} seconds"
|
|
108
|
+
ensure
|
|
109
|
+
stdout_reader&.kill if stdout_reader&.alive?
|
|
110
|
+
stderr_reader&.kill if stderr_reader&.alive?
|
|
111
|
+
end
|
|
112
|
+
rescue Errno::ENOENT, ArgumentError => e
|
|
113
|
+
"Error: #{e.message}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def resolved_path(path)
|
|
117
|
+
workspace_path(path)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def workspace_path(path)
|
|
123
|
+
target = Pathname.new(path.to_s)
|
|
124
|
+
target = @root.join(target) unless target.absolute?
|
|
125
|
+
|
|
126
|
+
expanded = target.expand_path
|
|
127
|
+
raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(expanded)
|
|
128
|
+
|
|
129
|
+
resolved = target.realpath
|
|
130
|
+
raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(resolved)
|
|
131
|
+
|
|
132
|
+
resolved.to_s
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def workspace_write_path(path)
|
|
136
|
+
target = Pathname.new(path.to_s)
|
|
137
|
+
target = @root.join(target) unless target.absolute?
|
|
138
|
+
|
|
139
|
+
expanded = target.expand_path
|
|
140
|
+
raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(expanded)
|
|
141
|
+
|
|
142
|
+
return workspace_path(path) if File.exist?(expanded) || File.symlink?(expanded)
|
|
143
|
+
|
|
144
|
+
parent = expanded.dirname.realpath
|
|
145
|
+
raise SecurityError, "path outside workspace: #{path}" unless inside_workspace?(parent)
|
|
146
|
+
|
|
147
|
+
expanded.to_s
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def inside_workspace?(path)
|
|
151
|
+
path.to_s == @root.to_s || path.to_s.start_with?("#{@root}/")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def relative_path(path)
|
|
155
|
+
Pathname.new(path).relative_path_from(@root).to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def read_file_slice(content, offset:, limit:)
|
|
159
|
+
lines = content.split("\n", -1)
|
|
160
|
+
lines = [""] if lines.empty?
|
|
161
|
+
start_index = read_start_index(offset)
|
|
162
|
+
return "Error: offset #{offset} is beyond end of file (#{lines.length} lines total)" if start_index >= lines.length
|
|
163
|
+
|
|
164
|
+
user_limit = read_limit(limit)
|
|
165
|
+
return user_limit if user_limit.is_a?(String)
|
|
166
|
+
|
|
167
|
+
selected_end = user_limit ? [start_index + user_limit, lines.length].min : lines.length
|
|
168
|
+
selected_lines = lines[start_index...selected_end]
|
|
169
|
+
truncated = truncate_read_lines(selected_lines)
|
|
170
|
+
return truncated[:error] if truncated[:error]
|
|
171
|
+
|
|
172
|
+
output = truncated[:content]
|
|
173
|
+
if truncated[:truncated]
|
|
174
|
+
output << read_truncation_notice(
|
|
175
|
+
start_index: start_index,
|
|
176
|
+
output_lines: truncated[:line_count],
|
|
177
|
+
total_lines: lines.length,
|
|
178
|
+
truncated_by: truncated[:truncated_by]
|
|
179
|
+
)
|
|
180
|
+
elsif user_limit && selected_end < lines.length
|
|
181
|
+
output << "\n\n[#{lines.length - selected_end} more lines in file. Use offset=#{selected_end + 1} to continue.]"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
output
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def read_start_index(offset)
|
|
188
|
+
return 0 if offset.nil?
|
|
189
|
+
|
|
190
|
+
[offset.to_i - 1, 0].max
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def read_limit(limit)
|
|
194
|
+
return nil if limit.nil?
|
|
195
|
+
|
|
196
|
+
value = limit.to_i
|
|
197
|
+
return "Error: limit must be positive" unless value.positive?
|
|
198
|
+
|
|
199
|
+
value
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def truncate_read_lines(lines)
|
|
203
|
+
first_line = lines.first.to_s
|
|
204
|
+
if first_line.bytesize > @max_read_output_bytes
|
|
205
|
+
return {
|
|
206
|
+
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."
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
output_lines = []
|
|
211
|
+
bytes = 0
|
|
212
|
+
truncated_by = nil
|
|
213
|
+
lines.each do |line|
|
|
214
|
+
if output_lines.length >= @max_read_output_lines
|
|
215
|
+
truncated_by = "lines"
|
|
216
|
+
break
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
separator_bytes = output_lines.empty? ? 0 : 1
|
|
220
|
+
next_bytes = line.bytesize + separator_bytes
|
|
221
|
+
if bytes + next_bytes > @max_read_output_bytes
|
|
222
|
+
truncated_by = "bytes"
|
|
223
|
+
break
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
output_lines << line
|
|
227
|
+
bytes += next_bytes
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
{
|
|
231
|
+
content: output_lines.join("\n"),
|
|
232
|
+
line_count: output_lines.length,
|
|
233
|
+
truncated: output_lines.length < lines.length,
|
|
234
|
+
truncated_by: truncated_by
|
|
235
|
+
}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def read_truncation_notice(start_index:, output_lines:, total_lines:, truncated_by:)
|
|
239
|
+
end_line = start_index + output_lines
|
|
240
|
+
next_offset = end_line + 1
|
|
241
|
+
detail = truncated_by == "lines" ? "#{@max_read_output_lines} line limit" : "#{@max_read_output_bytes} byte limit"
|
|
242
|
+
"\n\n[Showing lines #{start_index + 1}-#{end_line} of #{total_lines} (#{detail}). Use offset=#{next_offset} to continue.]"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def apply_edits(path, content, edits)
|
|
246
|
+
return { error: "Error: edits must contain at least one replacement" } unless edits.is_a?(Array) && !edits.empty?
|
|
247
|
+
|
|
248
|
+
replacements = []
|
|
249
|
+
edits.each_with_index do |edit, index|
|
|
250
|
+
old_text = edit_value(edit, "old_text")
|
|
251
|
+
new_text = edit_value(edit, "new_text")
|
|
252
|
+
return { error: "Error: edits[#{index}].old_text must be a string" } unless old_text.is_a?(String)
|
|
253
|
+
return { error: "Error: edits[#{index}].new_text must be a string" } unless new_text.is_a?(String)
|
|
254
|
+
return { error: "Error: edits[#{index}].old_text must not be empty" } if old_text.empty?
|
|
255
|
+
|
|
256
|
+
matches = match_indexes(content, old_text)
|
|
257
|
+
return { error: "Error: edits[#{index}].old_text was not found in #{path}" } if matches.empty?
|
|
258
|
+
if matches.length > 1
|
|
259
|
+
return { error: "Error: edits[#{index}].old_text appears #{matches.length} times in #{path}; provide more context" }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
replacements << { index: index, start: matches.first, length: old_text.length, new_text: new_text }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
replacements.sort_by! { |replacement| replacement[:start] }
|
|
266
|
+
replacements.each_cons(2) do |left, right|
|
|
267
|
+
if left[:start] + left[:length] > right[:start]
|
|
268
|
+
return { error: "Error: edits[#{left[:index]}] and edits[#{right[:index]}] overlap in #{path}" }
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
new_content = content.dup
|
|
273
|
+
replacements.reverse_each do |replacement|
|
|
274
|
+
new_content[replacement[:start], replacement[:length]] = replacement[:new_text]
|
|
275
|
+
end
|
|
276
|
+
return { error: "Error: no changes made to #{path}" } if new_content == content
|
|
277
|
+
|
|
278
|
+
{ content: new_content, count: replacements.length }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def edit_value(edit, key)
|
|
282
|
+
return nil unless edit.is_a?(Hash)
|
|
283
|
+
|
|
284
|
+
edit[key] || edit[key.to_sym]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def match_indexes(content, needle)
|
|
288
|
+
indexes = []
|
|
289
|
+
offset = 0
|
|
290
|
+
while (index = content.index(needle, offset))
|
|
291
|
+
indexes << index
|
|
292
|
+
offset = index + needle.length
|
|
293
|
+
end
|
|
294
|
+
indexes
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def truncated_diff(path, old_content, new_content)
|
|
298
|
+
diff = unified_diff(path, old_content, new_content)
|
|
299
|
+
return diff if diff.bytesize <= MAX_EDIT_DIFF_BYTES
|
|
300
|
+
|
|
301
|
+
counts = SessionDiff.count(diff)
|
|
302
|
+
diff.byteslice(0, MAX_EDIT_DIFF_BYTES).to_s.scrub << "\n... diff truncated to #{MAX_EDIT_DIFF_BYTES} bytes; full diff stats: +#{counts[:additions]}|-#{counts[:deletions]}. Use read_file to inspect current content."
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def unified_diff(path, old_content, new_content)
|
|
306
|
+
old_lines = old_content.lines(chomp: true)
|
|
307
|
+
new_lines = new_content.lines(chomp: true)
|
|
308
|
+
prefix = 0
|
|
309
|
+
prefix += 1 while prefix < old_lines.length && prefix < new_lines.length && old_lines[prefix] == new_lines[prefix]
|
|
310
|
+
|
|
311
|
+
old_suffix = old_lines.length - 1
|
|
312
|
+
new_suffix = new_lines.length - 1
|
|
313
|
+
while old_suffix >= prefix && new_suffix >= prefix && old_lines[old_suffix] == new_lines[new_suffix]
|
|
314
|
+
old_suffix -= 1
|
|
315
|
+
new_suffix -= 1
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
context_start = [prefix - 3, 0].max
|
|
319
|
+
old_context_end = [old_suffix + 3, old_lines.length - 1].min
|
|
320
|
+
new_context_end = [new_suffix + 3, new_lines.length - 1].min
|
|
321
|
+
old_hunk_length = old_context_end >= context_start ? old_context_end - context_start + 1 : 0
|
|
322
|
+
new_hunk_length = new_context_end >= context_start ? new_context_end - context_start + 1 : 0
|
|
323
|
+
|
|
324
|
+
lines = ["--- #{path}", "+++ #{path}", "@@ -#{context_start + 1},#{old_hunk_length} +#{context_start + 1},#{new_hunk_length} @@"]
|
|
325
|
+
old_lines[context_start...prefix].to_a.each { |line| lines << " #{line}" }
|
|
326
|
+
old_lines[prefix..old_suffix].to_a.each { |line| lines << "-#{line}" }
|
|
327
|
+
new_lines[prefix..new_suffix].to_a.each { |line| lines << "+#{line}" }
|
|
328
|
+
old_lines[(old_suffix + 1)..old_context_end].to_a.each { |line| lines << " #{line}" }
|
|
329
|
+
lines.join("\n")
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def truncate_output(output)
|
|
333
|
+
return output if output.bytesize <= @max_command_output_bytes
|
|
334
|
+
|
|
335
|
+
output.byteslice(0, @max_command_output_bytes) << "\n... truncated to #{@max_command_output_bytes} bytes"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def wait_for_process(wait_thread, timeout_seconds, cancellation)
|
|
339
|
+
deadline = Time.now + timeout_seconds
|
|
340
|
+
loop do
|
|
341
|
+
cancellation&.raise_if_cancelled!
|
|
342
|
+
if wait_thread.join(0.05)
|
|
343
|
+
cancellation&.raise_if_cancelled!
|
|
344
|
+
return wait_thread.value
|
|
345
|
+
end
|
|
346
|
+
raise Timeout::Error if Time.now >= deadline
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def terminate_process(pid)
|
|
351
|
+
return unless signal_process("TERM", pid)
|
|
352
|
+
|
|
353
|
+
deadline = Time.now + 0.2
|
|
354
|
+
while Time.now < deadline
|
|
355
|
+
return unless process_running?(pid)
|
|
356
|
+
|
|
357
|
+
sleep 0.02
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
signal_process("KILL", pid)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def process_running?(pid)
|
|
364
|
+
Process.kill(0, pid)
|
|
365
|
+
true
|
|
366
|
+
rescue Errno::ESRCH
|
|
367
|
+
false
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def signal_process(signal, pid)
|
|
371
|
+
Process.kill(signal, pid)
|
|
372
|
+
true
|
|
373
|
+
rescue Errno::ESRCH
|
|
374
|
+
false
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
data/lib/kward.rb
ADDED
data/lib/main.rb
ADDED