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,564 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Crimson
7
+ # Thread-safe abort signal for cancelling tool execution mid-flight.
8
+ class AbortSignal
9
+ def initialize
10
+ @aborted = false
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ # Mark as aborted.
15
+ # @return [void]
16
+ def abort!
17
+ @mutex.synchronize { @aborted = true }
18
+ end
19
+
20
+ # @return [Boolean] whether abort has been requested
21
+ def aborted?
22
+ @mutex.synchronize { @aborted }
23
+ end
24
+
25
+ # Reset abort state.
26
+ # @return [void]
27
+ def reset
28
+ @mutex.synchronize { @aborted = false }
29
+ end
30
+ end
31
+
32
+ # Core agent loop managing conversation history, tool execution, session persistence, and event emission.
33
+ class Agent
34
+ # Maximum iterations per user prompt before forcing a break.
35
+ MAX_ITERATIONS = 50
36
+ # File name for saving/loading conversation history.
37
+ HISTORY_FILE = ".crimson_history"
38
+
39
+ # Keywords that signal tool usage may be needed.
40
+ NEEDS_TOOL_PATTERNS = %w[
41
+ read write edit create fix bug test run exec command search find
42
+ file files directory folder install update delete remove patch
43
+ config setup deploy build compile lint format check verify
44
+ gem npm pip cargo bundle make git docker ls cat touch mkdir rm mv cp
45
+ grep rg sed awk head tail wc diff code project src spec
46
+ explain why how where when who which refactor implement
47
+ list show look open
48
+ ].freeze
49
+
50
+ # Patterns that indicate a trivial greeting that doesn't need tools.
51
+ TRIVIAL_PATTERNS = %w[hi hello hey thanks thank ok yes no bye goodbye sure].freeze
52
+
53
+ # @return [ToolRegistry]
54
+ # @return [Hash] token usage accumulator (prompt/completion/total)
55
+ # @return [EventEmitter]
56
+ # @return [SteeringManager]
57
+ attr_reader :tool_registry, :token_usage, :events, :steering
58
+ # @return [String, nil] current session ID
59
+ # @return [String, nil] session working directory
60
+ # @return [CostTracker]
61
+ # @return [Compactor, nil]
62
+ attr_reader :session_id, :session_cwd, :cost_tracker, :compactor
63
+ # @return [Config]
64
+ attr_accessor :config
65
+ # @api private
66
+ attr_writer :define_system_prompt
67
+
68
+ # @param client [Client::Base]
69
+ # @param tool_registry [ToolRegistry]
70
+ # @param system_prompt [String]
71
+ # @param skill_router [SkillRouter, nil]
72
+ def initialize(client:, tool_registry:, system_prompt:, skill_router: nil)
73
+ @client = client
74
+ @tool_registry = tool_registry
75
+ @system_prompt = system_prompt
76
+ @system_prompt_builder = nil
77
+ @skill_router = skill_router || SkillRouter.new
78
+ @active_skills = ["coding"]
79
+ @config = Crimson.config
80
+ @history = []
81
+ @events = Agent::EventEmitter.new
82
+ @steering = Agent::SteeringManager.new
83
+ @token_usage = { prompt: 0, completion: 0, total: 0 }
84
+ @before_tool_call = nil
85
+ @after_tool_call = nil
86
+ @abort_controller = false
87
+ @abort_signal = AbortSignal.new
88
+ @session_manager = nil
89
+ @session_id = nil
90
+ @session_cwd = nil
91
+ @last_entry_id = nil
92
+ @session_buffer = []
93
+ @compactor = nil
94
+ @cost_tracker = CostTracker.new
95
+ @cached_tool_defs = nil
96
+ @cached_system_msg = nil
97
+ end
98
+
99
+ # Subscribe to an agent event.
100
+ # @param event_type [Symbol] event type constant
101
+ # @yield handler block
102
+ # @return [void]
103
+ def on(event_type, &handler)
104
+ @events.on(event_type, &handler)
105
+ end
106
+
107
+ # Register a hook that runs before each tool call.
108
+ # @yieldparam tool_call [Message::ToolCall]
109
+ # @yieldparam args [Hash]
110
+ # @yieldparam history [Array<Message::Base>]
111
+ # @return [void]
112
+ def before_tool_call(&block)
113
+ @before_tool_call = block
114
+ end
115
+
116
+ # Register a hook that runs after each tool call.
117
+ # @yieldparam tool_call [Message::ToolCall]
118
+ # @yieldparam result [String]
119
+ # @yieldparam is_error [Boolean]
120
+ # @yieldparam history [Array<Message::Base>]
121
+ # @return [void]
122
+ def after_tool_call(&block)
123
+ @after_tool_call = block
124
+ end
125
+
126
+ # Start a new session for the given working directory.
127
+ # @param cwd [String]
128
+ # @param session_manager [SessionManager]
129
+ # @return [void]
130
+ def start_session(cwd:, session_manager: SessionManager.new)
131
+ @session_manager = session_manager
132
+ @session_id = @session_manager.create(cwd: cwd)
133
+ @session_cwd = cwd
134
+ @last_entry_id = nil
135
+ end
136
+
137
+ # Resume an existing session by loading its history.
138
+ # @param session_id [String]
139
+ # @param cwd [String]
140
+ # @param session_manager [SessionManager]
141
+ # @return [void]
142
+ def resume_session(session_id, cwd:, session_manager: SessionManager.new)
143
+ @session_manager = session_manager
144
+ entries = @session_manager.load(session_id, cwd: cwd)
145
+ @session_id = session_id
146
+ @session_cwd = cwd
147
+ @history = entries.map(&:to_message).compact
148
+ @last_entry_id = entries.last&.id
149
+ end
150
+
151
+ # Enable context compaction with the given client for summarization.
152
+ # @param client [Client::Base]
153
+ # @param max_context_tokens [Integer]
154
+ # @param model [String, nil]
155
+ # @param provider [String, nil]
156
+ # @return [void]
157
+ def enable_compaction!(client:, max_context_tokens: 100_000, model: nil, provider: nil)
158
+ @compactor = Compactor.new(
159
+ client: client,
160
+ max_context_tokens: max_context_tokens,
161
+ model: model || Crimson.config&.model,
162
+ provider: provider || Crimson.config&.provider
163
+ )
164
+ end
165
+
166
+ # Force compaction of the conversation history.
167
+ # @return [String] status message
168
+ def compact!
169
+ return "Compaction not enabled" unless @compactor
170
+ return "History too short to compact" if @history.length <= 5
171
+
172
+ @history = @compactor.compact(@history, system_prompt: resolved_system_prompt)
173
+ "Compacted history to #{@history.length} messages"
174
+ end
175
+
176
+ # Process user input through the agent loop.
177
+ # @param user_input [String]
178
+ # @return [void]
179
+ def prompt(user_input)
180
+ @history << Message::User.new(user_input)
181
+ append_to_session(@history.last)
182
+ @events.emit(Agent::Events::MESSAGE_START, message: @history.last)
183
+ @events.emit(Agent::Events::MESSAGE_END, message: @history.last)
184
+ run_loop
185
+ end
186
+
187
+ # Continue the agent loop after a manual break.
188
+ # @return [void]
189
+ def continue
190
+ run_loop
191
+ end
192
+
193
+ # Inject a steering message into the current turn.
194
+ # @param message [String]
195
+ # @return [void]
196
+ def steer(message)
197
+ @steering.steer(Message::User.new(message))
198
+ end
199
+
200
+ # Inject a follow-up message into the current turn.
201
+ # @param message [String]
202
+ # @return [void]
203
+ def follow_up(message)
204
+ @steering.follow_up(Message::User.new(message))
205
+ end
206
+
207
+ # Abort the current agent execution.
208
+ # @return [void]
209
+ def abort!
210
+ @abort_signal.abort!
211
+ @abort_controller = true
212
+ end
213
+
214
+ # Switch to a different model, recreating the client adapter.
215
+ # @param model_id [String]
216
+ # @return [void]
217
+ def switch_model(model_id)
218
+ @config = Config.new(
219
+ provider: @config.provider,
220
+ model: model_id,
221
+ api_key: @config.api_key,
222
+ base_url: @config.base_url,
223
+ max_tokens: @config.max_tokens,
224
+ thinking_level: @config.thinking_level
225
+ )
226
+ @client = Crimson::Client.create(@config)
227
+ @cached_tool_defs = nil
228
+ @cached_system_msg = nil
229
+ end
230
+
231
+ # Reset conversation history and token usage.
232
+ # @return [void]
233
+ def reset
234
+ @history.clear
235
+ @token_usage = { prompt: 0, completion: 0, total: 0 }
236
+ @steering.clear_all
237
+ @cost_tracker.reset
238
+ end
239
+
240
+ # @return [Array<Message::Base>] a copy of the conversation history
241
+ def history
242
+ @history.dup
243
+ end
244
+
245
+ # @param new_history [Array<Message::Base>]
246
+ def history=(new_history)
247
+ @history = new_history.dup
248
+ end
249
+
250
+ # Save conversation history to a JSON file.
251
+ # @return [String] status message
252
+ def save_history
253
+ data = {
254
+ history: @history.map { |msg| serialize_message(msg) },
255
+ token_usage: @token_usage
256
+ }
257
+ File.write(HISTORY_FILE, JSON.pretty_generate(data))
258
+ "Conversation saved to #{HISTORY_FILE}"
259
+ end
260
+
261
+ # Load conversation history from a JSON file.
262
+ # @return [String] status message
263
+ def load_history
264
+ return "No saved conversation found." unless File.exist?(HISTORY_FILE)
265
+
266
+ data = JSON.parse(File.read(HISTORY_FILE), symbolize_names: true)
267
+ @history = data[:history].map { |msg| deserialize_message(msg) }.compact
268
+ @token_usage = data[:token_usage] || { prompt: 0, completion: 0, total: 0 }
269
+ "Loaded #{@history.length} messages"
270
+ rescue => e
271
+ "Error loading history: #{e.message}"
272
+ end
273
+
274
+ private
275
+
276
+ # @api private
277
+ def resolved_system_prompt
278
+ @system_prompt || @system_prompt_builder&.call || ""
279
+ end
280
+
281
+ # @api private
282
+ def run_loop
283
+ @abort_controller = false
284
+ @abort_signal.reset
285
+ @events.emit(Agent::Events::AGENT_START)
286
+
287
+ iterations = 0
288
+ all_messages = []
289
+
290
+ loop do
291
+ iterations += 1
292
+ break if iterations > MAX_ITERATIONS
293
+ break if @abort_controller
294
+
295
+ last_user_msg = @history.last&.content.to_s
296
+ tools_invoked = last_invoked_tool_names
297
+ new_skills = @skill_router.resolve(last_user_msg, tools_invoked: tools_invoked)
298
+ if new_skills != @active_skills
299
+ @active_skills = new_skills
300
+ @cached_system_msg = nil
301
+ end
302
+
303
+ @events.emit(Agent::Events::TURN_START, active_skills: @active_skills)
304
+
305
+ maybe_compact
306
+
307
+ messages = build_messages
308
+ tools = tools_for_message(last_user_msg)
309
+
310
+ assistant_message, usage = RetryHandler.with_retry do
311
+ @client.chat(messages: messages, tools: tools) do |text_chunk, _tool_event|
312
+ if text_chunk
313
+ @events.emit(Agent::Events::MESSAGE_UPDATE, delta: text_chunk, content_index: 0)
314
+ end
315
+ end
316
+ end
317
+
318
+ break unless assistant_message
319
+
320
+ @events.emit(Agent::Events::MESSAGE_START, message: assistant_message)
321
+ @events.emit(Agent::Events::MESSAGE_END, message: assistant_message)
322
+
323
+ track_usage(usage) if usage
324
+ @history << assistant_message
325
+ append_to_session(assistant_message)
326
+ all_messages << assistant_message
327
+
328
+ if assistant_message.tool_call?
329
+ execute_tools_and_continue(assistant_message, all_messages)
330
+ else
331
+ @events.emit(Agent::Events::TURN_END, message: assistant_message, tool_results: [])
332
+ break if @abort_controller
333
+
334
+ if @steering.has_steering?
335
+ inject_steering_messages(all_messages)
336
+ next
337
+ end
338
+
339
+ if @steering.has_follow_up?
340
+ inject_follow_up_messages(all_messages)
341
+ next
342
+ end
343
+
344
+ break
345
+ end
346
+ end
347
+
348
+ flush_session_buffer
349
+ @events.emit(Agent::Events::AGENT_END, messages: all_messages)
350
+ end
351
+
352
+ # @api private
353
+ def maybe_compact
354
+ return unless @compactor && @history.length > 10 && @compactor.needs_compaction?(@history)
355
+
356
+ @history = @compactor.compact(@history, system_prompt: resolved_system_prompt)
357
+ end
358
+
359
+ # @api private
360
+ def execute_tools_and_continue(assistant_message, all_messages)
361
+ executor = Agent::ToolExecutor.new(
362
+ @tool_registry, @events,
363
+ before_hook: @before_tool_call,
364
+ after_hook: @after_tool_call,
365
+ abort_signal: @abort_signal
366
+ )
367
+
368
+ results = executor.execute(assistant_message.tool_calls, @history)
369
+
370
+ results.each do |r|
371
+ tr = Message::ToolResult.new(
372
+ tool_call_id: r[:tool_call].id,
373
+ name: r[:tool_call].name,
374
+ content: r[:result]
375
+ )
376
+ @history << tr
377
+ append_to_session(tr)
378
+ all_messages << tr
379
+ end
380
+
381
+ @events.emit(Agent::Events::TURN_END, message: assistant_message, tool_results: results)
382
+ return if @abort_controller
383
+
384
+ inject_steering_messages(all_messages) if @steering.has_steering?
385
+ end
386
+
387
+ # @api private
388
+ def inject_steering_messages(all_messages)
389
+ @steering.pop_all_steering.each do |msg|
390
+ @history << msg
391
+ all_messages << msg
392
+ end
393
+ end
394
+
395
+ # @api private
396
+ def inject_follow_up_messages(all_messages)
397
+ @steering.pop_all_follow_up.each do |msg|
398
+ @history << msg
399
+ all_messages << msg
400
+ end
401
+ end
402
+
403
+ # @api private
404
+ def build_messages
405
+ msgs = []
406
+ prompt = assemble_system_prompt
407
+ msgs << Message::System.new(prompt) unless prompt.empty?
408
+ msgs.concat(@history)
409
+ msgs
410
+ end
411
+
412
+ # @api private
413
+ def assemble_system_prompt
414
+ parts = []
415
+ base = resolved_system_prompt
416
+ parts << base unless base.empty?
417
+
418
+ @active_skills.each do |skill_name|
419
+ next if skill_name == "coding" && !base.empty?
420
+ content = @skill_router.load_skill(skill_name)
421
+ parts << content if content && !content.empty?
422
+ end
423
+
424
+ parts.join("\n\n")
425
+ end
426
+
427
+ # @api private
428
+ def last_invoked_tool_names
429
+ @history.reverse_each do |msg|
430
+ next unless msg.is_a?(Message::Assistant) && msg.tool_calls&.any?
431
+ return msg.tool_calls.map(&:name)
432
+ end
433
+ []
434
+ end
435
+
436
+ # @api private
437
+ def provider_tool_definitions
438
+ sdk = PROVIDERS[Crimson.config.provider.to_sym][:sdk]
439
+ case sdk
440
+ when :openai then @tool_registry.openai_definitions
441
+ when :anthropic then @tool_registry.anthropic_definitions
442
+ else []
443
+ end
444
+ end
445
+
446
+ # @api private
447
+ def track_usage(usage)
448
+ return unless usage
449
+ @token_usage[:prompt] += (usage[:prompt_tokens] || usage["prompt_tokens"] || 0)
450
+ @token_usage[:completion] += (usage[:completion_tokens] || usage["completion_tokens"] || 0)
451
+ @token_usage[:total] += (usage[:total_tokens] || usage["total_tokens"] || 0)
452
+ @cost_tracker.track(Crimson.config.model, usage)
453
+ end
454
+
455
+ # @api private
456
+ def tools_for_message(user_input)
457
+ return cached_tool_definitions if needs_tools?(user_input)
458
+ []
459
+ end
460
+
461
+ # @api private
462
+ def cached_tool_definitions
463
+ @cached_tool_defs ||= provider_tool_definitions
464
+ end
465
+
466
+ # @api private
467
+ def needs_tools?(input)
468
+ return true if @history.any? { |m| m.is_a?(Message::ToolResult) }
469
+
470
+ lower = input.downcase.strip
471
+ return false if TRIVIAL_PATTERNS.include?(lower) || lower.length < 5
472
+
473
+ NEEDS_TOOL_PATTERNS.any? { |keyword| lower.include?(keyword) }
474
+ end
475
+
476
+ # @api private
477
+ def append_to_session(message)
478
+ return unless @session_manager && @session_id
479
+
480
+ read_files = []
481
+ modified_files = []
482
+
483
+ if message.is_a?(Message::ToolResult)
484
+ tool_name = message.name
485
+ args = find_tool_call_args(tool_name, message.tool_call_id)
486
+ if args
487
+ path = args["path"] || args[:path]
488
+ case tool_name
489
+ when "read_file"
490
+ read_files = [path].compact
491
+ when "write_file", "edit_file"
492
+ modified_files = [path].compact
493
+ end
494
+ end
495
+ end
496
+
497
+ entry = SessionEntry.from_message(message, parent_id: @last_entry_id,
498
+ read_files: read_files,
499
+ modified_files: modified_files)
500
+ if message.is_a?(Message::Assistant) && @token_usage[:total] > 0
501
+ entry.token_usage = {
502
+ "prompt" => @token_usage[:prompt],
503
+ "completion" => @token_usage[:completion],
504
+ "total" => @token_usage[:total]
505
+ }
506
+ end
507
+ @last_entry_id = entry.id
508
+ @session_buffer << entry
509
+
510
+ flush_session_buffer if @session_buffer.length >= 3
511
+ end
512
+
513
+ # @api private
514
+ def find_tool_call_args(tool_name, tool_call_id)
515
+ @history.reverse_each do |msg|
516
+ next unless msg.is_a?(Message::Assistant) && msg.tool_calls
517
+ tc = msg.tool_calls.find { |t| t.id == tool_call_id }
518
+ return tc.arguments if tc
519
+ end
520
+ nil
521
+ end
522
+
523
+ # @api private
524
+ def flush_session_buffer
525
+ return if @session_buffer.empty?
526
+ return unless @session_manager && @session_id
527
+
528
+ entries = @session_buffer.dup
529
+ @session_buffer.clear
530
+ entries.each { |e| @session_manager.append(@session_id, cwd: @session_cwd, entry: e) }
531
+ end
532
+
533
+ # @api private
534
+ def serialize_message(msg)
535
+ case msg
536
+ when Message::User
537
+ { type: "user", content: msg.content }
538
+ when Message::Assistant
539
+ {
540
+ type: "assistant",
541
+ content: msg.content,
542
+ tool_calls: msg.tool_calls.map { |tc| { id: tc.id, name: tc.name, arguments: tc.arguments } }
543
+ }
544
+ when Message::ToolResult
545
+ { type: "tool_result", tool_call_id: msg.tool_call_id, name: msg.name, content: msg.content }
546
+ end
547
+ end
548
+
549
+ # @api private
550
+ def deserialize_message(data)
551
+ case data[:type]
552
+ when "user"
553
+ Message::User.new(data[:content])
554
+ when "assistant"
555
+ tcs = (data[:tool_calls] || []).map do |tc|
556
+ Message::ToolCall.new(id: tc[:id], name: tc[:name], arguments: tc[:arguments])
557
+ end
558
+ Message::Assistant.new(content: data[:content], tool_calls: tcs)
559
+ when "tool_result"
560
+ Message::ToolResult.new(tool_call_id: data[:tool_call_id], name: data[:name], content: data[:content])
561
+ end
562
+ end
563
+ end
564
+ end