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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +150 -0
  4. data/exe/crimson +207 -0
  5. data/lib/crimson/agent/event_emitter.rb +56 -0
  6. data/lib/crimson/agent/events.rb +43 -0
  7. data/lib/crimson/agent/steering.rb +91 -0
  8. data/lib/crimson/agent/tool_executor.rb +114 -0
  9. data/lib/crimson/agent.rb +564 -0
  10. data/lib/crimson/client/anthropic_adapter.rb +206 -0
  11. data/lib/crimson/client/base.rb +25 -0
  12. data/lib/crimson/client/factory.rb +27 -0
  13. data/lib/crimson/client/openai_adapter.rb +188 -0
  14. data/lib/crimson/compactor.rb +129 -0
  15. data/lib/crimson/config.rb +95 -0
  16. data/lib/crimson/cost_tracker.rb +62 -0
  17. data/lib/crimson/formatter.rb +93 -0
  18. data/lib/crimson/message.rb +177 -0
  19. data/lib/crimson/output_handler.rb +252 -0
  20. data/lib/crimson/project_context.rb +184 -0
  21. data/lib/crimson/providers.rb +49 -0
  22. data/lib/crimson/repl.rb +310 -0
  23. data/lib/crimson/retry_handler.rb +104 -0
  24. data/lib/crimson/session_entry.rb +145 -0
  25. data/lib/crimson/session_manager.rb +219 -0
  26. data/lib/crimson/setup.rb +134 -0
  27. data/lib/crimson/skill_router.rb +165 -0
  28. data/lib/crimson/token_counter.rb +84 -0
  29. data/lib/crimson/tool_registry.rb +112 -0
  30. data/lib/crimson/tools/diff_util.rb +44 -0
  31. data/lib/crimson/tools/edit_file.rb +145 -0
  32. data/lib/crimson/tools/file_mutation_queue.rb +30 -0
  33. data/lib/crimson/tools/glob.rb +49 -0
  34. data/lib/crimson/tools/index.rb +20 -0
  35. data/lib/crimson/tools/list_directory.rb +42 -0
  36. data/lib/crimson/tools/read_file.rb +92 -0
  37. data/lib/crimson/tools/run_command.rb +138 -0
  38. data/lib/crimson/tools/schema.rb +60 -0
  39. data/lib/crimson/tools/search_files.rb +107 -0
  40. data/lib/crimson/tools/truncator.rb +94 -0
  41. data/lib/crimson/tools/write_file.rb +53 -0
  42. data/lib/crimson/trust_manager.rb +102 -0
  43. data/lib/crimson/version.rb +6 -0
  44. data/lib/crimson.rb +55 -0
  45. data/skills/coding.md +49 -0
  46. data/skills/debugging.md +32 -0
  47. data/skills/git.md +37 -0
  48. data/skills/planning.md +56 -0
  49. data/skills/refactoring.md +37 -0
  50. data/skills/research.md +37 -0
  51. data/skills/review.md +37 -0
  52. data/skills/security.md +42 -0
  53. data/skills/testing.md +37 -0
  54. data/skills/writing.md +43 -0
  55. metadata +294 -0
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Crimson
6
+ # Auto-detects project language, framework, package manager, and testing tools.
7
+ # Also loads project context files (AGENTS.md, CLAUDE.md, etc.) from directory tree.
8
+ class ProjectContext
9
+ # File names considered as project instruction files.
10
+ CONTEXT_FILE_NAMES = %w[
11
+ AGENTS.md AGENTS.MD
12
+ CLAUDE.md CLAUDE.MD
13
+ GEMINI.md GEMINI.MD
14
+ ].freeze
15
+
16
+ # Detect project context (language, framework, package manager, git status).
17
+ # @param root_dir [String] project root directory
18
+ # @return [String] formatted context string
19
+ def self.detect(root_dir = Dir.pwd)
20
+ context = []
21
+ context << "Working directory: #{root_dir}"
22
+ context << "OS: #{RUBY_PLATFORM}"
23
+
24
+ lang = detect_language(root_dir)
25
+ context << "Language: #{lang}" if lang
26
+
27
+ framework = detect_framework(root_dir)
28
+ context << "Framework: #{framework}" if framework
29
+
30
+ pkg = detect_package_manager(root_dir)
31
+ context << "Package manager: #{pkg}" if pkg
32
+
33
+ testing = detect_testing(root_dir)
34
+ context << "Testing: #{testing}" if testing
35
+
36
+ git = detect_git(root_dir)
37
+ context << "Git: #{git}" if git
38
+
39
+ context.join("\n")
40
+ end
41
+
42
+ # Load project context files (AGENTS.md, CLAUDE.md, GEMINI.md) walking up to git root.
43
+ # @param root_dir [String] starting directory
44
+ # @return [Array<Hash>] array of { path:, content: } hashes
45
+ def self.load_context_files(root_dir = Dir.pwd)
46
+ files = []
47
+ seen_paths = Set.new
48
+ dir = File.expand_path(root_dir)
49
+
50
+ loop do
51
+ CONTEXT_FILE_NAMES.each do |name|
52
+ path = File.join(dir, name)
53
+ next unless File.exist?(path)
54
+ real = File.realpath(path) rescue File.expand_path(path)
55
+ next if seen_paths.include?(real)
56
+
57
+ seen_paths.add(real)
58
+ files << { path: path, content: File.read(path) }
59
+ end
60
+
61
+ break if git_root?(dir)
62
+ parent = File.dirname(dir)
63
+ break if parent == dir
64
+ dir = parent
65
+ end
66
+
67
+ global = File.join(Crimson::CONFIG_DIR, "AGENTS.md")
68
+ if File.exist?(global)
69
+ real = File.realpath(global) rescue File.expand_path(global)
70
+ unless seen_paths.include?(real)
71
+ files << { path: global, content: File.read(global) }
72
+ end
73
+ end
74
+
75
+ files
76
+ end
77
+
78
+ # Format context files into an XML-like string for the system prompt.
79
+ # @param files [Array<Hash>, nil] array of { path:, content: }
80
+ # @return [String]
81
+ def self.format_context_files(files)
82
+ return "" if files.nil? || files.empty?
83
+
84
+ parts = ["<project_context>", "", "Project-specific instructions and guidelines:", ""]
85
+ files.each do |f|
86
+ parts << "<project_instructions path=\"#{f[:path]}\">"
87
+ parts << f[:content]
88
+ parts << "</project_instructions>"
89
+ parts << ""
90
+ end
91
+ parts << "</project_context>"
92
+ parts.join("\n")
93
+ end
94
+
95
+ # @api private
96
+ def self.git_root?(dir)
97
+ Dir.exist?(File.join(dir, ".git"))
98
+ end
99
+
100
+ # @api private
101
+ def self.detect_language(root_dir)
102
+ indicators = {
103
+ "Ruby" => ["Gemfile", "*.rb", "*.gemspec"],
104
+ "Python" => ["requirements.txt", "pyproject.toml", "*.py", "Pipfile"],
105
+ "TypeScript" => ["tsconfig.json", "*.ts", "*.tsx"],
106
+ "JavaScript" => ["package.json", "*.js", "*.jsx"],
107
+ "Go" => ["go.mod", "*.go"],
108
+ "Rust" => ["Cargo.toml", "*.rs"],
109
+ "Java" => ["pom.xml", "build.gradle", "*.java"],
110
+ "Elixir" => ["mix.exs", "*.ex"],
111
+ }
112
+
113
+ indicators.each do |lang, patterns|
114
+ patterns.each do |pattern|
115
+ return lang if Dir.glob(File.join(root_dir, pattern)).any?
116
+ end
117
+ end
118
+
119
+ nil
120
+ end
121
+
122
+ # @api private
123
+ def self.detect_framework(root_dir)
124
+ return "Rails" if File.exist?(File.join(root_dir, "bin", "rails"))
125
+ return "Sinatra" if gem_in_gemfile?(root_dir, "sinatra")
126
+ return "Hanami" if gem_in_gemfile?(root_dir, "hanami")
127
+ return "Next.js" if file_has_dep?(root_dir, "package.json", "next")
128
+ return "React" if file_has_dep?(root_dir, "package.json", "react")
129
+ return "Vue" if file_has_dep?(root_dir, "package.json", "vue")
130
+ return "Express" if file_has_dep?(root_dir, "package.json", "express")
131
+ return "Django" if File.exist?(File.join(root_dir, "manage.py"))
132
+ return "Flask" if file_has_dep?(root_dir, "requirements.txt", "flask")
133
+ nil
134
+ end
135
+
136
+ # @api private
137
+ def self.detect_package_manager(root_dir)
138
+ return "bundler" if File.exist?(File.join(root_dir, "Gemfile"))
139
+ return "npm" if File.exist?(File.join(root_dir, "package-lock.json"))
140
+ return "yarn" if File.exist?(File.join(root_dir, "yarn.lock"))
141
+ return "pnpm" if File.exist?(File.join(root_dir, "pnpm-lock.yaml"))
142
+ return "pip" if File.exist?(File.join(root_dir, "requirements.txt"))
143
+ return "cargo" if File.exist?(File.join(root_dir, "Cargo.toml"))
144
+ return "go modules" if File.exist?(File.join(root_dir, "go.mod"))
145
+ nil
146
+ end
147
+
148
+ # @api private
149
+ def self.detect_testing(root_dir)
150
+ return "RSpec" if File.exist?(File.join(root_dir, ".rspec")) || gem_in_gemfile?(root_dir, "rspec")
151
+ return "Minitest" if Dir.glob(File.join(root_dir, "test/**/*_test.rb")).any?
152
+ return "Jest" if file_has_dep?(root_dir, "package.json", "jest")
153
+ return "pytest" if File.exist?(File.join(root_dir, "pytest.ini"))
154
+ return "Go testing" if Dir.glob(File.join(root_dir, "**/*_test.go")).any?
155
+ nil
156
+ end
157
+
158
+ # @api private
159
+ def self.detect_git(root_dir)
160
+ return nil unless Dir.exist?(File.join(root_dir, ".git"))
161
+
162
+ branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
163
+ return nil if branch.empty?
164
+
165
+ dirty = !`git status --porcelain 2>/dev/null`.strip.empty?
166
+ status = dirty ? "#{branch} (dirty)" : "#{branch} (clean)"
167
+ status
168
+ end
169
+
170
+ # @api private
171
+ def self.gem_in_gemfile?(root_dir, gem_name)
172
+ gemfile = File.join(root_dir, "Gemfile")
173
+ return false unless File.exist?(gemfile)
174
+ File.read(gemfile).include?(gem_name)
175
+ end
176
+
177
+ # @api private
178
+ def self.file_has_dep?(root_dir, filename, dep_name)
179
+ path = File.join(root_dir, filename)
180
+ return false unless File.exist?(path)
181
+ File.read(path).include?(dep_name)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ # @!parse
5
+ # PROVIDERS = Hash[Symbol, Hash]
6
+ # Provider definitions including base URLs, SDK type, and auth header builders.
7
+ # Each entry maps a provider symbol to its configuration hash.
8
+ PROVIDERS = {
9
+ openai: {
10
+ name: "OpenAI",
11
+ base_url: "https://api.openai.com/v1",
12
+ sdk: :openai,
13
+ auth_headers: ->(key) { { "Authorization" => "Bearer #{key}" } }
14
+ },
15
+ anthropic: {
16
+ name: "Anthropic",
17
+ base_url: "https://api.anthropic.com/v1",
18
+ sdk: :anthropic,
19
+ auth_headers: ->(key) { { "x-api-key" => key, "anthropic-version" => "2023-06-01" } }
20
+ },
21
+ openrouter: {
22
+ name: "OpenRouter",
23
+ base_url: "https://openrouter.ai/api/v1",
24
+ sdk: :openai,
25
+ auth_headers: ->(key) { { "Authorization" => "Bearer #{key}" } }
26
+ },
27
+ mistral: {
28
+ name: "Mistral",
29
+ base_url: "https://api.mistral.ai/v1",
30
+ sdk: :openai,
31
+ auth_headers: ->(key) { { "Authorization" => "Bearer #{key}" } }
32
+ },
33
+ xai: {
34
+ name: "xAI (Grok)",
35
+ base_url: "https://api.x.ai/v1",
36
+ sdk: :openai,
37
+ auth_headers: ->(key) { { "Authorization" => "Bearer #{key}" } }
38
+ },
39
+ custom: {
40
+ name: "Custom (OpenAI-compatible)",
41
+ base_url: nil,
42
+ sdk: :openai,
43
+ auth_headers: ->(key) { { "Authorization" => "Bearer #{key}" } }
44
+ }
45
+ }
46
+
47
+ # Endpoint path suffix appended to provider base URLs to fetch available models.
48
+ MODELS_ENDPOINT = "/models"
49
+ end
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+ require "pastel"
5
+
6
+ module Crimson
7
+ # Interactive REPL with readline support, slash commands, and session management.
8
+ class Repl
9
+ # @param agent [Agent]
10
+ def initialize(agent)
11
+ @agent = agent
12
+ @pastel = Pastel.new
13
+ @output_handler = OutputHandler.new
14
+ @output_handler.attach(agent)
15
+ setup_readline
16
+ end
17
+
18
+ # Start the REPL event loop.
19
+ # @return [void]
20
+ def start
21
+ puts @pastel.bold("Crimson v#{VERSION}")
22
+ puts @pastel.dim("Type /help for commands, /exit to quit")
23
+ puts
24
+
25
+ loop do
26
+ input = Reline.readline("> ", true)
27
+
28
+ break if input.nil?
29
+ input = input.strip
30
+ break if input == "/exit" || input == "/quit"
31
+ next if input.empty?
32
+
33
+ if input.start_with?("/")
34
+ handle_command(input)
35
+ else
36
+ @agent.prompt(input)
37
+ end
38
+ rescue => e
39
+ puts @pastel.red("Error: #{e.message}")
40
+ end
41
+
42
+ puts @pastel.dim("Goodbye!")
43
+ end
44
+
45
+ private
46
+
47
+ # @api private
48
+ def handle_command(input)
49
+ case input
50
+ when "/help"
51
+ puts @pastel.bold("Commands:")
52
+ puts " /help Show help message"
53
+ puts " /clear Clear conversation history"
54
+ puts " /model Switch model (interactive selector)"
55
+ puts " /thinking Set thinking level (off/low/medium/high)"
56
+ puts " /tools List available tools"
57
+ puts " /save Save conversation to file"
58
+ puts " /load Load conversation from file"
59
+ puts " /usage Show token usage and cost"
60
+ puts " /sessions List sessions for current directory"
61
+ puts " /name Set session name"
62
+ puts " /session Show session info"
63
+ puts " /fork Fork current session into new branch"
64
+ puts " /tree Show conversation tree"
65
+ puts " /compact Compact conversation history"
66
+ puts " /exit Exit crimson"
67
+ when "/clear"
68
+ @agent.reset
69
+ puts @pastel.dim("Conversation cleared.")
70
+ when "/model"
71
+ handle_model_switch
72
+ when "/thinking"
73
+ handle_thinking
74
+ when "/tools"
75
+ puts @pastel.bold("Available tools:")
76
+ @agent.tool_registry.tool_names.each do |name|
77
+ puts " - #{name}"
78
+ end
79
+ when "/save"
80
+ puts @agent.save_history
81
+ when "/load"
82
+ puts @agent.load_history
83
+ when "/usage"
84
+ usage = @agent.token_usage
85
+ cost = @agent.cost_tracker.total_cost
86
+ puts @pastel.bold("Token usage:")
87
+ puts " Prompt: #{usage[:prompt]}"
88
+ puts " Completion: #{usage[:completion]}"
89
+ puts " Total: #{usage[:total]}"
90
+ puts " Cost: $#{format('%.4f', cost)}" if cost > 0
91
+ when "/sessions"
92
+ handle_sessions
93
+ when "/name"
94
+ handle_name
95
+ when "/session"
96
+ handle_session_info
97
+ when "/fork"
98
+ handle_fork
99
+ when "/tree"
100
+ handle_tree
101
+ when "/compact"
102
+ if @agent.compactor
103
+ result = @agent.compact!
104
+ puts @pastel.dim(result)
105
+ else
106
+ puts @pastel.yellow("Compaction not enabled.")
107
+ end
108
+ else
109
+ if input.start_with?("/name ")
110
+ handle_name_set(input[6..].strip)
111
+ else
112
+ puts @pastel.yellow("Unknown command: #{input}. Type /help for commands.")
113
+ end
114
+ end
115
+ end
116
+
117
+ # @api private
118
+ def handle_sessions
119
+ return puts(@pastel.dim("No active session.")) unless @agent.session_id
120
+
121
+ manager = SessionManager.new
122
+ sessions = manager.list(cwd: Dir.pwd)
123
+ if sessions.empty?
124
+ puts @pastel.dim("No sessions found.")
125
+ else
126
+ puts @pastel.bold("Sessions:")
127
+ sessions.each do |s|
128
+ current = s.id == @agent.session_id ? " (current)" : ""
129
+ name_str = s.name ? "[#{s.name}] " : ""
130
+ preview = s.preview || "(no preview)"
131
+ puts " #{@pastel.cyan(s.id[0..7])} #{name_str}#{preview} #{s.last_timestamp}#{current}"
132
+ end
133
+ end
134
+ end
135
+
136
+ # @api private
137
+ def handle_model_switch
138
+ config = @agent.config || Crimson.config
139
+ puts @pastel.dim("Current: #{PROVIDERS[config.provider.to_sym][:name]} / #{config.model}")
140
+ puts
141
+
142
+ begin
143
+ prompt = TTY::Prompt.new
144
+ models = fetch_available_models(config)
145
+ if models.empty?
146
+ puts @pastel.yellow("Could not fetch model list. Showing current model only.")
147
+ return
148
+ end
149
+
150
+ selected = prompt.select("Select model:", models.map { |m| { name: m, value: m } })
151
+ @agent.switch_model(selected)
152
+ @agent.config.save
153
+ puts @pastel.dim("Switched to: #{selected}")
154
+ rescue => e
155
+ puts @pastel.red("Error switching model: #{e.message}")
156
+ end
157
+ end
158
+
159
+ # @api private
160
+ def fetch_available_models(config)
161
+ require "net/http"
162
+ require "uri"
163
+ provider = PROVIDERS[config.provider.to_sym]
164
+ base_url = config.base_url || provider[:base_url]
165
+ url = URI("#{base_url}/models")
166
+
167
+ headers = provider[:auth_headers].call(config.api_key)
168
+ http = Net::HTTP.new(url.host, url.port)
169
+ http.use_ssl = url.scheme == "https"
170
+ http.open_timeout = 5
171
+ http.read_timeout = 10
172
+
173
+ request = Net::HTTP::Get.new(url.request_uri, headers)
174
+ response = http.request(request)
175
+
176
+ return [] unless response.is_a?(Net::HTTPSuccess)
177
+ data = JSON.parse(response.body)
178
+ (data["data"] || []).map { |m| m["id"] }.sort
179
+ rescue
180
+ []
181
+ end
182
+
183
+ # @api private
184
+ def handle_thinking
185
+ config = @agent.config || Crimson.config
186
+ current = config.thinking_level || "off"
187
+ puts @pastel.dim("Current thinking level: #{current}")
188
+ puts
189
+
190
+ begin
191
+ prompt = TTY::Prompt.new
192
+ level = prompt.select("Thinking level:", %w[off low medium high].map { |l| { name: l, value: l } })
193
+ config.thinking_level = level
194
+ config.save
195
+ @agent.config = config
196
+ puts @pastel.dim("Thinking level set to: #{level}")
197
+ rescue => e
198
+ puts @pastel.red("Error setting thinking level: #{e.message}")
199
+ end
200
+ end
201
+
202
+ # @api private
203
+ def handle_name
204
+ return puts(@pastel.yellow("No active session.")) unless @agent.session_id
205
+ puts @pastel.dim("Usage: /name <session name>")
206
+ end
207
+
208
+ # @api private
209
+ def handle_name_set(name)
210
+ return puts(@pastel.yellow("No active session.")) unless @agent.session_id
211
+ return puts(@pastel.yellow("Usage: /name <session name>")) if name.empty?
212
+
213
+ manager = SessionManager.new
214
+ manager.set_name(@agent.session_id, cwd: Dir.pwd, name: name)
215
+ puts @pastel.dim("Session name set to: #{name}")
216
+ end
217
+
218
+ # @api private
219
+ def handle_session_info
220
+ return puts(@pastel.dim("No active session.")) unless @agent.session_id
221
+
222
+ manager = SessionManager.new
223
+ header = manager.load_header(@agent.session_id, cwd: Dir.pwd)
224
+ entries = manager.load(@agent.session_id, cwd: Dir.pwd)
225
+
226
+ puts @pastel.bold("Session info:")
227
+ puts " ID: #{@agent.session_id}"
228
+ puts " Name: #{header&.dig('name') || '(unnamed)'}" if header
229
+ puts " Created: #{header&.dig('timestamp')}" if header
230
+ puts " CWD: #{@agent.session_cwd}"
231
+ puts " Entries: #{entries.length}"
232
+
233
+ usage = @agent.token_usage
234
+ puts " Tokens: #{usage[:total]} (#{usage[:prompt]} prompt + #{usage[:completion]} completion)"
235
+ cost = @agent.cost_tracker.total_cost
236
+ puts " Cost: $#{format('%.4f', cost)}" if cost > 0
237
+ end
238
+
239
+ # @api private
240
+ def handle_fork
241
+ return puts(@pastel.yellow("No active session to fork.")) unless @agent.session_id
242
+
243
+ manager = SessionManager.new
244
+ last_id = @agent.instance_variable_get(:@last_entry_id)
245
+ new_id = manager.fork(@agent.session_id, cwd: Dir.pwd, from_entry_id: last_id)
246
+ @agent.resume_session(new_id, cwd: Dir.pwd, session_manager: manager)
247
+ puts @pastel.dim("Forked to new session: #{new_id[0..7]}")
248
+ end
249
+
250
+ # @api private
251
+ def handle_tree
252
+ return puts(@pastel.dim("No active session.")) unless @agent.session_id
253
+
254
+ manager = SessionManager.new
255
+ entries = manager.load(@agent.session_id, cwd: Dir.pwd)
256
+ entries.each do |e|
257
+ case e.role
258
+ when "user"
259
+ preview = truncate(e.content.to_s, 60)
260
+ puts " #{@pastel.cyan("⏺")} #{preview}"
261
+ when "assistant"
262
+ tool_str = e.tool_calls.any? ? " [#{e.tool_calls.map { |t| t["name"] }.join(", ")}]" : ""
263
+ preview = truncate(e.content.to_s, 60)
264
+ puts " #{@pastel.dim("↳ #{preview}#{tool_str}")}"
265
+ when "tool_result"
266
+ preview = truncate(e.content.to_s, 40)
267
+ puts " #{@pastel.dim(" → #{e.tool_name}: #{preview}")}"
268
+ end
269
+ end
270
+ end
271
+
272
+ # @api private
273
+ def truncate(text, max_len)
274
+ return "" if text.nil?
275
+ cleaned = text.gsub("\n", "\\n")
276
+ cleaned.length > max_len ? "#{cleaned[0...max_len]}..." : cleaned
277
+ end
278
+
279
+ # @api private
280
+ def setup_readline
281
+ Reline.completion_proc = method(:file_path_completion)
282
+ end
283
+
284
+ # @api private
285
+ def file_path_completion(input)
286
+ prefix = input.strip
287
+ return [] unless prefix.start_with?("@", "./", "~/", "/")
288
+
289
+ path_prefix = prefix.start_with?("@") ? prefix[1..] : prefix
290
+ expanded = File.expand_path(path_prefix)
291
+
292
+ if File.directory?(expanded)
293
+ Dir.entries(expanded)
294
+ .reject { |e| e.start_with?(".") }
295
+ .map { |e| prefix.end_with?("/") ? "#{prefix}#{e}" : "#{prefix}/#{e}" }
296
+ else
297
+ dir = File.dirname(expanded)
298
+ base = File.basename(expanded)
299
+ return [] unless Dir.exist?(dir)
300
+
301
+ Dir.entries(dir)
302
+ .reject { |e| e.start_with?(".") }
303
+ .select { |e| e.downcase.start_with?(base.downcase) }
304
+ .map { |e| prefix.include?("/") ? "#{File.dirname(prefix)}/#{e}" : e }
305
+ end
306
+ rescue => e
307
+ []
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ # Retry logic for API calls with exponential backoff and retry-After header support.
5
+ module RetryHandler
6
+ # Maximum number of retry attempts.
7
+ MAX_RETRIES = 3
8
+ # Base delay in seconds for exponential backoff.
9
+ BASE_DELAY = 1.0
10
+ # Maximum delay cap in seconds.
11
+ MAX_DELAY = 30.0
12
+
13
+ # Patterns in error messages that indicate a retry is appropriate.
14
+ RETRYABLE_MESSAGES = [
15
+ /rate.?limit/i,
16
+ /too many requests/i,
17
+ /429/,
18
+ /5\d{2}/,
19
+ /timeout/i,
20
+ /timed?\s*out/i,
21
+ /connection.*reset/i,
22
+ /connection.*refused/i,
23
+ /ECONNRESET/,
24
+ /ECONNREFUSED/,
25
+ /ETIMEDOUT/,
26
+ /ENOTFOUND/,
27
+ /network/i,
28
+ /overloaded/i,
29
+ /capacity/i,
30
+ /server error/i,
31
+ /service unavailable/i,
32
+ /bad gateway/i,
33
+ /gateway timeout/i,
34
+ /internal server error/i
35
+ ].freeze
36
+
37
+ # Execute a block with retry logic.
38
+ # @param max_retries [Integer] maximum retry attempts
39
+ # @param base_delay [Float] base delay in seconds
40
+ # @param max_delay [Float] maximum delay cap
41
+ # @yield block to execute and potentially retry
42
+ # @return [Object] the result of the block
43
+ # @raise [StandardError] if all retries are exhausted or error is non-retryable
44
+ def self.with_retry(max_retries: MAX_RETRIES, base_delay: BASE_DELAY, max_delay: MAX_DELAY)
45
+ attempts = 0
46
+ last_error = nil
47
+
48
+ loop do
49
+ attempts += 1
50
+ begin
51
+ return yield
52
+ rescue => e
53
+ last_error = e
54
+ raise e if attempts > max_retries
55
+ raise e unless retryable?(e)
56
+
57
+ delay = compute_delay(e, attempts, base_delay, max_delay)
58
+ sleep delay
59
+ end
60
+ end
61
+ end
62
+
63
+ # Check whether an error is retryable based on its message.
64
+ # @param error [StandardError]
65
+ # @return [Boolean]
66
+ def self.retryable?(error)
67
+ message = "#{error.class}: #{error.message}"
68
+ RETRYABLE_MESSAGES.any? { |pattern| message.match?(pattern) }
69
+ end
70
+
71
+ # Compute delay using exponential backoff, respecting Retry-After headers.
72
+ # @param error [StandardError]
73
+ # @param attempt [Integer] current attempt number
74
+ # @param base_delay [Float]
75
+ # @param max_delay [Float]
76
+ # @return [Float] delay in seconds
77
+ def self.compute_delay(error, attempt, base_delay, max_delay)
78
+ retry_after = extract_retry_after(error)
79
+ return [retry_after, max_delay].min if retry_after && retry_after > 0
80
+
81
+ delay = [base_delay * (2 ** (attempt - 1)), max_delay].min
82
+ delay + rand * 0.5
83
+ end
84
+
85
+ # Extract Retry-After header from an error response.
86
+ # @api private
87
+ def self.extract_retry_after(error)
88
+ return nil unless error.respond_to?(:response)
89
+
90
+ response = error.response
91
+ return nil unless response.is_a?(Hash)
92
+
93
+ headers = response[:headers] || response["headers"]
94
+ return nil unless headers.is_a?(Hash)
95
+
96
+ retry_after = headers["Retry-After"] || headers["retry-after"]
97
+ return nil unless retry_after
98
+
99
+ retry_after.to_f
100
+ rescue
101
+ nil
102
+ end
103
+ end
104
+ end