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,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
|