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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ # Model pricing in USD per million tokens.
5
+ # @!parse
6
+ # MODEL_PRICING = Hash[String, Hash]
7
+ MODEL_PRICING = {
8
+ "gpt-4o" => { input: 2.50, output: 10.00 },
9
+ "gpt-4o-mini" => { input: 0.15, output: 0.60 },
10
+ "gpt-4-turbo" => { input: 10.00, output: 30.00 },
11
+ "gpt-3.5-turbo" => { input: 0.50, output: 1.50 },
12
+ "claude-sonnet-4-20250514" => { input: 3.00, output: 15.00 },
13
+ "claude-3-5-sonnet-20241022" => { input: 3.00, output: 15.00 },
14
+ "claude-3-5-haiku-20241022" => { input: 0.80, output: 4.00 },
15
+ "claude-3-opus-20240229" => { input: 15.00, output: 75.00 },
16
+ "claude-3-haiku-20240307" => { input: 0.25, output: 1.25 }
17
+ }
18
+
19
+ # Per-model cost tracking for API usage.
20
+ class CostTracker
21
+ # @return [Float] accumulated cost in USD
22
+ attr_reader :total_cost
23
+
24
+ def initialize
25
+ @total_cost = 0.0
26
+ @cost_breakdown = []
27
+ end
28
+
29
+ # Record usage for a single turn.
30
+ # @param model [String] model name
31
+ # @param usage [Hash, nil] token usage with prompt_tokens/completion_tokens
32
+ # @return [Hash] input/output/total cost for this turn
33
+ def track(model, usage)
34
+ pricing = MODEL_PRICING[model]
35
+ return { input: 0, output: 0, total: 0 } unless pricing && usage
36
+
37
+ prompt_tokens = usage[:prompt_tokens] || usage["prompt_tokens"] || usage[:prompt] || 0
38
+ completion_tokens = usage[:completion_tokens] || usage["completion_tokens"] || usage[:completion] || 0
39
+
40
+ input_cost = (pricing[:input] / 1_000_000.0) * prompt_tokens
41
+ output_cost = (pricing[:output] / 1_000_000.0) * completion_tokens
42
+ turn_cost = input_cost + output_cost
43
+
44
+ @total_cost += turn_cost
45
+ @cost_breakdown << { input: input_cost, output: output_cost, total: turn_cost }
46
+
47
+ { input: input_cost, output: output_cost, total: turn_cost }
48
+ end
49
+
50
+ # @return [Array<Hash>] per-turn cost breakdown
51
+ def breakdown
52
+ @cost_breakdown.dup
53
+ end
54
+
55
+ # Reset all tracked costs.
56
+ # @return [void]
57
+ def reset
58
+ @total_cost = 0.0
59
+ @cost_breakdown = []
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Crimson
6
+ # ANSI-markdown formatter for rendering Markdown-formatted text with terminal colors.
7
+ module Formatter
8
+ @pastel = Pastel.new(enabled: true)
9
+ @in_code_block = false
10
+
11
+ class << self
12
+ # Reset the code block tracking state.
13
+ # @return [void]
14
+ def reset
15
+ @in_code_block = false
16
+ end
17
+
18
+ # Format a single line of Markdown text with ANSI styling.
19
+ # @param line [String, nil]
20
+ # @return [String] styled output
21
+ def format_line(line)
22
+ return "" if line.nil?
23
+
24
+ if @in_code_block
25
+ if line.start_with?("```")
26
+ @in_code_block = false
27
+ @pastel.dim("```")
28
+ else
29
+ line
30
+ end
31
+ elsif line.start_with?("```")
32
+ @in_code_block = true
33
+ lang = line.sub(/^```\s*/, "").strip
34
+ lang.empty? ? @pastel.dim("```") : @pastel.dim("``` #{lang}")
35
+ else
36
+ style_inline(line)
37
+ end
38
+ end
39
+
40
+ # @return [Boolean] whether currently inside a code block
41
+ def in_code_block?
42
+ @in_code_block
43
+ end
44
+
45
+ private
46
+
47
+ # Apply inline Markdown styling (headers, bold, code, links, etc.).
48
+ # @api private
49
+ def style_inline(line)
50
+ result = line.dup
51
+
52
+ header_re = Regexp.new('^\#{1,6}\s+(.+)$')
53
+ if (m = result.match(header_re))
54
+ return @pastel.bold.yellow(m[1])
55
+ end
56
+
57
+ result = result.gsub(/^(\s{0,3})([-*+])\s/) do
58
+ "#{$1}#{@pastel.cyan($2)} "
59
+ end
60
+
61
+ result = result.gsub(/^(\s{0,3})(\d+\.)\s/) do
62
+ "#{$1}#{@pastel.cyan($2)} "
63
+ end
64
+
65
+ result = result.gsub(/^>\s?(.*)$/) do
66
+ @pastel.dim("│ ") + @pastel.dim($1)
67
+ end
68
+
69
+ if result.strip =~ /^-{3,}$|^_{3,}$|^\*{3,}$/
70
+ result = @pastel.dim("─" * 40)
71
+ end
72
+
73
+ result = result.gsub(/\[([^\]]+)\]\(([^)]+)\)/) do
74
+ @pastel.underline($1) + @pastel.dim(" (#{$2})")
75
+ end
76
+
77
+ result = result.gsub(/`([^`]+)`/) do
78
+ @pastel.cyan($1)
79
+ end
80
+
81
+ result = result.gsub(/\*\*([^*]+)\*\*/) do
82
+ @pastel.bold($1)
83
+ end
84
+
85
+ result = result.gsub(/(?<!\*)\*([^*]+)\*(?!\*)/) do
86
+ @pastel.italic($1)
87
+ end
88
+
89
+ result
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Crimson
6
+ # Namespace for message types used in conversation history.
7
+ module Message
8
+ # Base class for all message types.
9
+ # @abstract
10
+ class Base
11
+ # @return [String] the role name (e.g. "system", "user", "assistant", "tool")
12
+ attr_reader :role
13
+
14
+ # @param role [String]
15
+ def initialize(role)
16
+ @role = role
17
+ end
18
+ end
19
+
20
+ # A system-level message carrying instructions or context.
21
+ class System < Base
22
+ # @return [String] the system message content
23
+ attr_reader :content
24
+
25
+ # @param content [String]
26
+ def initialize(content)
27
+ super("system")
28
+ @content = content
29
+ end
30
+
31
+ # @return [Hash] OpenAI-compatible representation
32
+ def to_openai_h
33
+ { role: "system", content: @content }
34
+ end
35
+
36
+ # @return [Hash] Anthropic-compatible representation
37
+ def to_anthropic_h
38
+ { type: "text", text: @content }
39
+ end
40
+ end
41
+
42
+ # A user message.
43
+ class User < Base
44
+ # @return [String] the user message content
45
+ attr_reader :content
46
+
47
+ # @param content [String]
48
+ def initialize(content)
49
+ super("user")
50
+ @content = content
51
+ end
52
+
53
+ # @return [Hash] OpenAI-compatible representation
54
+ def to_openai_h
55
+ { role: "user", content: @content }
56
+ end
57
+
58
+ # @return [Hash] Anthropic-compatible representation
59
+ def to_anthropic_h
60
+ { role: "user", content: @content }
61
+ end
62
+ end
63
+
64
+ # An assistant (model) response, optionally containing tool calls.
65
+ class Assistant < Base
66
+ # @return [String, nil] the text content
67
+ # @return [Array<ToolCall>] any tool calls requested
68
+ attr_reader :content, :tool_calls
69
+
70
+ # @param content [String, nil]
71
+ # @param tool_calls [Array<ToolCall>]
72
+ def initialize(content: nil, tool_calls: [])
73
+ super("assistant")
74
+ @content = content
75
+ @tool_calls = tool_calls
76
+ end
77
+
78
+ # @return [Boolean] whether this message contains tool calls
79
+ def tool_call?
80
+ !@tool_calls.nil? && !@tool_calls.empty?
81
+ end
82
+
83
+ # @return [Hash] OpenAI-compatible representation
84
+ def to_openai_h
85
+ h = { role: "assistant" }
86
+ h[:content] = @content if @content
87
+ h[:tool_calls] = @tool_calls.map(&:to_openai_h) if tool_call?
88
+ h
89
+ end
90
+
91
+ # @return [Hash] Anthropic-compatible representation
92
+ def to_anthropic_h
93
+ content_blocks = []
94
+ content_blocks << { type: "text", text: @content } if @content && !@content.empty?
95
+ @tool_calls.each do |tc|
96
+ content_blocks << {
97
+ type: "tool_use",
98
+ id: tc.id,
99
+ name: tc.name,
100
+ input: tc.arguments
101
+ }
102
+ end
103
+ { role: "assistant", content: content_blocks }
104
+ end
105
+ end
106
+
107
+ # Represents a tool/function call requested by the model.
108
+ class ToolCall
109
+ # @return [String] unique identifier for this tool call
110
+ # @return [String] name of the tool
111
+ # @return [Hash] arguments passed to the tool
112
+ attr_reader :id, :name, :arguments
113
+
114
+ # @param id [String]
115
+ # @param name [String]
116
+ # @param arguments [Hash]
117
+ def initialize(id:, name:, arguments: {})
118
+ @id = id
119
+ @name = name
120
+ @arguments = arguments
121
+ end
122
+
123
+ # @return [Hash] OpenAI-compatible tool call representation
124
+ def to_openai_h
125
+ {
126
+ id: @id,
127
+ type: "function",
128
+ function: {
129
+ name: @name,
130
+ arguments: JSON.generate(@arguments)
131
+ }
132
+ }
133
+ end
134
+ end
135
+
136
+ # The result of executing a tool call, sent back to the model.
137
+ class ToolResult < Base
138
+ # @return [String] the ID of the tool call this result belongs to
139
+ # @return [String] the tool name
140
+ # @return [String] the result content
141
+ attr_reader :tool_call_id, :name, :content
142
+
143
+ # @param tool_call_id [String]
144
+ # @param name [String]
145
+ # @param content [String]
146
+ def initialize(tool_call_id:, name:, content:)
147
+ super("tool")
148
+ @tool_call_id = tool_call_id
149
+ @name = name
150
+ @content = content
151
+ end
152
+
153
+ # @return [Hash] OpenAI-compatible representation
154
+ def to_openai_h
155
+ {
156
+ role: "tool",
157
+ tool_call_id: @tool_call_id,
158
+ content: @content
159
+ }
160
+ end
161
+
162
+ # @return [Hash] Anthropic-compatible representation
163
+ def to_anthropic_h
164
+ {
165
+ role: "user",
166
+ content: [
167
+ {
168
+ type: "tool_result",
169
+ tool_use_id: @tool_call_id,
170
+ content: @content
171
+ }
172
+ ]
173
+ }
174
+ end
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "pastel"
5
+
6
+ module Crimson
7
+ # Streaming output handler with spinner, tool call logging, and usage statistics.
8
+ # Subscribes to agent events to provide real-time terminal feedback.
9
+ class OutputHandler
10
+ # Interval in seconds between render flushes.
11
+ RENDER_INTERVAL = 0.05
12
+ # Spinner animation frame characters.
13
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].freeze
14
+
15
+ # Visual styles for known tools (prefix color and label).
16
+ TOOL_STYLES = {
17
+ "read_file" => { prefix: "→Read", color: :blue },
18
+ "write_file" => { prefix: "→Write", color: :green },
19
+ "edit_file" => { prefix: "→Edit", color: :yellow },
20
+ "run_command" => { prefix: "$", color: :bright_white },
21
+ "search_files" => { prefix: "✱Search", color: :cyan },
22
+ "glob" => { prefix: "✱Glob", color: :cyan },
23
+ "list_directory" => { prefix: "→List", color: :cyan }
24
+ }.freeze
25
+
26
+ # Extractors to pull display-relevant arguments from tool call argument hashes.
27
+ TOOL_ARG_EXTRACTORS = {
28
+ "read_file" => ->(a) { a["path"] || a[:path] },
29
+ "write_file" => ->(a) { a["path"] || a[:path] },
30
+ "edit_file" => ->(a) { a["path"] || a[:path] },
31
+ "run_command" => ->(a) { a["command"] || a[:command] },
32
+ "search_files" => ->(a) { a["pattern"] || a[:pattern] },
33
+ "list_directory" => ->(a) { a["path"] || a[:path] },
34
+ "glob" => ->(a) { a["pattern"] || a[:pattern] }
35
+ }.freeze
36
+
37
+ def initialize
38
+ @pastel = Pastel.new
39
+ @spinner_active = false
40
+ @first_token = false
41
+ @render_buffer = String.new
42
+ @render_thread = nil
43
+ @render_mutex = Mutex.new
44
+ @spinner_thread = nil
45
+ @seen_tool_calls = Set.new
46
+ @thinking_start = nil
47
+ @run_start = nil
48
+ end
49
+
50
+ # Subscribe to events on the given agent for output rendering.
51
+ # @param agent [Agent]
52
+ # @return [void]
53
+ def attach(agent)
54
+ agent.on(Agent::Events::AGENT_START) do
55
+ @first_token = false
56
+ Formatter.reset
57
+ @thinking_start = Time.now
58
+ @run_start = Time.now
59
+ start_spinner
60
+ end
61
+
62
+ agent.on(Agent::Events::MESSAGE_UPDATE) do |_event, delta:, **|
63
+ unless @first_token
64
+ stop_spinner
65
+ @first_token = true
66
+ if @thinking_start
67
+ elapsed = format("%.1fs", Time.now - @thinking_start)
68
+ puts @pastel.dim("+ Thought: #{elapsed}")
69
+ @thinking_start = nil
70
+ end
71
+ end
72
+ @render_mutex.synchronize { @render_buffer << delta }
73
+ start_render_thread unless @render_thread&.alive?
74
+ end
75
+
76
+ agent.on(Agent::Events::TOOL_EXECUTION_START) do |_event, tool_name:, args:, tool_call_id:, **|
77
+ next if @seen_tool_calls.include?(tool_call_id)
78
+ @seen_tool_calls << tool_call_id
79
+ stop_spinner
80
+ $stdout.write("\r\e[2K")
81
+ $stdout.flush
82
+ log_tool_call(tool_name, args)
83
+ end
84
+
85
+ agent.on(Agent::Events::TOOL_EXECUTION_END) do |_event, result:, is_error:, tool_call_id:, **|
86
+ next if tool_call_id && @seen_tool_calls.include?("#{tool_call_id}_end")
87
+ @seen_tool_calls << "#{tool_call_id}_end" if tool_call_id
88
+ next unless is_error
89
+ truncated = truncate(result.to_s, 120)
90
+ puts @pastel.red(" ✗ #{truncated}")
91
+ end
92
+
93
+ agent.on(Agent::Events::TOOL_EXECUTION_UPDATE) do |_event, tool_name:, partial_result:, **|
94
+ next unless tool_name == "run_command"
95
+ flush_render_buffer
96
+ $stdout.write("\r\e[2K #{@pastel.dim(partial_result)}")
97
+ $stdout.flush
98
+ end
99
+
100
+ agent.on(Agent::Events::TURN_START) do |_event, active_skills: []|
101
+ conditional = active_skills.reject { |s| s == "coding" }
102
+ unless conditional.empty?
103
+ stop_spinner
104
+ puts @pastel.dim("+ #{conditional.join(", ")}")
105
+ end
106
+ unless @first_token
107
+ @thinking_start = Time.now
108
+ start_spinner
109
+ end
110
+ end
111
+
112
+ agent.on(Agent::Events::AGENT_END) do
113
+ stop_spinner
114
+ flush_render_buffer(final: true)
115
+ @seen_tool_calls.clear
116
+ elapsed = @run_start ? format_elapsed(Time.now - @run_start) : ""
117
+ usage = agent.token_usage
118
+ parts = []
119
+ if usage[:total] > 0
120
+ cost = agent.cost_tracker.total_cost
121
+ cost_str = cost > 0 ? " ($#{format("%.4f", cost)})" : ""
122
+ parts << "tokens: #{usage[:prompt]}↑ #{usage[:completion]}↓ = #{usage[:total]}#{cost_str}"
123
+ end
124
+ parts << "time: #{elapsed}" unless elapsed.empty?
125
+ puts @pastel.dim("\n #{parts.join(" · ")}") unless parts.empty?
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ # @api private
132
+ def log_tool_call(tool_name, args)
133
+ style = TOOL_STYLES[tool_name]
134
+ if style
135
+ detail = extract_tool_arg(tool_name, args) || ""
136
+ puts @pastel.decorate("#{style[:prefix]} ", style[:color]) + detail
137
+ else
138
+ detail = extract_tool_arg(tool_name, args)
139
+ puts @pastel.dim("→ #{tool_name}") + (detail ? " #{detail}" : "")
140
+ end
141
+ end
142
+
143
+ # @api private
144
+ def format_elapsed(seconds)
145
+ if seconds < 60
146
+ format("%.1fs", seconds)
147
+ else
148
+ mins = (seconds / 60).to_i
149
+ secs = format("%.0f", seconds - mins * 60)
150
+ "#{mins}m #{secs}s"
151
+ end
152
+ end
153
+
154
+ # @api private
155
+ def start_spinner
156
+ return if @spinner_active
157
+ @spinner_active = true
158
+ @spinner_thread = Thread.new do
159
+ i = 0
160
+ while @spinner_active
161
+ $stdout.write("\r \e[36m#{SPINNER_FRAMES[i % SPINNER_FRAMES.length]}\e[0m Thinking...")
162
+ $stdout.flush
163
+ i += 1
164
+ sleep 0.08
165
+ end
166
+ $stdout.write("\r\e[2K")
167
+ $stdout.flush
168
+ end
169
+ end
170
+
171
+ # @api private
172
+ def stop_spinner
173
+ return unless @spinner_active
174
+ @spinner_active = false
175
+ @spinner_thread&.join(2)
176
+ @spinner_thread = nil
177
+ $stdout.write("\r\e[2K")
178
+ $stdout.flush
179
+ end
180
+
181
+ # @api private
182
+ def start_render_thread
183
+ @render_thread = Thread.new do
184
+ loop do
185
+ sleep RENDER_INTERVAL
186
+ break if flush_render_buffer == :empty
187
+ end
188
+ end
189
+ end
190
+
191
+ # @api private
192
+ def flush_render_buffer(final: false)
193
+ data = nil
194
+ @render_mutex.synchronize do
195
+ data = @render_buffer.dup
196
+ @render_buffer.clear
197
+ end
198
+ return :empty if data.nil? || data.empty?
199
+
200
+ output = String.new
201
+ lines = if final
202
+ data.split("\n", -1)
203
+ else
204
+ last_newline = data.rindex("\n")
205
+ if last_newline.nil?
206
+ @render_mutex.synchronize { @render_buffer.prepend(data) }
207
+ return :empty
208
+ end
209
+
210
+ complete = data[0..last_newline]
211
+ remainder = data[(last_newline + 1)..]
212
+ @render_mutex.synchronize { @render_buffer.prepend(remainder) } if remainder
213
+ complete.split("\n", -1)
214
+ end
215
+
216
+ lines.each do |line|
217
+ next if line.nil?
218
+ next if line.strip.empty?
219
+ if !output.empty? && header?(line)
220
+ output << "\n"
221
+ end
222
+ styled = Formatter.format_line(line)
223
+ output << styled << "\n"
224
+ end
225
+
226
+ $stdout.write(output)
227
+ $stdout.flush
228
+ nil
229
+ end
230
+
231
+ # @api private
232
+ def extract_tool_arg(tool_name, args)
233
+ return nil unless args.is_a?(Hash)
234
+ extractor = TOOL_ARG_EXTRACTORS[tool_name]
235
+ extractor ? extractor.call(args) : nil
236
+ rescue
237
+ nil
238
+ end
239
+
240
+ # @api private
241
+ def header?(line)
242
+ line.match?(Regexp.new('^\#{1,6}\s'))
243
+ end
244
+
245
+ # @api private
246
+ def truncate(text, max_len)
247
+ return "" if text.nil?
248
+ cleaned = text.gsub("\n", "\\n")
249
+ cleaned.length > max_len ? "#{cleaned[0...max_len]}..." : cleaned
250
+ end
251
+ end
252
+ end