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,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
|
data/lib/crimson/repl.rb
ADDED
|
@@ -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
|