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,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Crimson
|
|
7
|
+
# Data model for a single entry in a session log.
|
|
8
|
+
# Can represent user messages, assistant responses, and tool results.
|
|
9
|
+
class SessionEntry
|
|
10
|
+
# @return [String] unique entry ID
|
|
11
|
+
# @return [String, nil] parent entry ID for threading
|
|
12
|
+
# @return [String] role (user/assistant/tool_result/system)
|
|
13
|
+
# @return [String, nil] message content
|
|
14
|
+
# @return [Array<Hash>] tool call data
|
|
15
|
+
# @return [String, nil] tool call ID for results
|
|
16
|
+
# @return [String, nil] tool name for results
|
|
17
|
+
# @return [Hash] token usage metadata
|
|
18
|
+
# @return [String] ISO 8601 timestamp
|
|
19
|
+
# @return [Array<String>] files read by this entry
|
|
20
|
+
# @return [Array<String>] files modified by this entry
|
|
21
|
+
attr_accessor :id, :parent_id, :role, :content,
|
|
22
|
+
:tool_calls, :tool_call_id, :tool_name,
|
|
23
|
+
:token_usage, :timestamp,
|
|
24
|
+
:read_files, :modified_files
|
|
25
|
+
|
|
26
|
+
# @param attrs [Hash] entry attributes
|
|
27
|
+
def initialize(attrs = {})
|
|
28
|
+
@id = attrs[:id] || SecureRandom.uuid
|
|
29
|
+
@parent_id = attrs[:parent_id]
|
|
30
|
+
@role = attrs[:role]
|
|
31
|
+
@content = attrs[:content]
|
|
32
|
+
@tool_calls = attrs[:tool_calls] || []
|
|
33
|
+
@tool_call_id = attrs[:tool_call_id]
|
|
34
|
+
@tool_name = attrs[:tool_name]
|
|
35
|
+
@token_usage = attrs[:token_usage] || {}
|
|
36
|
+
@timestamp = attrs[:timestamp] || Time.now.utc.iso8601
|
|
37
|
+
@read_files = attrs[:read_files] || []
|
|
38
|
+
@modified_files = attrs[:modified_files] || []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Convert to a hash suitable for JSON serialization.
|
|
42
|
+
# @return [Hash]
|
|
43
|
+
def to_h
|
|
44
|
+
h = {
|
|
45
|
+
id: @id,
|
|
46
|
+
parentId: @parent_id,
|
|
47
|
+
role: @role,
|
|
48
|
+
content: @content,
|
|
49
|
+
toolCalls: @tool_calls,
|
|
50
|
+
timestamp: @timestamp
|
|
51
|
+
}
|
|
52
|
+
h[:toolCallId] = @tool_call_id if @tool_call_id
|
|
53
|
+
h[:toolName] = @tool_name if @tool_name
|
|
54
|
+
h[:tokenUsage] = @token_usage unless @token_usage.empty?
|
|
55
|
+
h[:readFiles] = @read_files unless @read_files.empty?
|
|
56
|
+
h[:modifiedFiles] = @modified_files unless @modified_files.empty?
|
|
57
|
+
h
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String] JSON representation
|
|
61
|
+
def to_json(*_args)
|
|
62
|
+
JSON.generate(to_h)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Deserialize from a hash (with string or symbol keys).
|
|
66
|
+
# @param hash [Hash]
|
|
67
|
+
# @return [SessionEntry]
|
|
68
|
+
def self.from_h(hash)
|
|
69
|
+
new(
|
|
70
|
+
id: hash[:id] || hash["id"],
|
|
71
|
+
parent_id: hash[:parentId] || hash["parentId"],
|
|
72
|
+
role: hash[:role] || hash["role"],
|
|
73
|
+
content: hash[:content] || hash["content"],
|
|
74
|
+
tool_calls: hash[:toolCalls] || hash["toolCalls"] || [],
|
|
75
|
+
tool_call_id: hash[:toolCallId] || hash["toolCallId"],
|
|
76
|
+
tool_name: hash[:toolName] || hash["toolName"],
|
|
77
|
+
token_usage: hash[:tokenUsage] || hash["tokenUsage"] || {},
|
|
78
|
+
timestamp: hash[:timestamp] || hash["timestamp"],
|
|
79
|
+
read_files: hash[:readFiles] || hash["readFiles"] || [],
|
|
80
|
+
modified_files: hash[:modifiedFiles] || hash["modifiedFiles"] || []
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Build a session entry from a message object.
|
|
85
|
+
# @param message [Message::Base]
|
|
86
|
+
# @param parent_id [String, nil]
|
|
87
|
+
# @param read_files [Array<String>]
|
|
88
|
+
# @param modified_files [Array<String>]
|
|
89
|
+
# @return [SessionEntry]
|
|
90
|
+
def self.from_message(message, parent_id:, read_files: [], modified_files: [])
|
|
91
|
+
case message
|
|
92
|
+
when Message::User
|
|
93
|
+
new(role: "user", content: message.content, parent_id: parent_id)
|
|
94
|
+
when Message::Assistant
|
|
95
|
+
tc_data = message.tool_calls.map do |tc|
|
|
96
|
+
{ "id" => tc.id, "name" => tc.name, "arguments" => tc.arguments }
|
|
97
|
+
end
|
|
98
|
+
new(
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: message.content,
|
|
101
|
+
parent_id: parent_id,
|
|
102
|
+
tool_calls: tc_data
|
|
103
|
+
)
|
|
104
|
+
when Message::ToolResult
|
|
105
|
+
new(
|
|
106
|
+
role: "tool_result",
|
|
107
|
+
content: message.content,
|
|
108
|
+
parent_id: parent_id,
|
|
109
|
+
tool_call_id: message.tool_call_id,
|
|
110
|
+
tool_name: message.name,
|
|
111
|
+
read_files: read_files,
|
|
112
|
+
modified_files: modified_files
|
|
113
|
+
)
|
|
114
|
+
else
|
|
115
|
+
new(role: "system", content: message&.content.to_s, parent_id: parent_id)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Convert back to a Message object.
|
|
120
|
+
# @return [Message::Base, nil]
|
|
121
|
+
def to_message
|
|
122
|
+
case @role
|
|
123
|
+
when "user"
|
|
124
|
+
Message::User.new(@content)
|
|
125
|
+
when "assistant"
|
|
126
|
+
tcs = (@tool_calls || []).map do |tc|
|
|
127
|
+
Message::ToolCall.new(
|
|
128
|
+
id: tc["id"],
|
|
129
|
+
name: tc["name"],
|
|
130
|
+
arguments: tc["arguments"]
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
Message::Assistant.new(content: @content, tool_calls: tcs)
|
|
134
|
+
when "tool_result"
|
|
135
|
+
Message::ToolResult.new(
|
|
136
|
+
tool_call_id: @tool_call_id,
|
|
137
|
+
name: @tool_name,
|
|
138
|
+
content: @content
|
|
139
|
+
)
|
|
140
|
+
else
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "digest"
|
|
7
|
+
|
|
8
|
+
module Crimson
|
|
9
|
+
# Metadata for a session listing.
|
|
10
|
+
SessionMeta = Struct.new(:id, :entry_count, :last_timestamp, :preview, :name, :mtime, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
# JSONL-based session persistence manager.
|
|
13
|
+
# Sessions are stored as per-directory JSONL files with a header entry.
|
|
14
|
+
class SessionManager
|
|
15
|
+
# Current session file format version.
|
|
16
|
+
CURRENT_SESSION_VERSION = 1
|
|
17
|
+
|
|
18
|
+
# @param sessions_dir [String, nil] base directory for session storage
|
|
19
|
+
def initialize(sessions_dir: nil)
|
|
20
|
+
@sessions_dir = sessions_dir || File.join(Crimson::CONFIG_DIR, "sessions")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create a new session and return its ID.
|
|
24
|
+
# @param cwd [String] working directory for the session
|
|
25
|
+
# @param parent_session [String, nil] optional parent session ID
|
|
26
|
+
# @return [String] session ID
|
|
27
|
+
def create(cwd:, parent_session: nil)
|
|
28
|
+
id = SecureRandom.uuid
|
|
29
|
+
FileUtils.mkdir_p(session_dir(cwd: cwd))
|
|
30
|
+
header = {
|
|
31
|
+
type: "session_header",
|
|
32
|
+
version: CURRENT_SESSION_VERSION,
|
|
33
|
+
id: id,
|
|
34
|
+
timestamp: Time.now.utc.iso8601,
|
|
35
|
+
cwd: cwd,
|
|
36
|
+
parentSession: parent_session
|
|
37
|
+
}
|
|
38
|
+
File.write(session_file(id, cwd: cwd), JSON.generate(header) + "\n")
|
|
39
|
+
id
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Load all entries for a session.
|
|
43
|
+
# @param session_id [String]
|
|
44
|
+
# @param cwd [String] working directory
|
|
45
|
+
# @return [Array<SessionEntry>]
|
|
46
|
+
def load(session_id, cwd:)
|
|
47
|
+
file = session_file(session_id, cwd: cwd)
|
|
48
|
+
return [] unless File.exist?(file)
|
|
49
|
+
|
|
50
|
+
entries = []
|
|
51
|
+
File.foreach(file) do |line|
|
|
52
|
+
line = line.strip
|
|
53
|
+
next if line.empty?
|
|
54
|
+
begin
|
|
55
|
+
parsed = JSON.parse(line)
|
|
56
|
+
next if parsed["type"] == "session_header"
|
|
57
|
+
entries << SessionEntry.from_h(parsed)
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
entries
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Load only the header entry of a session.
|
|
66
|
+
# @param session_id [String]
|
|
67
|
+
# @param cwd [String] working directory
|
|
68
|
+
# @return [Hash, nil]
|
|
69
|
+
def load_header(session_id, cwd:)
|
|
70
|
+
file = session_file(session_id, cwd: cwd)
|
|
71
|
+
return nil unless File.exist?(file)
|
|
72
|
+
|
|
73
|
+
File.foreach(file) do |line|
|
|
74
|
+
line = line.strip
|
|
75
|
+
next if line.empty?
|
|
76
|
+
begin
|
|
77
|
+
parsed = JSON.parse(line)
|
|
78
|
+
return parsed if parsed["type"] == "session_header"
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Append an entry to a session.
|
|
87
|
+
# @param session_id [String]
|
|
88
|
+
# @param cwd [String] working directory
|
|
89
|
+
# @param entry [SessionEntry]
|
|
90
|
+
# @return [void]
|
|
91
|
+
def append(session_id, cwd:, entry:)
|
|
92
|
+
file = session_file(session_id, cwd: cwd)
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(file))
|
|
94
|
+
File.open(file, "a") { |f| f.puts(entry.to_json) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# List all sessions for a given directory, sorted by mtime (newest first).
|
|
98
|
+
# @param cwd [String] working directory
|
|
99
|
+
# @return [Array<SessionMeta>]
|
|
100
|
+
def list(cwd:)
|
|
101
|
+
dir = session_dir(cwd: cwd)
|
|
102
|
+
return [] unless Dir.exist?(dir)
|
|
103
|
+
|
|
104
|
+
Dir.glob(File.join(dir, "*.jsonl")).filter_map do |file|
|
|
105
|
+
id = File.basename(file, ".jsonl")
|
|
106
|
+
entries = []
|
|
107
|
+
last_user_content = nil
|
|
108
|
+
session_name = nil
|
|
109
|
+
|
|
110
|
+
File.foreach(file) do |line|
|
|
111
|
+
line = line.strip
|
|
112
|
+
next if line.empty?
|
|
113
|
+
begin
|
|
114
|
+
parsed = JSON.parse(line)
|
|
115
|
+
if parsed["type"] == "session_header"
|
|
116
|
+
session_name = parsed["name"]
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
entry = SessionEntry.from_h(parsed)
|
|
120
|
+
entries << entry
|
|
121
|
+
last_user_content = entry.content if entry.role == "user"
|
|
122
|
+
rescue JSON::ParserError
|
|
123
|
+
next
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
next if entries.empty?
|
|
128
|
+
|
|
129
|
+
SessionMeta.new(
|
|
130
|
+
id: id,
|
|
131
|
+
entry_count: entries.length,
|
|
132
|
+
last_timestamp: entries.last.timestamp,
|
|
133
|
+
preview: last_user_content && last_user_content.length > 80 ? last_user_content[0, 77] + "..." : last_user_content,
|
|
134
|
+
name: session_name,
|
|
135
|
+
mtime: File.mtime(file)
|
|
136
|
+
)
|
|
137
|
+
end.sort_by { |s| s.mtime }.reverse
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Set the human-readable name for a session.
|
|
141
|
+
# @param session_id [String]
|
|
142
|
+
# @param cwd [String] working directory
|
|
143
|
+
# @param name [String]
|
|
144
|
+
# @return [void]
|
|
145
|
+
def set_name(session_id, cwd:, name:)
|
|
146
|
+
file = session_file(session_id, cwd: cwd)
|
|
147
|
+
return unless File.exist?(file)
|
|
148
|
+
|
|
149
|
+
lines = File.readlines(file)
|
|
150
|
+
lines.each_with_index do |line, idx|
|
|
151
|
+
stripped = line.strip
|
|
152
|
+
next if stripped.empty?
|
|
153
|
+
begin
|
|
154
|
+
parsed = JSON.parse(stripped)
|
|
155
|
+
if parsed["type"] == "session_header"
|
|
156
|
+
parsed["name"] = name
|
|
157
|
+
lines[idx] = JSON.generate(parsed) + "\n"
|
|
158
|
+
File.write(file, lines.join)
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
rescue JSON::ParserError
|
|
162
|
+
next
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Get the most recent session for a directory.
|
|
168
|
+
# @param cwd [String] working directory
|
|
169
|
+
# @return [SessionMeta, nil]
|
|
170
|
+
def latest(cwd:)
|
|
171
|
+
sessions = list(cwd: cwd)
|
|
172
|
+
sessions.first
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Fork a session at a specific entry, creating a new branching session.
|
|
176
|
+
# @param session_id [String]
|
|
177
|
+
# @param cwd [String] working directory
|
|
178
|
+
# @param from_entry_id [String] entry ID to fork at
|
|
179
|
+
# @return [String] new session ID
|
|
180
|
+
# @raise [RuntimeError] if entry is not found
|
|
181
|
+
def fork(session_id, cwd:, from_entry_id:)
|
|
182
|
+
entries = load(session_id, cwd: cwd)
|
|
183
|
+
fork_point = entries.index { |e| e.id == from_entry_id }
|
|
184
|
+
raise "Entry #{from_entry_id} not found in session #{session_id}" unless fork_point
|
|
185
|
+
|
|
186
|
+
prefix = entries[0..fork_point]
|
|
187
|
+
new_id = SecureRandom.uuid
|
|
188
|
+
prefix.each { |e| append(new_id, cwd: cwd, entry: e) }
|
|
189
|
+
new_id
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Delete a session file.
|
|
193
|
+
# @param session_id [String]
|
|
194
|
+
# @param cwd [String] working directory
|
|
195
|
+
# @return [void]
|
|
196
|
+
def delete(session_id, cwd:)
|
|
197
|
+
file = session_file(session_id, cwd: cwd)
|
|
198
|
+
File.delete(file) if File.exist?(file)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @api private
|
|
202
|
+
def session_file(session_id, cwd:)
|
|
203
|
+
File.join(session_dir(cwd: cwd), "#{session_id}.jsonl")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Compute a short directory hash for session folder naming.
|
|
207
|
+
# @api private
|
|
208
|
+
def dir_hash(cwd:)
|
|
209
|
+
Digest::SHA256.hexdigest(cwd)[0, 12]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
# @api private
|
|
215
|
+
def session_dir(cwd:)
|
|
216
|
+
File.join(@sessions_dir, dir_hash(cwd: cwd))
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tty-prompt'
|
|
4
|
+
require 'tty-spinner'
|
|
5
|
+
require 'net/http'
|
|
6
|
+
require 'uri'
|
|
7
|
+
require 'json'
|
|
8
|
+
require 'fileutils'
|
|
9
|
+
require_relative 'providers'
|
|
10
|
+
|
|
11
|
+
module Crimson
|
|
12
|
+
# First-run setup wizard that guides users through provider selection and configuration.
|
|
13
|
+
class Setup
|
|
14
|
+
# Run the full first-time setup including copying default skills.
|
|
15
|
+
# @return [void]
|
|
16
|
+
def self.first_run
|
|
17
|
+
copy_default_skills
|
|
18
|
+
run
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Run the interactive configuration wizard.
|
|
22
|
+
# @return [void]
|
|
23
|
+
def self.run
|
|
24
|
+
prompt = TTY::Prompt.new
|
|
25
|
+
puts "Crimson Setup"
|
|
26
|
+
puts "============="
|
|
27
|
+
puts
|
|
28
|
+
|
|
29
|
+
provider = select_provider(prompt)
|
|
30
|
+
api_key = ask_for_api_key(prompt, provider)
|
|
31
|
+
base_url = ask_for_base_url(prompt) if provider == :custom
|
|
32
|
+
models = fetch_models(provider, api_key, base_url)
|
|
33
|
+
|
|
34
|
+
if models.empty?
|
|
35
|
+
puts "No models found for the provided API key."
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
model = select_model(prompt, models)
|
|
40
|
+
save_config(provider, api_key, base_url, model)
|
|
41
|
+
|
|
42
|
+
puts
|
|
43
|
+
puts "Configuration saved to #{Crimson::CONFIG_FILE}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# @api private
|
|
49
|
+
def self.select_provider(prompt)
|
|
50
|
+
prompt.select("Select a provider:",
|
|
51
|
+
PROVIDERS.map { |key, data| { name: data[:name], value: key } }
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @api private
|
|
56
|
+
def self.ask_for_api_key(prompt, provider)
|
|
57
|
+
prompt.mask("Enter your #{PROVIDERS[provider][:name]} API key:")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @api private
|
|
61
|
+
def self.ask_for_base_url(prompt)
|
|
62
|
+
prompt.ask("Enter the base URL for the provider:")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @api private
|
|
66
|
+
def self.select_model(prompt, models)
|
|
67
|
+
prompt.select("Select a model:", models)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @api private
|
|
71
|
+
def self.fetch_models(provider, api_key, base_url = nil)
|
|
72
|
+
spinner = TTY::Spinner.new("[:spinner] Fetching models...", format: :dots)
|
|
73
|
+
spinner.auto_spin
|
|
74
|
+
|
|
75
|
+
url_str = base_url || PROVIDERS[provider][:base_url]
|
|
76
|
+
url_str += MODELS_ENDPOINT
|
|
77
|
+
uri = URI(url_str)
|
|
78
|
+
|
|
79
|
+
headers = PROVIDERS[provider][:auth_headers].call(api_key)
|
|
80
|
+
|
|
81
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
82
|
+
http.use_ssl = uri.scheme == "https"
|
|
83
|
+
http.open_timeout = 10
|
|
84
|
+
http.read_timeout = 30
|
|
85
|
+
|
|
86
|
+
request = Net::HTTP::Get.new(uri.request_uri, headers)
|
|
87
|
+
|
|
88
|
+
begin
|
|
89
|
+
response = http.request(request)
|
|
90
|
+
|
|
91
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
92
|
+
spinner.error("Failed!")
|
|
93
|
+
return []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
data = JSON.parse(response.body)
|
|
97
|
+
models = data["data"].map { |model| model["id"] }
|
|
98
|
+
|
|
99
|
+
spinner.success("Done!")
|
|
100
|
+
models
|
|
101
|
+
rescue => e
|
|
102
|
+
spinner.error("Error: #{e.message}")
|
|
103
|
+
[]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# @api private
|
|
108
|
+
def self.save_config(provider, api_key, base_url, model)
|
|
109
|
+
config = Crimson::Config.new(
|
|
110
|
+
provider: provider.to_s,
|
|
111
|
+
model: model,
|
|
112
|
+
api_key: api_key,
|
|
113
|
+
base_url: base_url,
|
|
114
|
+
max_tokens: 1000
|
|
115
|
+
)
|
|
116
|
+
config.save
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @api private
|
|
120
|
+
def self.copy_default_skills
|
|
121
|
+
FileUtils.mkdir_p(Crimson::SKILLS_DIR)
|
|
122
|
+
|
|
123
|
+
gem_root = File.expand_path("../..", __dir__)
|
|
124
|
+
bundled_skills_dir = File.join(gem_root, "skills")
|
|
125
|
+
|
|
126
|
+
return unless Dir.exist?(bundled_skills_dir)
|
|
127
|
+
|
|
128
|
+
Dir.glob(File.join(bundled_skills_dir, "*.md")).each do |file|
|
|
129
|
+
dest = File.join(Crimson::SKILLS_DIR, File.basename(file))
|
|
130
|
+
FileUtils.cp(file, dest) unless File.exist?(dest)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Crimson
|
|
4
|
+
# Routes user intents to skills based on trigger keyword matching.
|
|
5
|
+
# Supports auto-inject skills triggered by tool usage and domain-based priority sorting.
|
|
6
|
+
class SkillRouter
|
|
7
|
+
# Directory where bundled skills are stored in the repository.
|
|
8
|
+
REPO_SKILLS_DIR = File.expand_path("../../skills", __dir__)
|
|
9
|
+
|
|
10
|
+
# Skill domain descriptor.
|
|
11
|
+
Domain = Struct.new(:name, :priority, keyword_init: true)
|
|
12
|
+
|
|
13
|
+
# Built-in skill domains with their priority levels.
|
|
14
|
+
DOMAINS = {
|
|
15
|
+
engineering: Domain.new(name: "engineering", priority: 10),
|
|
16
|
+
analysis: Domain.new(name: "analysis", priority: 5),
|
|
17
|
+
communication: Domain.new(name: "communication", priority: 5),
|
|
18
|
+
safety: Domain.new(name: "safety", priority: 20),
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
MAX_CONDITIONAL_SKILLS = 2
|
|
22
|
+
|
|
23
|
+
# @param skills_dirs [Array<String>, nil] directories to search for skill markdown files
|
|
24
|
+
def initialize(skills_dirs: nil)
|
|
25
|
+
@skills_dirs = skills_dirs || [REPO_SKILLS_DIR]
|
|
26
|
+
@manifests = {}
|
|
27
|
+
@skill_paths = {}
|
|
28
|
+
load_manifests
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Resolve which skills are relevant to a user message.
|
|
32
|
+
# @param user_message [String] the user's input
|
|
33
|
+
# @param tools_invoked [Array<String>] tools used in the current turn
|
|
34
|
+
# @return [Array<String>] list of active skill names (always includes "coding")
|
|
35
|
+
def resolve(user_message, tools_invoked: [])
|
|
36
|
+
lower = user_message.to_s.downcase.strip
|
|
37
|
+
matched = []
|
|
38
|
+
|
|
39
|
+
@manifests.each do |name, manifest|
|
|
40
|
+
next if manifest[:auto_inject]
|
|
41
|
+
next unless triggers_match?(lower, manifest[:triggers])
|
|
42
|
+
matched << { name: name, priority: manifest[:domain_priority], domain: manifest[:domain] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
matched.sort_by! { |s| -s[:priority] }
|
|
46
|
+
|
|
47
|
+
result = ["coding"]
|
|
48
|
+
seen_domains = Set.new
|
|
49
|
+
|
|
50
|
+
matched.each do |skill|
|
|
51
|
+
break if result.length >= MAX_CONDITIONAL_SKILLS + 1
|
|
52
|
+
next if seen_domains.include?(skill[:domain])
|
|
53
|
+
result << skill[:name].to_s
|
|
54
|
+
seen_domains << skill[:domain]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@manifests.each do |name, manifest|
|
|
58
|
+
next unless manifest[:auto_inject]
|
|
59
|
+
next unless (tools_invoked & manifest[:auto_inject_tools]).any?
|
|
60
|
+
result << name.to_s unless result.include?(name.to_s)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Load a skill's content (with front matter stripped).
|
|
67
|
+
# @param name [String] skill name
|
|
68
|
+
# @return [String, nil] skill content or nil if not found
|
|
69
|
+
def load_skill(name)
|
|
70
|
+
path = @skill_paths[name.to_sym]
|
|
71
|
+
return nil unless path && File.exist?(path)
|
|
72
|
+
content = File.read(path)
|
|
73
|
+
strip_front_matter(content)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Array<String>] all discovered skill names
|
|
77
|
+
def skill_names
|
|
78
|
+
@manifests.keys.map(&:to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# @api private
|
|
84
|
+
def load_manifests
|
|
85
|
+
@skills_dirs.each do |dir|
|
|
86
|
+
next unless Dir.exist?(dir)
|
|
87
|
+
Dir.glob(File.join(dir, "*.md")).each do |path|
|
|
88
|
+
name = File.basename(path, ".md").to_sym
|
|
89
|
+
next if @manifests.key?(name)
|
|
90
|
+
content = File.read(path)
|
|
91
|
+
manifest = parse_front_matter(content, name)
|
|
92
|
+
if manifest
|
|
93
|
+
@manifests[name] = manifest
|
|
94
|
+
@skill_paths[name] = path
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
rescue Errno::ENOENT
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @api private
|
|
103
|
+
def parse_front_matter(content, name)
|
|
104
|
+
return default_manifest(name) unless content.start_with?("---")
|
|
105
|
+
|
|
106
|
+
parts = content.split("---", 3)
|
|
107
|
+
return default_manifest(name) if parts.length < 3
|
|
108
|
+
|
|
109
|
+
yaml_block = parts[1]
|
|
110
|
+
manifest = default_manifest(name)
|
|
111
|
+
|
|
112
|
+
yaml_block.each_line do |line|
|
|
113
|
+
line = line.strip
|
|
114
|
+
case line
|
|
115
|
+
when /^domain:\s*(\S+)/
|
|
116
|
+
domain_name = $1.to_sym
|
|
117
|
+
manifest[:domain] = domain_name
|
|
118
|
+
manifest[:domain_priority] = DOMAINS[domain_name]&.priority || 0
|
|
119
|
+
when /^triggers:\s*\[(.+)\]/
|
|
120
|
+
manifest[:triggers] = $1.split(",").map { |t| t.strip.downcase }
|
|
121
|
+
when /^priority:\s*(\d+)/
|
|
122
|
+
manifest[:priority] = $1.to_i
|
|
123
|
+
when /^auto_inject:\s*true/
|
|
124
|
+
manifest[:auto_inject] = true
|
|
125
|
+
manifest[:auto_inject_tools] = %w[write_file edit_file]
|
|
126
|
+
when /^auto_inject_tools:\s*\[(.+)\]/
|
|
127
|
+
manifest[:auto_inject_tools] = $1.split(",").map { |t| t.strip }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
manifest[:triggers]&.map!(&:downcase)
|
|
132
|
+
manifest
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @api private
|
|
136
|
+
def default_manifest(name)
|
|
137
|
+
{
|
|
138
|
+
domain: :base,
|
|
139
|
+
domain_priority: 0,
|
|
140
|
+
triggers: [],
|
|
141
|
+
priority: 0,
|
|
142
|
+
auto_inject: false,
|
|
143
|
+
auto_inject_tools: [],
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @api private
|
|
148
|
+
def triggers_match?(message, triggers)
|
|
149
|
+
triggers&.any? do |t|
|
|
150
|
+
if t.include?(" ")
|
|
151
|
+
message.include?(t)
|
|
152
|
+
else
|
|
153
|
+
message.match?(/\b#{Regexp.escape(t)}\b/)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @api private
|
|
159
|
+
def strip_front_matter(content)
|
|
160
|
+
return content unless content.start_with?("---")
|
|
161
|
+
parts = content.split("---", 3)
|
|
162
|
+
parts.length >= 3 ? parts[2].strip : content
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|