crimson-code 0.1.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/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/exe/crimson +207 -0
- data/lib/crimson/agent/event_emitter.rb +56 -0
- data/lib/crimson/agent/events.rb +43 -0
- data/lib/crimson/agent/steering.rb +91 -0
- data/lib/crimson/agent/tool_executor.rb +114 -0
- data/lib/crimson/agent.rb +564 -0
- data/lib/crimson/client/anthropic_adapter.rb +206 -0
- data/lib/crimson/client/base.rb +25 -0
- data/lib/crimson/client/factory.rb +27 -0
- data/lib/crimson/client/openai_adapter.rb +188 -0
- data/lib/crimson/compactor.rb +129 -0
- data/lib/crimson/config.rb +95 -0
- data/lib/crimson/cost_tracker.rb +62 -0
- data/lib/crimson/formatter.rb +93 -0
- data/lib/crimson/message.rb +177 -0
- data/lib/crimson/output_handler.rb +252 -0
- data/lib/crimson/project_context.rb +184 -0
- data/lib/crimson/providers.rb +49 -0
- data/lib/crimson/repl.rb +310 -0
- data/lib/crimson/retry_handler.rb +104 -0
- data/lib/crimson/session_entry.rb +145 -0
- data/lib/crimson/session_manager.rb +219 -0
- data/lib/crimson/setup.rb +134 -0
- data/lib/crimson/skill_router.rb +165 -0
- data/lib/crimson/token_counter.rb +84 -0
- data/lib/crimson/tool_registry.rb +112 -0
- data/lib/crimson/tools/diff_util.rb +44 -0
- data/lib/crimson/tools/edit_file.rb +145 -0
- data/lib/crimson/tools/file_mutation_queue.rb +30 -0
- data/lib/crimson/tools/glob.rb +49 -0
- data/lib/crimson/tools/index.rb +20 -0
- data/lib/crimson/tools/list_directory.rb +42 -0
- data/lib/crimson/tools/read_file.rb +92 -0
- data/lib/crimson/tools/run_command.rb +138 -0
- data/lib/crimson/tools/schema.rb +60 -0
- data/lib/crimson/tools/search_files.rb +107 -0
- data/lib/crimson/tools/truncator.rb +94 -0
- data/lib/crimson/tools/write_file.rb +53 -0
- data/lib/crimson/trust_manager.rb +102 -0
- data/lib/crimson/version.rb +6 -0
- data/lib/crimson.rb +55 -0
- data/skills/coding.md +49 -0
- data/skills/debugging.md +32 -0
- data/skills/git.md +37 -0
- data/skills/planning.md +56 -0
- data/skills/refactoring.md +37 -0
- data/skills/research.md +37 -0
- data/skills/review.md +37 -0
- data/skills/security.md +42 -0
- data/skills/testing.md +37 -0
- data/skills/writing.md +43 -0
- metadata +294 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
# Token counter using tiktoken when available, with fallback estimation.
|
|
5
|
+
class TokenCounter
|
|
6
|
+
# @param model [String, nil] model name for encoder selection
|
|
7
|
+
# @param provider [String, Symbol, nil] provider name
|
|
8
|
+
def initialize(model: nil, provider: nil)
|
|
9
|
+
@model = model
|
|
10
|
+
@provider = provider ? provider.to_sym : nil
|
|
11
|
+
@encoder = nil
|
|
12
|
+
@encoder_loaded = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Count tokens in a text string.
|
|
16
|
+
# @param text [String, nil]
|
|
17
|
+
# @return [Integer]
|
|
18
|
+
def count(text)
|
|
19
|
+
return 0 if text.nil? || text.empty?
|
|
20
|
+
encoder = load_encoder
|
|
21
|
+
return estimate(text) unless encoder
|
|
22
|
+
encoder.encode(text).length
|
|
23
|
+
rescue => e
|
|
24
|
+
estimate(text)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Count total tokens for an array of messages, including per-message overhead.
|
|
28
|
+
# @param messages [Array<Message::Base>]
|
|
29
|
+
# @return [Integer]
|
|
30
|
+
def count_messages(messages)
|
|
31
|
+
total = 0
|
|
32
|
+
messages.each do |msg|
|
|
33
|
+
total += 4
|
|
34
|
+
total += count(msg.content.to_s)
|
|
35
|
+
if msg.respond_to?(:tool_calls) && msg.tool_calls
|
|
36
|
+
msg.tool_calls.each do |tc|
|
|
37
|
+
total += count(tc.name.to_s)
|
|
38
|
+
total += count(tc.arguments.to_s)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
total
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# @api private
|
|
48
|
+
def load_encoder
|
|
49
|
+
return @encoder if @encoder_loaded
|
|
50
|
+
@encoder_loaded = true
|
|
51
|
+
|
|
52
|
+
require "tiktoken_ruby"
|
|
53
|
+
|
|
54
|
+
@encoder = if openai_sdk?
|
|
55
|
+
load_openai_encoder
|
|
56
|
+
else
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
rescue LoadError
|
|
60
|
+
@encoder = nil
|
|
61
|
+
rescue => e
|
|
62
|
+
@encoder = nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @api private
|
|
66
|
+
def openai_sdk?
|
|
67
|
+
return true if @provider.nil? && @model&.match?(/gpt|o1|o3|davinci|curie|babbage|ada/)
|
|
68
|
+
return false unless @provider
|
|
69
|
+
PROVIDERS.dig(@provider, :sdk) == :openai
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @api private
|
|
73
|
+
def load_openai_encoder
|
|
74
|
+
enc = @model ? ::Tiktoken.encoding_for_model(@model) : nil
|
|
75
|
+
enc || ::Tiktoken.get_encoding("cl100k_base")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fallback token estimation based on character count.
|
|
79
|
+
# @api private
|
|
80
|
+
def estimate(text)
|
|
81
|
+
(text.length / 4.0).ceil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Crimson
|
|
6
|
+
# Central registry for tool registration, execution, and schema generation.
|
|
7
|
+
class ToolRegistry
|
|
8
|
+
def initialize
|
|
9
|
+
@tools = {}
|
|
10
|
+
@openai_defs = nil
|
|
11
|
+
@anthropic_defs = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Register a tool module by its TOOL_NAME constant.
|
|
15
|
+
# @param tool_module [Module] a tool module with TOOL_NAME, .call, and .definition
|
|
16
|
+
# @return [void]
|
|
17
|
+
def register(tool_module)
|
|
18
|
+
name = tool_module.const_get(:TOOL_NAME)
|
|
19
|
+
@tools[name] = tool_module
|
|
20
|
+
@openai_defs = nil
|
|
21
|
+
@anthropic_defs = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Execute a tool by name with the given arguments.
|
|
25
|
+
# @param tool_name [String]
|
|
26
|
+
# @param arguments [Hash, String] argument hash or JSON string
|
|
27
|
+
# @param abort_signal [AbortSignal, nil]
|
|
28
|
+
# @return [String] tool result (or error message prefixed with "Error")
|
|
29
|
+
def execute(tool_name, arguments, abort_signal: nil)
|
|
30
|
+
tool = @tools[tool_name]
|
|
31
|
+
return "Error: Unknown tool '#{tool_name}'" unless tool
|
|
32
|
+
|
|
33
|
+
args = if arguments.is_a?(String)
|
|
34
|
+
JSON.parse(arguments, symbolize_names: true)
|
|
35
|
+
else
|
|
36
|
+
arguments.transform_keys(&:to_sym)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if tool.respond_to?(:prepare_arguments)
|
|
40
|
+
begin
|
|
41
|
+
prepared = tool.prepare_arguments(args.transform_keys(&:to_s))
|
|
42
|
+
args = prepared.transform_keys(&:to_sym)
|
|
43
|
+
rescue => e
|
|
44
|
+
return "Error preparing arguments for #{tool_name}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
result = if tool.respond_to?(:call_with_signal) && abort_signal
|
|
49
|
+
tool.call_with_signal(**args, signal: abort_signal)
|
|
50
|
+
else
|
|
51
|
+
tool.call(**args)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
result = apply_truncation(tool_name, result)
|
|
55
|
+
|
|
56
|
+
result
|
|
57
|
+
rescue JSON::ParserError
|
|
58
|
+
"Error: Invalid JSON arguments for #{tool_name}"
|
|
59
|
+
rescue ArgumentError => e
|
|
60
|
+
"Error: Wrong arguments for #{tool_name}: #{e.message}"
|
|
61
|
+
rescue => e
|
|
62
|
+
"Error executing #{tool_name}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Array<Hash>] OpenAI-compatible tool definitions
|
|
66
|
+
def openai_definitions
|
|
67
|
+
@openai_defs ||= @tools.values.map(&:definition)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Array<Hash>] Anthropic-compatible tool definitions
|
|
71
|
+
def anthropic_definitions
|
|
72
|
+
@anthropic_defs ||= @tools.values.map(&:anthropic_definition)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Look up a tool module by name.
|
|
76
|
+
# @param tool_name [String]
|
|
77
|
+
# @return [Module, nil]
|
|
78
|
+
def lookup(tool_name)
|
|
79
|
+
@tools[tool_name]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Array<String>] all registered tool names
|
|
83
|
+
def tool_names
|
|
84
|
+
@tools.keys
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Load all skill markdown files from a directory.
|
|
88
|
+
# @param skills_dir [String] directory path
|
|
89
|
+
# @return [String] concatenated skill content
|
|
90
|
+
def load_skills(skills_dir)
|
|
91
|
+
return "" unless Dir.exist?(skills_dir)
|
|
92
|
+
|
|
93
|
+
Dir.glob(File.join(skills_dir, "*.md")).sort.filter_map do |file|
|
|
94
|
+
File.read(file).strip
|
|
95
|
+
end.join("\n\n")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# @api private
|
|
101
|
+
def apply_truncation(tool_name, result)
|
|
102
|
+
return result unless result.is_a?(String)
|
|
103
|
+
return result if result.start_with?("Error")
|
|
104
|
+
return result if result.bytesize <= Tools::Truncator::DEFAULT_MAX_BYTES
|
|
105
|
+
|
|
106
|
+
truncation = Tools::Truncator.truncate(result)
|
|
107
|
+
output = truncation.text
|
|
108
|
+
output += "\n\n(full output saved to #{truncation.full_output_path})" if truncation.full_output_path
|
|
109
|
+
output
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "diff/lcs"
|
|
4
|
+
require "pastel"
|
|
5
|
+
|
|
6
|
+
module Crimson
|
|
7
|
+
module Tools
|
|
8
|
+
# Diff utility using the diff/lcs library with ANSI-colored output.
|
|
9
|
+
module DiffUtil
|
|
10
|
+
# Produce a colored unified-format diff between two texts.
|
|
11
|
+
# @param old_text [String] original content
|
|
12
|
+
# @param new_text [String] new content
|
|
13
|
+
# @param path [String] file path for header display
|
|
14
|
+
# @return [String] ANSI-colored diff output
|
|
15
|
+
def self.format_diff(old_text, new_text, path)
|
|
16
|
+
pastel = Pastel.new
|
|
17
|
+
old_lines = old_text.lines.map(&:chomp)
|
|
18
|
+
new_lines = new_text.lines.map(&:chomp)
|
|
19
|
+
|
|
20
|
+
changes = Diff::LCS.sdiff(old_lines, new_lines)
|
|
21
|
+
|
|
22
|
+
output = []
|
|
23
|
+
output << pastel.dim("--- #{path}")
|
|
24
|
+
output << pastel.dim("+++ #{path}")
|
|
25
|
+
|
|
26
|
+
changes.each do |change|
|
|
27
|
+
case change.action
|
|
28
|
+
when "-"
|
|
29
|
+
output << pastel.red("- #{change.old_element}")
|
|
30
|
+
when "+"
|
|
31
|
+
output << pastel.green("+ #{change.new_element}")
|
|
32
|
+
when "!"
|
|
33
|
+
output << pastel.red("- #{change.old_element}")
|
|
34
|
+
output << pastel.green("+ #{change.new_element}")
|
|
35
|
+
when "="
|
|
36
|
+
output << pastel.dim(" #{change.old_element}")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
output.join("\n")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
module Tools
|
|
5
|
+
# Edit files by replacing strings, with single and multi-edit modes.
|
|
6
|
+
# Handles BOM, CRLF/LF line endings, and produces diffs.
|
|
7
|
+
module EditFile
|
|
8
|
+
TOOL_NAME = "edit_file"
|
|
9
|
+
|
|
10
|
+
# Tool parameter definitions.
|
|
11
|
+
PARAMS = {
|
|
12
|
+
path: { type: "string", description: "The path to the file to edit" },
|
|
13
|
+
old_string: { type: "string", description: "The exact string to find and replace (single edit mode)" },
|
|
14
|
+
new_string: { type: "string", description: "The string to replace it with (single edit mode)" },
|
|
15
|
+
replace_all: { type: "boolean", description: "Replace all occurrences (default: false)" },
|
|
16
|
+
edits: {
|
|
17
|
+
type: "array",
|
|
18
|
+
description: "Array of edits for multiple replacements in one call. Each edit has old_string, new_string, and optional replace_all.",
|
|
19
|
+
items: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
old_string: { type: "string", description: "The exact string to find" },
|
|
23
|
+
new_string: { type: "string", description: "The replacement string" },
|
|
24
|
+
replace_all: { type: "boolean", description: "Replace all occurrences (default: false)" }
|
|
25
|
+
},
|
|
26
|
+
required: %w[old_string new_string]
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
MUTATION_QUEUE = FileMutationQueue.new
|
|
32
|
+
|
|
33
|
+
# @api private
|
|
34
|
+
def self.prepare_arguments(args)
|
|
35
|
+
if args["edits"].is_a?(Array)
|
|
36
|
+
args["edits"].each { |e| e["replace_all"] = !!e["replace_all"] if e.key?("replace_all") }
|
|
37
|
+
end
|
|
38
|
+
args["replace_all"] = !!args["replace_all"] if args.key?("replace_all")
|
|
39
|
+
args
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash] OpenAI-compatible tool definition
|
|
43
|
+
def self.definition
|
|
44
|
+
Schema.build(name: TOOL_NAME, description: "Replace strings in a file. Supports single edit or multiple edits in one call.", parameters: PARAMS, required: ["path"])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Hash] Anthropic-compatible tool definition
|
|
48
|
+
def self.anthropic_definition
|
|
49
|
+
Schema.build_anthropic(name: TOOL_NAME, description: "Replace strings in a file. Supports single edit or multiple edits in one call.", parameters: PARAMS, required: ["path"])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Execute the tool.
|
|
53
|
+
# @param path [String] file path
|
|
54
|
+
# @param old_string [String, nil] text to find
|
|
55
|
+
# @param new_string [String, nil] replacement text
|
|
56
|
+
# @param replace_all [Boolean] replace all occurrences
|
|
57
|
+
# @param edits [Array<Hash>, nil] multiple edits
|
|
58
|
+
# @return [String] result message with diff or error
|
|
59
|
+
def self.call(path:, old_string: nil, new_string: nil, replace_all: false, edits: nil)
|
|
60
|
+
return "Error: No path provided" if path.nil? || path.strip.empty?
|
|
61
|
+
|
|
62
|
+
expanded = File.expand_path(path)
|
|
63
|
+
|
|
64
|
+
MUTATION_QUEUE.with_file(expanded) do
|
|
65
|
+
return "Error: File not found: #{path}" unless File.exist?(expanded)
|
|
66
|
+
return "Error: Not a file: #{path}" unless File.file?(expanded)
|
|
67
|
+
|
|
68
|
+
content = File.binread(expanded)
|
|
69
|
+
has_bom = content.start_with?("\xEF\xBB\xBF")
|
|
70
|
+
content = content.byteslice(3..) if has_bom
|
|
71
|
+
content = content.force_encoding("UTF-8")
|
|
72
|
+
|
|
73
|
+
line_ending = detect_line_ending(content)
|
|
74
|
+
content = content.gsub("\r\n", "\n") if line_ending == :crlf
|
|
75
|
+
|
|
76
|
+
old_content = content.dup
|
|
77
|
+
|
|
78
|
+
if edits.is_a?(Array) && !edits.empty?
|
|
79
|
+
count = 0
|
|
80
|
+
edits.each do |e|
|
|
81
|
+
result = apply_edit(content, e["old_string"], e["new_string"], e["replace_all"])
|
|
82
|
+
return result[:error] if result[:error]
|
|
83
|
+
content = result[:content]
|
|
84
|
+
count += result[:count]
|
|
85
|
+
end
|
|
86
|
+
elsif old_string
|
|
87
|
+
return "Error: No old_string provided" if old_string.nil? || old_string.empty?
|
|
88
|
+
|
|
89
|
+
result = apply_edit(content, old_string, new_string, replace_all)
|
|
90
|
+
return result[:error] if result[:error]
|
|
91
|
+
|
|
92
|
+
content = result[:content]
|
|
93
|
+
count = result[:count]
|
|
94
|
+
else
|
|
95
|
+
return "Error: Provide either old_string/new_string or edits array"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
content = content.gsub("\n", "\r\n") if line_ending == :crlf
|
|
99
|
+
content = "\xEF\xBB\xBF#{content}" if has_bom
|
|
100
|
+
|
|
101
|
+
File.binwrite(expanded, content)
|
|
102
|
+
|
|
103
|
+
clean_old = old_content
|
|
104
|
+
clean_new = has_bom ? content.byteslice(3..) : content
|
|
105
|
+
diff = DiffUtil.format_diff(clean_old, clean_new, path)
|
|
106
|
+
"Successfully edited #{path} (#{count} replacement#{'s' if count != 1})\n#{diff}"
|
|
107
|
+
end
|
|
108
|
+
rescue => e
|
|
109
|
+
"Error editing file: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
class << self
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# @api private
|
|
116
|
+
def apply_edit(content, old_string, new_string, replace_all = false)
|
|
117
|
+
return { error: "Error: old_string not provided" } if old_string.nil? || old_string.empty?
|
|
118
|
+
return { error: "Error: new_string not provided" } if new_string.nil?
|
|
119
|
+
|
|
120
|
+
count = content.scan(Regexp.escape(old_string)).length
|
|
121
|
+
|
|
122
|
+
if count == 0
|
|
123
|
+
return { error: "Error: old_string not found in file. Make sure it matches exactly." }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if !replace_all && count > 1
|
|
127
|
+
return { error: "Error: old_string found #{count} times. It must be unique, or use replace_all: true." }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
new_content = replace_all ? content.gsub(old_string, new_string) : content.sub(old_string, new_string)
|
|
131
|
+
{ content: new_content, count: count }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @api private
|
|
135
|
+
def detect_line_ending(content)
|
|
136
|
+
crlf_pos = content.index("\r\n")
|
|
137
|
+
lf_pos = content.index("\n")
|
|
138
|
+
return :lf if lf_pos.nil?
|
|
139
|
+
return :lf if crlf_pos.nil?
|
|
140
|
+
crlf_pos < lf_pos ? :crlf : :lf
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
module Tools
|
|
5
|
+
# Per-file mutex queue to serialize write/edit operations on the same path.
|
|
6
|
+
class FileMutationQueue
|
|
7
|
+
def initialize
|
|
8
|
+
@queues = {}
|
|
9
|
+
@global_mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Execute a block with exclusive access to the given file.
|
|
13
|
+
# @param path [String] file path
|
|
14
|
+
# @yield block to run under the file's mutex
|
|
15
|
+
# @return [Object] the block's result
|
|
16
|
+
def with_file(path)
|
|
17
|
+
normalized = File.expand_path(path)
|
|
18
|
+
queue = @global_mutex.synchronize do
|
|
19
|
+
@queues[normalized] ||= Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
queue.synchronize { yield }
|
|
23
|
+
ensure
|
|
24
|
+
@global_mutex.synchronize do
|
|
25
|
+
@queues.delete(normalized) if queue && !queue.locked?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
module Tools
|
|
5
|
+
# Find files matching a glob pattern with configurable search root.
|
|
6
|
+
module Glob
|
|
7
|
+
TOOL_NAME = "glob"
|
|
8
|
+
|
|
9
|
+
# Tool parameter definitions.
|
|
10
|
+
PARAMS = {
|
|
11
|
+
pattern: { type: "string", description: "The glob pattern to match files against" },
|
|
12
|
+
path: { type: "string", description: "The directory to search in. Defaults to current directory." }
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
# @return [Hash] OpenAI-compatible tool definition
|
|
16
|
+
def self.definition
|
|
17
|
+
Schema.build(name: TOOL_NAME, description: "Find files matching a glob pattern (e.g. '**/*.rb', 'src/**/*.ts'). Returns sorted file paths.", parameters: PARAMS, required: ["pattern"])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Hash] Anthropic-compatible tool definition
|
|
21
|
+
def self.anthropic_definition
|
|
22
|
+
Schema.build_anthropic(name: TOOL_NAME, description: "Find files matching a glob pattern (e.g. '**/*.rb', 'src/**/*.ts'). Returns sorted file paths.", parameters: PARAMS, required: ["pattern"])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Execute the tool.
|
|
26
|
+
# @param pattern [String] glob pattern
|
|
27
|
+
# @param path [String] search root (default ".")
|
|
28
|
+
# @return [String] sorted file paths or error
|
|
29
|
+
def self.call(pattern:, path: ".")
|
|
30
|
+
return "Error: No pattern provided" if pattern.nil? || pattern.strip.empty?
|
|
31
|
+
|
|
32
|
+
expanded = File.expand_path(path)
|
|
33
|
+
return "Error: Directory not found: #{path}" unless Dir.exist?(expanded)
|
|
34
|
+
|
|
35
|
+
files = Dir.glob(File.join(expanded, pattern)).sort
|
|
36
|
+
|
|
37
|
+
if files.empty?
|
|
38
|
+
"No files found matching pattern: #{pattern}"
|
|
39
|
+
elsif files.length > 200
|
|
40
|
+
"#{files.first(200).join("\n")}\n... (truncated, #{files.length - 200} more files)"
|
|
41
|
+
else
|
|
42
|
+
files.join("\n")
|
|
43
|
+
end
|
|
44
|
+
rescue => e
|
|
45
|
+
"Error searching files: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "schema"
|
|
4
|
+
require_relative "diff_util"
|
|
5
|
+
require_relative "truncator"
|
|
6
|
+
require_relative "file_mutation_queue"
|
|
7
|
+
require_relative "read_file"
|
|
8
|
+
require_relative "write_file"
|
|
9
|
+
require_relative "edit_file"
|
|
10
|
+
require_relative "list_directory"
|
|
11
|
+
require_relative "run_command"
|
|
12
|
+
require_relative "search_files"
|
|
13
|
+
require_relative "glob"
|
|
14
|
+
|
|
15
|
+
module Crimson
|
|
16
|
+
module Tools
|
|
17
|
+
# All built-in tool modules available for registration.
|
|
18
|
+
ALL = [ReadFile, WriteFile, EditFile, ListDirectory, RunCommand, SearchFiles, Glob].freeze
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
module Tools
|
|
5
|
+
# List files and directories at a given path, with trailing / for directories.
|
|
6
|
+
module ListDirectory
|
|
7
|
+
TOOL_NAME = "list_directory"
|
|
8
|
+
|
|
9
|
+
# Tool parameter definitions.
|
|
10
|
+
PARAMS = {
|
|
11
|
+
path: { type: "string", description: "The directory path to list. Defaults to current directory." }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# @return [Hash] OpenAI-compatible tool definition
|
|
15
|
+
def self.definition
|
|
16
|
+
Schema.build(name: TOOL_NAME, description: "List files and directories at the given path.", parameters: PARAMS, required: ["path"])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Hash] Anthropic-compatible tool definition
|
|
20
|
+
def self.anthropic_definition
|
|
21
|
+
Schema.build_anthropic(name: TOOL_NAME, description: "List files and directories at the given path.", parameters: PARAMS, required: ["path"])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Execute the tool.
|
|
25
|
+
# @param path [String] directory path (defaults to ".")
|
|
26
|
+
# @return [String] sorted listing or error
|
|
27
|
+
def self.call(path: ".")
|
|
28
|
+
expanded = File.expand_path(path)
|
|
29
|
+
return "Error: Directory not found: #{path}" unless Dir.exist?(expanded)
|
|
30
|
+
|
|
31
|
+
entries = Dir.entries(expanded).sort - [".", ".."]
|
|
32
|
+
|
|
33
|
+
entries.map do |entry|
|
|
34
|
+
full_path = File.join(expanded, entry)
|
|
35
|
+
File.directory?(full_path) ? "#{entry}/" : entry
|
|
36
|
+
end.join("\n")
|
|
37
|
+
rescue => e
|
|
38
|
+
"Error listing directory: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
module Tools
|
|
5
|
+
# Read file contents with optional offset/limit for large files.
|
|
6
|
+
# Detects image and binary files and returns metadata instead of content.
|
|
7
|
+
module ReadFile
|
|
8
|
+
TOOL_NAME = "read_file"
|
|
9
|
+
|
|
10
|
+
# Extensions treated as viewable images.
|
|
11
|
+
IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .bmp .ico .svg .tiff .tif].freeze
|
|
12
|
+
# Extensions treated as binary (not readable as text).
|
|
13
|
+
BINARY_EXTENSIONS = %w[.zip .tar .gz .bz2 .xz .7z .rar .exe .dll .so .dylib .o .a .class .jar .wasm .pdf .doc .docx .xls .xlsx .ppt .pptx .woff .woff2 .ttf .eot .otf .mp3 .mp4 .avi .mov .mkv .flac .ogg .wav].freeze
|
|
14
|
+
|
|
15
|
+
# Tool parameter definitions.
|
|
16
|
+
PARAMS = {
|
|
17
|
+
path: { type: "string", description: "The path to the file to read" },
|
|
18
|
+
offset: { type: "integer", description: "Line number to start reading from (1-indexed). Defaults to 1." },
|
|
19
|
+
limit: { type: "integer", description: "Maximum number of lines to read. Defaults to all lines." }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# @api private
|
|
23
|
+
def self.prepare_arguments(args)
|
|
24
|
+
args["offset"] = args["offset"].to_i if args["offset"]
|
|
25
|
+
args["limit"] = args["limit"].to_i if args["limit"]
|
|
26
|
+
args
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Hash] OpenAI-compatible tool definition
|
|
30
|
+
def self.definition
|
|
31
|
+
Schema.build(name: TOOL_NAME, description: "Read the contents of a file. Supports offset/limit for reading portions of large files.", parameters: PARAMS, required: ["path"])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Hash] Anthropic-compatible tool definition
|
|
35
|
+
def self.anthropic_definition
|
|
36
|
+
Schema.build_anthropic(name: TOOL_NAME, description: "Read the contents of a file. Supports offset/limit for reading portions of large files.", parameters: PARAMS, required: ["path"])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Execute the tool.
|
|
40
|
+
# @param path [String] file path
|
|
41
|
+
# @param offset [Integer, nil] starting line (1-indexed)
|
|
42
|
+
# @param limit [Integer, nil] max lines to read
|
|
43
|
+
# @return [String] file content or error message
|
|
44
|
+
def self.call(path:, offset: nil, limit: nil)
|
|
45
|
+
return "Error: No path provided" if path.nil? || path.strip.empty?
|
|
46
|
+
|
|
47
|
+
expanded = File.expand_path(path)
|
|
48
|
+
return "Error: File not found: #{path}" unless File.exist?(expanded)
|
|
49
|
+
return "Error: Not a file: #{path}" unless File.file?(expanded)
|
|
50
|
+
|
|
51
|
+
ext = File.extname(expanded).downcase
|
|
52
|
+
return describe_image(expanded, ext) if IMAGE_EXTENSIONS.include?(ext)
|
|
53
|
+
return describe_binary(expanded, ext) if BINARY_EXTENSIONS.include?(ext)
|
|
54
|
+
|
|
55
|
+
content = File.read(expanded)
|
|
56
|
+
lines = content.lines
|
|
57
|
+
|
|
58
|
+
if offset || limit
|
|
59
|
+
start_line = [(offset || 1) - 1, 0].max
|
|
60
|
+
end_line = limit ? start_line + limit : lines.length
|
|
61
|
+
end_line = [end_line, lines.length].min
|
|
62
|
+
total = lines.length
|
|
63
|
+
|
|
64
|
+
selected = lines[start_line...end_line]
|
|
65
|
+
numbered = selected.each_with_index.map do |line, i|
|
|
66
|
+
"#{start_line + i + 1}: #{line}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
"(lines #{start_line + 1}-#{end_line} of #{total})\n#{numbered.join}"
|
|
70
|
+
else
|
|
71
|
+
content
|
|
72
|
+
end
|
|
73
|
+
rescue => e
|
|
74
|
+
"Error reading file: #{e.message}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @api private
|
|
78
|
+
def self.describe_image(path, ext)
|
|
79
|
+
size = File.size(path)
|
|
80
|
+
size_str = size > 1_048_576 ? "#{(size / 1_048_576.0).round(1)}MB" : "#{(size / 1024.0).round(1)}KB"
|
|
81
|
+
"Image file: #{File.basename(path)} (#{ext}, #{size_str}). Image reading not yet supported."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @api private
|
|
85
|
+
def self.describe_binary(path, ext)
|
|
86
|
+
size = File.size(path)
|
|
87
|
+
size_str = size > 1_048_576 ? "#{(size / 1_048_576.0).round(1)}MB" : "#{(size / 1024.0).round(1)}KB"
|
|
88
|
+
"Binary file: #{File.basename(path)} (#{ext}, #{size_str}). Cannot display binary content."
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|