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