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,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'anthropic'
5
+ require_relative 'base'
6
+
7
+ module Crimson
8
+ module Client
9
+ # Anthropic SDK client adapter supporting streaming, thinking mode, and tool use.
10
+ class AnthropicAdapter < Base
11
+ # @param config [Config]
12
+ def initialize(config)
13
+ super
14
+ @client = Anthropic::Client.new(api_key: config.api_key)
15
+ end
16
+
17
+ # @param messages [Array<Message::Base>]
18
+ # @param tools [Array<Hash>]
19
+ # @yield [text_chunk, tool_event] streaming callback
20
+ # @return [Array(Message::Assistant, Hash, nil)]
21
+ def chat(messages:, tools: [], &stream_callback)
22
+ system_msg, chat_msgs = split_messages(messages)
23
+
24
+ params = {
25
+ model: @config.model,
26
+ max_tokens: @config.max_tokens
27
+ }
28
+ params[:system] = system_msg if system_msg
29
+ params[:messages] = chat_msgs
30
+ params[:tools] = tools unless tools.empty?
31
+
32
+ if @config.thinking_level && @config.thinking_level != "off"
33
+ budget = thinking_budget(@config.thinking_level)
34
+ params[:thinking] = { type: "enabled", budget_tokens: budget }
35
+ params[:max_tokens] = [params[:max_tokens], budget * 2].max
36
+ end
37
+
38
+ if block_given?
39
+ stream_chat(params, &stream_callback)
40
+ else
41
+ non_stream_chat(params)
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ # @api private
48
+ def split_messages(messages)
49
+ system_parts = []
50
+ chat_msgs = []
51
+
52
+ messages.each do |msg|
53
+ case msg
54
+ when Message::System
55
+ system_parts << msg.content
56
+ when Message::ToolResult
57
+ anthropic_h = msg.to_anthropic_h
58
+ last_msg = chat_msgs.last
59
+ if last_msg && last_msg[:role] == "user" && last_msg[:content].is_a?(Array)
60
+ last_msg[:content].concat(anthropic_h[:content])
61
+ else
62
+ chat_msgs << anthropic_h
63
+ end
64
+ else
65
+ chat_msgs << msg.to_anthropic_h
66
+ end
67
+ end
68
+
69
+ system_text = system_parts.join("\n\n")
70
+ system_text = nil if system_text.empty?
71
+
72
+ [system_text, chat_msgs]
73
+ end
74
+
75
+ # @api private
76
+ def thinking_budget(level)
77
+ case level
78
+ when "low" then 2_048
79
+ when "medium" then 10_000
80
+ when "high" then 32_000
81
+ else 10_000
82
+ end
83
+ end
84
+
85
+ # @api private
86
+ def stream_chat(params, &callback)
87
+ collected_content = String.new
88
+ collected_tool_calls = {}
89
+ current_tool_use = nil
90
+ collected_usage = nil
91
+
92
+ stream = @client.messages.stream(
93
+ model: params[:model],
94
+ max_tokens: params[:max_tokens],
95
+ system: params[:system],
96
+ messages: params[:messages],
97
+ tools: params[:tools]
98
+ )
99
+
100
+ stream.each do |event|
101
+ case event.type
102
+ when "message_delta"
103
+ if event.respond_to?(:usage)
104
+ u = event.usage
105
+ collected_usage = {
106
+ prompt_tokens: u&.input_tokens || 0,
107
+ completion_tokens: u&.output_tokens || 0,
108
+ total_tokens: (u&.input_tokens || 0) + (u&.output_tokens || 0)
109
+ }
110
+ end
111
+ when "content_block_delta"
112
+ delta = event.delta
113
+ next unless delta
114
+
115
+ delta_type = delta[:type] || delta["type"]
116
+ if delta_type == "text_delta"
117
+ text = delta[:text] || delta["text"] || ""
118
+ collected_content << text
119
+ callback.call(text, nil)
120
+ elsif delta_type == "input_json_delta"
121
+ partial = delta[:partial_json] || delta["partial_json"] || ""
122
+ current_tool_use[:arguments] << partial if current_tool_use
123
+ end
124
+ when "content_block_start"
125
+ content_block = event.content_block
126
+ next unless content_block
127
+
128
+ cb_type = content_block[:type] || content_block["type"]
129
+ if cb_type == "tool_use"
130
+ current_tool_use = {
131
+ id: content_block[:id] || content_block["id"],
132
+ name: content_block[:name] || content_block["name"],
133
+ arguments: String.new
134
+ }
135
+ end
136
+ when "content_block_stop"
137
+ if current_tool_use
138
+ callback.call(nil, current_tool_use)
139
+ collected_tool_calls[current_tool_use[:id]] = current_tool_use
140
+ current_tool_use = nil
141
+ end
142
+ end
143
+ end
144
+
145
+ [build_assistant_message(collected_content, collected_tool_calls.values), collected_usage]
146
+ rescue => e
147
+ [Message::Assistant.new(content: "Error communicating with Anthropic: #{e.message}"), nil]
148
+ end
149
+
150
+ # @api private
151
+ def non_stream_chat(params)
152
+ response = @client.messages.create(
153
+ model: params[:model],
154
+ max_tokens: params[:max_tokens],
155
+ system: params[:system],
156
+ messages: params[:messages],
157
+ tools: params[:tools]
158
+ )
159
+
160
+ content = String.new
161
+ tool_calls = []
162
+
163
+ Array(response.content).each do |block|
164
+ block_type = block[:type] || block["type"]
165
+ if block_type == "text"
166
+ content << (block[:text] || block["text"] || "")
167
+ elsif block_type == "tool_use"
168
+ tool_calls << Message::ToolCall.new(
169
+ id: block[:id] || block["id"],
170
+ name: block[:name] || block["name"],
171
+ arguments: block[:input] || block["input"] || {}
172
+ )
173
+ end
174
+ end
175
+
176
+ usage = response.usage
177
+ usage_h = usage ? {
178
+ prompt_tokens: usage.input_tokens || 0,
179
+ completion_tokens: usage.output_tokens || 0,
180
+ total_tokens: (usage.input_tokens || 0) + (usage.output_tokens || 0)
181
+ } : nil
182
+
183
+ [Message::Assistant.new(content: content.empty? ? nil : content.to_s, tool_calls: tool_calls), usage_h]
184
+ rescue => e
185
+ [Message::Assistant.new(content: "Error communicating with Anthropic: #{e.message}"), nil]
186
+ end
187
+
188
+ # @api private
189
+ def build_assistant_message(content, tool_calls)
190
+ tc = tool_calls.map do |raw|
191
+ args = begin
192
+ JSON.parse(raw[:arguments], symbolize_names: false)
193
+ rescue JSON::ParserError
194
+ {}
195
+ end
196
+ Message::ToolCall.new(id: raw[:id], name: raw[:name], arguments: args)
197
+ end
198
+
199
+ Message::Assistant.new(
200
+ content: content.empty? ? nil : content.to_s,
201
+ tool_calls: tc
202
+ )
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ module Client
5
+ # Abstract base class for LLM API client adapters.
6
+ # @abstract Subclasses must implement {#chat}.
7
+ class Base
8
+ # @param config [Config]
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ # Send a chat request and return the assistant response.
14
+ # @param messages [Array<Message::Base>] conversation messages
15
+ # @param tools [Array<Hash>] tool definitions
16
+ # @yield [text_chunk, tool_event] optional streaming callback
17
+ # @yieldparam text_chunk [String, nil] incremental text delta
18
+ # @yieldparam tool_event [Hash, nil] partial tool call data
19
+ # @return [Array(Message::Assistant, Hash, nil)] response message and usage data
20
+ def chat(messages:, tools: [], &stream_callback)
21
+ raise NotImplementedError, "#{self.class}#chat must be implemented"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Crimson
6
+ module Client
7
+ # Factory method to create the correct client adapter based on provider configuration.
8
+ # @param config [Config]
9
+ # @return [Client::Base]
10
+ # @raise [Error] if the provider SDK is unsupported
11
+ def self.create(config)
12
+ provider = config.provider.to_sym
13
+ sdk = PROVIDERS[provider][:sdk]
14
+
15
+ case sdk
16
+ when :openai
17
+ require_relative 'openai_adapter'
18
+ OpenAIAdapter.new(config)
19
+ when :anthropic
20
+ require_relative 'anthropic_adapter'
21
+ AnthropicAdapter.new(config)
22
+ else
23
+ raise Error, "Unsupported provider: #{config.provider}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openai'
5
+ require_relative 'base'
6
+
7
+ module Crimson
8
+ module Client
9
+ # OpenAI SDK client adapter supporting streaming and non-streaming chat.
10
+ class OpenAIAdapter < Base
11
+ # @param config [Config]
12
+ def initialize(config)
13
+ super
14
+ @client = build_client
15
+ end
16
+
17
+ # @param messages [Array<Message::Base>]
18
+ # @param tools [Array<Hash>]
19
+ # @yield [text_chunk, tool_event] streaming callback
20
+ # @return [Array(Message::Assistant, Hash, nil)]
21
+ def chat(messages:, tools: [], &stream_callback)
22
+ params = {
23
+ messages: messages.map(&:to_openai_h),
24
+ model: @config.model
25
+ }
26
+ params[:tools] = tools unless tools.empty?
27
+
28
+ if block_given?
29
+ stream_chat(params, &stream_callback)
30
+ else
31
+ non_stream_chat(params)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ # @api private
38
+ def build_client
39
+ opts = { api_key: @config.api_key }
40
+
41
+ base_url = @config.base_url || PROVIDERS[@config.provider.to_sym][:base_url]
42
+ opts[:base_url] = base_url if base_url
43
+
44
+ OpenAI::Client.new(**opts)
45
+ end
46
+
47
+ # @api private
48
+ def stream_chat(params, &callback)
49
+ collected_content = String.new
50
+ collected_tool_calls = {}
51
+ usage = nil
52
+
53
+ stream = @client.chat.completions.stream(
54
+ messages: params[:messages],
55
+ model: params[:model],
56
+ tools: params[:tools] || []
57
+ )
58
+
59
+ stream.each do |event|
60
+ case event
61
+ when OpenAI::Helpers::Streaming::ChatContentDeltaEvent
62
+ text = event.delta
63
+ if text && !text.empty?
64
+ collected_content << text
65
+ callback.call(text, nil)
66
+ end
67
+
68
+ when OpenAI::Helpers::Streaming::ChatFunctionToolCallArgumentsDeltaEvent
69
+ idx = event.index
70
+ collected_tool_calls[idx] ||= {
71
+ id: nil,
72
+ name: event.name || "",
73
+ arguments: String.new,
74
+ _emitted: false
75
+ }
76
+ collected_tool_calls[idx][:name] = event.name if event.name
77
+ collected_tool_calls[idx][:arguments] << event.arguments_delta if event.arguments_delta
78
+
79
+ if event.name && !collected_tool_calls[idx][:_emitted]
80
+ collected_tool_calls[idx][:_emitted] = true
81
+ callback.call(nil, collected_tool_calls[idx])
82
+ end
83
+
84
+ when OpenAI::Helpers::Streaming::ChatFunctionToolCallArgumentsDoneEvent
85
+ idx = event.index
86
+ collected_tool_calls[idx] ||= {
87
+ id: nil,
88
+ name: event.name || "",
89
+ arguments: String.new
90
+ }
91
+ collected_tool_calls[idx][:name] = event.name if event.name
92
+ collected_tool_calls[idx][:arguments] = event.arguments if event.arguments
93
+
94
+ when OpenAI::Helpers::Streaming::ResponseCompletedEvent
95
+ final = event.response
96
+ if final.respond_to?(:usage) && final.usage
97
+ usage = {
98
+ prompt_tokens: final.usage.prompt_tokens || 0,
99
+ completion_tokens: final.usage.completion_tokens || 0,
100
+ total_tokens: final.usage.total_tokens || 0
101
+ }
102
+ end
103
+
104
+ when OpenAI::Helpers::Streaming::ChatChunkEvent
105
+ chunk = event.chunk
106
+ if chunk.respond_to?(:usage) && chunk.usage
107
+ usage = {
108
+ prompt_tokens: chunk.usage.prompt_tokens || 0,
109
+ completion_tokens: chunk.usage.completion_tokens || 0,
110
+ total_tokens: chunk.usage.total_tokens || 0
111
+ }
112
+ end
113
+ end
114
+ end
115
+
116
+ collected_tool_calls.each do |_idx, tc|
117
+ next if tc[:_emitted]
118
+ callback.call(nil, tc)
119
+ end
120
+
121
+ [build_assistant_message(collected_content, collected_tool_calls.values), usage]
122
+ rescue => e
123
+ [Message::Assistant.new(content: "Error communicating with #{provider_name}: #{e.message}"), nil]
124
+ end
125
+
126
+ # @api private
127
+ def non_stream_chat(params)
128
+ response = @client.chat.completions.create(
129
+ messages: params[:messages],
130
+ model: params[:model],
131
+ tools: params[:tools] || []
132
+ )
133
+
134
+ choice = response.choices&.first
135
+ return [Message::Assistant.new(content: ""), nil] unless choice
136
+
137
+ msg = choice.message
138
+ tool_calls = parse_tool_calls(msg.tool_calls) if msg.tool_calls
139
+
140
+ usage = response.usage
141
+ usage_h = usage ? {
142
+ prompt_tokens: usage.prompt_tokens || 0,
143
+ completion_tokens: usage.completion_tokens || 0,
144
+ total_tokens: usage.total_tokens || 0
145
+ } : nil
146
+
147
+ [Message::Assistant.new(content: msg.content, tool_calls: tool_calls || []), usage_h]
148
+ rescue => e
149
+ [Message::Assistant.new(content: "Error communicating with #{provider_name}: #{e.message}"), nil]
150
+ end
151
+
152
+ # @api private
153
+ def parse_tool_calls(raw_tool_calls)
154
+ raw_tool_calls.map do |tc|
155
+ args = begin
156
+ JSON.parse(tc.function.arguments, symbolize_names: false)
157
+ rescue JSON::ParserError
158
+ {}
159
+ end
160
+
161
+ Message::ToolCall.new(id: tc.id, name: tc.function.name, arguments: args)
162
+ end
163
+ end
164
+
165
+ # @api private
166
+ def build_assistant_message(content, tool_calls)
167
+ tc = tool_calls.map do |raw|
168
+ args = begin
169
+ JSON.parse(raw[:arguments], symbolize_names: false)
170
+ rescue JSON::ParserError
171
+ {}
172
+ end
173
+ Message::ToolCall.new(id: raw[:id] || SecureRandom.uuid, name: raw[:name], arguments: args)
174
+ end
175
+
176
+ Message::Assistant.new(
177
+ content: content.empty? ? nil : content.to_s,
178
+ tool_calls: tc
179
+ )
180
+ end
181
+
182
+ # @api private
183
+ def provider_name
184
+ PROVIDERS[@config.provider.to_sym][:name]
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crimson
4
+ # Compacts conversation history by summarizing older messages when context limits are approached.
5
+ class Compactor
6
+ # Default max context tokens before compaction is triggered.
7
+ DEFAULT_MAX_CONTEXT_TOKENS = 100_000
8
+ # Number of most recent messages to preserve verbatim during compaction.
9
+ KEEP_RECENT_MESSAGES = 4
10
+
11
+ # @param client [Client::Base] the API client used for summarization
12
+ # @param max_context_tokens [Integer] threshold for triggering compaction
13
+ # @param model [String, nil] model name for token counting
14
+ # @param provider [String, nil] provider name
15
+ def initialize(client:, max_context_tokens: DEFAULT_MAX_CONTEXT_TOKENS, model: nil, provider: nil)
16
+ @client = client
17
+ @max_context_tokens = max_context_tokens
18
+ @token_counter = TokenCounter.new(model: model, provider: provider)
19
+ end
20
+
21
+ # Check whether the history exceeds 80% of the max context token budget.
22
+ # @param history [Array<Message::Base>]
23
+ # @return [Boolean]
24
+ def needs_compaction?(history)
25
+ estimated_tokens(history) > @max_context_tokens * 0.8
26
+ end
27
+
28
+ # Compact history by summarizing older entries and keeping recent ones verbatim.
29
+ # @param history [Array<Message::Base>]
30
+ # @param system_prompt [String]
31
+ # @return [Array<Message::Base>] compacted history
32
+ def compact(history, system_prompt:)
33
+ return history if history.length <= KEEP_RECENT_MESSAGES + 1
34
+
35
+ older = history[0...-KEEP_RECENT_MESSAGES]
36
+ recent = history[-KEEP_RECENT_MESSAGES..]
37
+
38
+ file_ops = extract_file_operations(history)
39
+ summary = summarize(older, system_prompt, file_ops)
40
+
41
+ compacted = []
42
+ compacted << Message::User.new("[Previous conversation summary]\n#{summary}")
43
+ compacted << Message::Assistant.new(content: "Understood. I have the context from the previous conversation.")
44
+ compacted.concat(recent)
45
+ compacted
46
+ end
47
+
48
+ private
49
+
50
+ # @api private
51
+ def extract_file_operations(history)
52
+ ops = { read: [], modified: [] }
53
+ pending_tool_calls = {}
54
+
55
+ history.each do |msg|
56
+ if msg.is_a?(Message::Assistant) && msg.tool_calls
57
+ msg.tool_calls.each do |tc|
58
+ pending_tool_calls[tc.id] = tc
59
+ end
60
+ elsif msg.is_a?(Message::ToolResult)
61
+ tc = pending_tool_calls[msg.tool_call_id]
62
+ if tc
63
+ path = tc.arguments.is_a?(Hash) ? (tc.arguments["path"] || tc.arguments[:path]) : nil
64
+ case tc.name
65
+ when "read_file"
66
+ ops[:read] << path if path
67
+ when "write_file", "edit_file"
68
+ ops[:modified] << path if path
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ ops[:read] = ops[:read].compact.uniq
75
+ ops[:modified] = ops[:modified].compact.uniq
76
+ ops
77
+ end
78
+
79
+ # @api private
80
+ def summarize(messages, system_prompt, file_ops = { read: [], modified: [] })
81
+ files_section = ""
82
+ if file_ops[:read].any? || file_ops[:modified].any?
83
+ files_section = "\n\nFiles involved in this conversation:\n"
84
+ files_section += " Read: #{file_ops[:read].join(', ')}\n" if file_ops[:read].any?
85
+ files_section += " Modified: #{file_ops[:modified].join(', ')}\n" if file_ops[:modified].any?
86
+ end
87
+
88
+ summary_prompt = "Summarize the following conversation between a user and an AI coding assistant. " \
89
+ "Preserve all important details: file paths discussed, code changes made, errors encountered, " \
90
+ "decisions reached, and any unresolved issues. Be concise but complete.\n\n" \
91
+ "Conversation:\n#{format_messages(messages)}#{files_section}"
92
+
93
+ msgs = [
94
+ Message::System.new("You are a helpful assistant that summarizes conversations concisely."),
95
+ Message::User.new(summary_prompt)
96
+ ]
97
+
98
+ response, _usage = @client.chat(messages: msgs, tools: [])
99
+ response&.content || "Summary unavailable"
100
+ end
101
+
102
+ # @api private
103
+ def format_messages(messages)
104
+ messages.map do |msg|
105
+ case msg
106
+ when Message::User
107
+ "User: #{msg.content}"
108
+ when Message::Assistant
109
+ tool_str = msg.tool_calls.any? ? " [called: #{msg.tool_calls.map(&:name).join(", ")}]" : ""
110
+ "Assistant: #{msg.content}#{tool_str}"
111
+ when Message::ToolResult
112
+ "Tool (#{msg.name}): #{truncate(msg.content.to_s, 200)}"
113
+ else
114
+ nil
115
+ end
116
+ end.compact.join("\n")
117
+ end
118
+
119
+ # @api private
120
+ def estimated_tokens(history)
121
+ @token_counter.count_messages(history)
122
+ end
123
+
124
+ # @api private
125
+ def truncate(text, max)
126
+ text.length > max ? "#{text[0...max]}..." : text
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Crimson
6
+ # Configuration model with JSON file persistence.
7
+ # Stores provider, model, API key, and other connection settings.
8
+ class Config
9
+ # @return [String, nil] provider name
10
+ # @return [String, nil] model identifier
11
+ # @return [String, nil] API key
12
+ # @return [String, nil] custom base URL
13
+ # @return [Integer] max tokens for responses
14
+ # @return [String, nil] thinking level (off/low/medium/high)
15
+ attr_reader :provider, :model, :api_key, :base_url, :max_tokens, :thinking_level
16
+
17
+ # Valid thinking level values.
18
+ VALID_THINKING_LEVELS = %w[off low medium high].freeze
19
+
20
+ # @param provider [String, nil]
21
+ # @param model [String, nil]
22
+ # @param api_key [String, nil]
23
+ # @param base_url [String, nil]
24
+ # @param max_tokens [Integer]
25
+ # @param thinking_level [String, nil]
26
+ def initialize(provider: nil, model: nil, api_key: nil, base_url: nil, max_tokens: 8192, thinking_level: nil)
27
+ @provider = provider
28
+ @model = model
29
+ @api_key = api_key
30
+ @base_url = base_url
31
+ @max_tokens = max_tokens
32
+ @thinking_level = validate_thinking_level(thinking_level)
33
+ end
34
+
35
+ # Load configuration from the JSON config file.
36
+ # @return [Config]
37
+ def self.load
38
+ return new unless File.exist?(Crimson::CONFIG_FILE)
39
+
40
+ data = JSON.parse(File.read(Crimson::CONFIG_FILE))
41
+ new(
42
+ provider: data["provider"],
43
+ model: data["model"],
44
+ api_key: data["api_key"],
45
+ base_url: data["base_url"],
46
+ max_tokens: data["max_tokens"] || 1000,
47
+ thinking_level: data["thinking_level"]
48
+ )
49
+ rescue JSON::ParserError => e
50
+ raise Error, "Invalid config file: #{e.message}"
51
+ end
52
+
53
+ # Persist configuration to the JSON config file with restricted permissions.
54
+ # @return [void]
55
+ def save
56
+ FileUtils.mkdir_p(File.dirname(Crimson::CONFIG_FILE))
57
+
58
+ data = {
59
+ provider: @provider,
60
+ model: @model,
61
+ api_key: @api_key,
62
+ base_url: @base_url,
63
+ max_tokens: @max_tokens,
64
+ thinking_level: @thinking_level
65
+ }
66
+
67
+ File.write(Crimson::CONFIG_FILE, JSON.pretty_generate(data))
68
+ File.chmod(0o600, Crimson::CONFIG_FILE)
69
+ end
70
+
71
+ # @return [Boolean] whether required fields are present
72
+ def valid?
73
+ return false if @provider.nil? || @provider.empty?
74
+ return false if @model.nil? || @model.empty?
75
+ return false if @api_key.nil? || @api_key.empty?
76
+ return false if @provider == "custom" && (@base_url.nil? || @base_url.empty?)
77
+ true
78
+ end
79
+
80
+ # @param level [String, nil]
81
+ def thinking_level=(level)
82
+ @thinking_level = validate_thinking_level(level)
83
+ end
84
+
85
+ private
86
+
87
+ # @param level [String, nil]
88
+ # @return [String, nil]
89
+ def validate_thinking_level(level)
90
+ return nil if level.nil?
91
+ level = level.to_s.downcase
92
+ VALID_THINKING_LEVELS.include?(level) ? level : nil
93
+ end
94
+ end
95
+ end