x_aeon_agents 1.0.28
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/AGENTS.md +136 -0
- data/CHANGELOG.md +398 -0
- data/README.md +579 -0
- data/TODO.md +48 -0
- data/bin/xaa +5 -0
- data/lib/x_aeon_agents/agent_defaults.rb +60 -0
- data/lib/x_aeon_agents/agent_options.rb +58 -0
- data/lib/x_aeon_agents/agents/coder_agent.rb +82 -0
- data/lib/x_aeon_agents/agents/committer_agent.rb +71 -0
- data/lib/x_aeon_agents/agents/developer_agent.rb +235 -0
- data/lib/x_aeon_agents/agents/diff_interpreter_agent.rb +80 -0
- data/lib/x_aeon_agents/agents/documenter_agent.rb +44 -0
- data/lib/x_aeon_agents/agents/executor_agent.rb +9 -0
- data/lib/x_aeon_agents/agents/feedback_analyst_agent.rb +62 -0
- data/lib/x_aeon_agents/agents/gh_comments.gql +64 -0
- data/lib/x_aeon_agents/agents/git_diff_interpreter_agent.rb +57 -0
- data/lib/x_aeon_agents/agents/issue_implementer_agent.rb +88 -0
- data/lib/x_aeon_agents/agents/one_line_code_diff_summarizer_agent.rb +57 -0
- data/lib/x_aeon_agents/agents/plan_generator_agent.rb +51 -0
- data/lib/x_aeon_agents/agents/planner_agent.rb +89 -0
- data/lib/x_aeon_agents/agents/pull_request_creator_agent.rb +144 -0
- data/lib/x_aeon_agents/agents/readme/about_analyzer_agent.rb +55 -0
- data/lib/x_aeon_agents/agents/readme/contributing_agent.rb +47 -0
- data/lib/x_aeon_agents/agents/readme/development_agent.rb +54 -0
- data/lib/x_aeon_agents/agents/readme/documentation_agent.rb +43 -0
- data/lib/x_aeon_agents/agents/readme/features_agent.rb +45 -0
- data/lib/x_aeon_agents/agents/readme/how_it_works_agent.rb +47 -0
- data/lib/x_aeon_agents/agents/readme/license_agent.rb +43 -0
- data/lib/x_aeon_agents/agents/readme/public_api_agent.rb +44 -0
- data/lib/x_aeon_agents/agents/readme/quick_start_agent.rb +50 -0
- data/lib/x_aeon_agents/agents/readme/requirements_agent.rb +43 -0
- data/lib/x_aeon_agents/agents/readme_generator_agent.rb +438 -0
- data/lib/x_aeon_agents/agents/review_resolver_agent.rb +236 -0
- data/lib/x_aeon_agents/agents/review_responder_agent.rb +64 -0
- data/lib/x_aeon_agents/agents/skill_generator_agent.rb +95 -0
- data/lib/x_aeon_agents/agents/skill_installer_agent.rb +103 -0
- data/lib/x_aeon_agents/agents/task_starter_agent.rb +66 -0
- data/lib/x_aeon_agents/agents/tester_agent.rb +44 -0
- data/lib/x_aeon_agents/cli.rb +384 -0
- data/lib/x_aeon_agents/config.rb +110 -0
- data/lib/x_aeon_agents/gen_helpers.rb +270 -0
- data/lib/x_aeon_agents/helpers.rb +211 -0
- data/lib/x_aeon_agents/logger.rb +21 -0
- data/lib/x_aeon_agents/providers/cline.rb +78 -0
- data/lib/x_aeon_agents/version.rb +6 -0
- data/lib/x_aeon_agents.rb +38 -0
- metadata +296 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require 'erb'
|
|
2
|
+
require 'yaml'
|
|
3
|
+
|
|
4
|
+
module XAeonAgents
|
|
5
|
+
# Helper methods for generating skill content
|
|
6
|
+
module GenHelpers
|
|
7
|
+
# @!group Public API
|
|
8
|
+
|
|
9
|
+
# Define a skill metadata.
|
|
10
|
+
# This should always be the first call in a skill ERB file.
|
|
11
|
+
# It also returns the corresponding YAML frontmatter.
|
|
12
|
+
# The name is automatically derived from skill_name.
|
|
13
|
+
#
|
|
14
|
+
# @param description [String] Description of the skill
|
|
15
|
+
# @param dependencies [Array<String>] List of skills dependencies
|
|
16
|
+
# @param plan [Boolean] Is this skill applicable to plan mode?
|
|
17
|
+
# @param metadata [Hash] Optional metadata key-value pairs
|
|
18
|
+
# @return [String] The complete YAML frontmatter block (including --- delimiters)
|
|
19
|
+
def skill(description:, dependencies: [], plan: false, metadata: {})
|
|
20
|
+
@plan = plan
|
|
21
|
+
frontmatter = {
|
|
22
|
+
'name' => name,
|
|
23
|
+
'description' => "#{description}#{' Use this skill also in Plan mode.' if plan}"
|
|
24
|
+
}
|
|
25
|
+
metadata['agent'] = 'Plan' if plan
|
|
26
|
+
metadata['dependencies'] = dependencies unless dependencies.empty?
|
|
27
|
+
frontmatter['metadata'] = metadata.transform_keys(&:to_s) unless metadata.empty?
|
|
28
|
+
"#{YAML.dump(frontmatter, line_width: -1).chomp}\n---"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Define or get the skill goal to be used in ERB templates
|
|
32
|
+
#
|
|
33
|
+
# @param goal_desc [String, nil] The skill goal, or nil to retrieve the previously set skill goal
|
|
34
|
+
# @return [String] The skill goal
|
|
35
|
+
def goal(goal_desc = nil)
|
|
36
|
+
@skill_goal = goal_desc unless goal_desc.nil?
|
|
37
|
+
@skill_goal
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get the skill goal as a sentence
|
|
41
|
+
# Prerequisite: skill_goal should be set before.
|
|
42
|
+
#
|
|
43
|
+
# @return [String] The skill goal as useable inside a sentence
|
|
44
|
+
def goal_sentence
|
|
45
|
+
"#{@skill_goal[0].downcase}#{@skill_goal[1..]}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Return the prompt to announce that the agent is working on a skill.
|
|
49
|
+
# Prerequisite: skill_goal should be set before.
|
|
50
|
+
def announce
|
|
51
|
+
"Always tell the user \"SKILL: I am #{goal_sentence}\" to inform the user that you are running this skill."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Return a default temporary folder that agents can use in a project.
|
|
55
|
+
# It's better to force it to the agents, as some models will try weird CLI commands to create temporary files otherwise.
|
|
56
|
+
#
|
|
57
|
+
# @return [String] Temporary folder path
|
|
58
|
+
def tmp_path
|
|
59
|
+
"#{Config.data_dir}/tmp"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Generate a rule documentation block with examples and rationale.
|
|
63
|
+
#
|
|
64
|
+
# @param title [String] The rule title
|
|
65
|
+
# @param description [String, nil] The additional description
|
|
66
|
+
# @param type [Symbol] The code block language type (e.g., :bash, :ruby)
|
|
67
|
+
# @param bad [String, nil] The incorrect example
|
|
68
|
+
# @param good [String, nil] The correct example
|
|
69
|
+
# @param rationale [String, nil] The explanation for why this rule exists
|
|
70
|
+
# @return [String] The formatted markdown documentation for the rule
|
|
71
|
+
def rule(title, description: nil, type: :ruby, bad: nil, good: nil, rationale: nil)
|
|
72
|
+
markdown_sections = [
|
|
73
|
+
<<~EO_MARKDOWN
|
|
74
|
+
### Rule: #{title}#{"\n\n#{description.strip}" unless description.nil?}
|
|
75
|
+
EO_MARKDOWN
|
|
76
|
+
]
|
|
77
|
+
unless bad.nil?
|
|
78
|
+
markdown_sections << <<~EO_MARKDOWN
|
|
79
|
+
#### Example: Incorrect
|
|
80
|
+
|
|
81
|
+
```#{type}
|
|
82
|
+
#{bad.strip}
|
|
83
|
+
```
|
|
84
|
+
EO_MARKDOWN
|
|
85
|
+
end
|
|
86
|
+
unless good.nil?
|
|
87
|
+
markdown_sections << <<~EO_MARKDOWN
|
|
88
|
+
#### Example: Correct
|
|
89
|
+
|
|
90
|
+
```#{type}
|
|
91
|
+
#{good.strip}
|
|
92
|
+
```
|
|
93
|
+
EO_MARKDOWN
|
|
94
|
+
end
|
|
95
|
+
unless rationale.nil?
|
|
96
|
+
markdown_sections << <<~EO_MARKDOWN
|
|
97
|
+
#### Rationale
|
|
98
|
+
|
|
99
|
+
#{rationale}
|
|
100
|
+
EO_MARKDOWN
|
|
101
|
+
end
|
|
102
|
+
markdown_sections.join("\n").strip
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Define an ordered todo list for a skill.
|
|
106
|
+
# Captures the ERB block content, parses its ## sections, numbers them starting at 2
|
|
107
|
+
# (section 1 "Inform the USER" is auto-generated), and wraps everything with the
|
|
108
|
+
# standard skill header, checklist initialization, and final verification sections.
|
|
109
|
+
#
|
|
110
|
+
# @param erb_block [#call] ERB block containing the markdown sections
|
|
111
|
+
# @return [String] The formatted todo list
|
|
112
|
+
def ordered_todo_list(&erb_block)
|
|
113
|
+
transform_erb_block(erb_block) do |erb_content|
|
|
114
|
+
# Split into sections by ## headings
|
|
115
|
+
# Number sections starting from 2 and strip trailing whitespace
|
|
116
|
+
step_number = 2
|
|
117
|
+
numbered_sections = erb_content
|
|
118
|
+
.strip
|
|
119
|
+
.split(/^(?=### )/)
|
|
120
|
+
.reject { |s| s.strip.empty? }
|
|
121
|
+
.map do |section|
|
|
122
|
+
numbered = section.sub(/^### /, "### #{step_number}. ").rstrip
|
|
123
|
+
step_number += 1
|
|
124
|
+
numbered
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Compose the full output and append directly to ERB buffer
|
|
128
|
+
# (we use <% %> not <%= %> since standard ERB doesn't support <%= method do %>)
|
|
129
|
+
<<~EO_MARKDOWN
|
|
130
|
+
## Sequential steps to be followed when using this skill
|
|
131
|
+
|
|
132
|
+
When #{goal_sentence}, follow those steps.
|
|
133
|
+
|
|
134
|
+
#{init_skill_checklist(name).rstrip}
|
|
135
|
+
|
|
136
|
+
### 1. Inform the user
|
|
137
|
+
|
|
138
|
+
- #{announce}
|
|
139
|
+
|
|
140
|
+
#{numbered_sections.join("\n\n")}
|
|
141
|
+
|
|
142
|
+
#{validate_skill_checklist(name).rstrip}
|
|
143
|
+
EO_MARKDOWN
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Return the skill config hash from its .skill_config.yml file, if it exists.
|
|
148
|
+
#
|
|
149
|
+
# @param skill_name [String] The skill name (matching a directory under skills.src/)
|
|
150
|
+
# @return [Hash] The YAML config hash, or an empty Hash if no config file exists
|
|
151
|
+
def self.config(skill_name)
|
|
152
|
+
skill_config_file = "skills.src/#{skill_name}/.skill_config.yml"
|
|
153
|
+
File.exist?(skill_config_file) ? YAML.load_file(skill_config_file) : {}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Return the skill being generated
|
|
157
|
+
#
|
|
158
|
+
# @return [String] Skill name being generated
|
|
159
|
+
def name
|
|
160
|
+
current_erb_file.match(%r{/skills\.src/([^/]+)/})[1]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Generate the "When to use it" section for a skill.
|
|
164
|
+
# This helper automatically includes standard items and custom usage instructions.
|
|
165
|
+
#
|
|
166
|
+
# @param erb_block [#call] ERB block containing custom usage instructions
|
|
167
|
+
# @return [String] The formatted "When to use it" section
|
|
168
|
+
def when_to_use(&erb_block)
|
|
169
|
+
transform_erb_block(erb_block) do |erb_content|
|
|
170
|
+
blocks = []
|
|
171
|
+
blocks << '- This skill can be used when in Plan mode.' if @plan
|
|
172
|
+
blocks << "- Always use it every time another skill specifically mentions `skill: #{name}`."
|
|
173
|
+
blocks << erb_content
|
|
174
|
+
<<~EO_MARKDOWN
|
|
175
|
+
## When to use it
|
|
176
|
+
|
|
177
|
+
#{blocks.join("\n").rstrip}
|
|
178
|
+
EO_MARKDOWN
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Small class that can serve as a container for ERB evaluation with our DSL
|
|
183
|
+
class ErbEvaluator
|
|
184
|
+
include GenHelpers
|
|
185
|
+
|
|
186
|
+
# Constructor
|
|
187
|
+
#
|
|
188
|
+
# @param erb_file [String] File containing the ERB template
|
|
189
|
+
def initialize(erb_file)
|
|
190
|
+
@erb = ERB.new(File.read(erb_file), trim_mode: '-')
|
|
191
|
+
# Use filename for better error reporting
|
|
192
|
+
@erb.filename = erb_file
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Evaluate the ERB template
|
|
196
|
+
#
|
|
197
|
+
# @return [String] The evaluated ERB result
|
|
198
|
+
def result
|
|
199
|
+
@erb.result(binding)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
# Return the execution checklist initialization section
|
|
206
|
+
#
|
|
207
|
+
# @param checklist_name [String] Name to be given to this checklist
|
|
208
|
+
# @return [String] The execution checklist section
|
|
209
|
+
def init_skill_checklist(checklist_name)
|
|
210
|
+
<<~EO_MARKDOWN
|
|
211
|
+
### Create the #{checklist_name} Execution Checklist (MANDATORY)
|
|
212
|
+
|
|
213
|
+
- Before executing anything, create a checklist named #{checklist_name} Execution Checklist with all steps of these instructions.
|
|
214
|
+
- The #{checklist_name} Execution Checklist must include all numbered steps explicitly.
|
|
215
|
+
- After completing each step of these instructions, mark the item in the #{checklist_name} Execution Checklist as completed.
|
|
216
|
+
- Do not skip any item.
|
|
217
|
+
- If an item cannot be executed, explicitly explain why.
|
|
218
|
+
- Never mark the task as completed while any item from the #{checklist_name} Execution Checklist remains open.
|
|
219
|
+
EO_MARKDOWN
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Return the final verification section
|
|
223
|
+
#
|
|
224
|
+
# @param checklist_name [String] Name to be given to this checklist
|
|
225
|
+
# @return [String] The final verification section
|
|
226
|
+
def validate_skill_checklist(checklist_name)
|
|
227
|
+
<<~EO_MARKDOWN
|
|
228
|
+
### Final Verification (MANDATORY)
|
|
229
|
+
|
|
230
|
+
Before declaring the task complete:
|
|
231
|
+
|
|
232
|
+
- Re-list all numbered steps from the #{checklist_name} Execution Checklist.
|
|
233
|
+
- Confirm each one was executed.
|
|
234
|
+
- If any step was not executed, execute it now.
|
|
235
|
+
EO_MARKDOWN
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Return the ERB file being generated
|
|
239
|
+
#
|
|
240
|
+
# @return [String] ERB file being generated
|
|
241
|
+
def current_erb_file
|
|
242
|
+
file_found = caller.find { |stack_trace| stack_trace =~ %r{(/skills\.src/.+\.erb)} }
|
|
243
|
+
raise "Unable to find ERB file among stack:\n#{caller.join("\n")}" if file_found.nil?
|
|
244
|
+
|
|
245
|
+
Regexp.last_match[1]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Capture the ERB content inside a code block, and return a user-transformed version of it.
|
|
249
|
+
# Handle indentation properly by removing the indentation caused by the ERB call itself.
|
|
250
|
+
#
|
|
251
|
+
# @param erb_block [#call] The block containing ERB content
|
|
252
|
+
# @yield [#call(erb_content) -> String] The code that should transform the content
|
|
253
|
+
# @yieldparam erb_content [String] The ERB content
|
|
254
|
+
# @yieldreturn [String] The transformed content
|
|
255
|
+
def transform_erb_block(erb_block)
|
|
256
|
+
# Capture the ERB block content using buffer manipulation
|
|
257
|
+
erb_buffer = eval('_erbout', erb_block.binding, __FILE__, __LINE__)
|
|
258
|
+
saved_content = erb_buffer.dup
|
|
259
|
+
erb_buffer.clear
|
|
260
|
+
erb_block.call
|
|
261
|
+
captured = erb_buffer.dup
|
|
262
|
+
erb_buffer.replace(saved_content)
|
|
263
|
+
|
|
264
|
+
# Dedent the captured content: remove common leading whitespace
|
|
265
|
+
lines = captured.lines
|
|
266
|
+
min_indent = lines.reject { |l| l.strip.empty? }.map { |l| l.match(/^(\s*)/)[1].length }.min || 0
|
|
267
|
+
erb_buffer << yield(lines.map { |l| l.strip.empty? ? "\n" : l[min_indent..] }.join)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
require 'fileutils'
|
|
2
|
+
require 'git'
|
|
3
|
+
require 'launchy'
|
|
4
|
+
require 'octokit'
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'secret_string'
|
|
7
|
+
|
|
8
|
+
module XAeonAgents
|
|
9
|
+
# Various helpers and utilities that are used internally
|
|
10
|
+
module Helpers
|
|
11
|
+
# Exception class used to identify commands not returning the expected exit status
|
|
12
|
+
class UnexpectedExitStatusError < StandardError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
include Logger
|
|
17
|
+
|
|
18
|
+
# Retrieve API keys needed for the agents from the X-Aeon launcher
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash{Symbol => SecretString}] The keys retrieved
|
|
21
|
+
def keys_from_launcher
|
|
22
|
+
@keys_from_launcher ||= begin
|
|
23
|
+
keys = {
|
|
24
|
+
cline_api_key: 'Cline API key',
|
|
25
|
+
github_token: 'Github API token',
|
|
26
|
+
openrouter_api_key: 'OpenRouter API key'
|
|
27
|
+
}
|
|
28
|
+
launcher_keys = {}
|
|
29
|
+
Bundler.with_unbundled_env { `launcher safe -- #{keys.values.map { |launcher_key| "\"#{launcher_key}\"" }.join(' ')}` }.each_line do |line|
|
|
30
|
+
next unless line =~ /^\[PASSWORD\] \[([^\]]+)\]: (.+)$/
|
|
31
|
+
|
|
32
|
+
launcher_keys[Regexp.last_match(1)] = SecretString.new(Regexp.last_match(2))
|
|
33
|
+
end
|
|
34
|
+
keys.to_h { |key, launcher_key| [key, launcher_keys[launcher_key]] }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Execute a command while capturing its output in real time
|
|
39
|
+
#
|
|
40
|
+
# @param cmd [String] Command to be run
|
|
41
|
+
# @param expected_exit_status [Integer, nil] Expected exit status, or nil for no expectation
|
|
42
|
+
# @return [Hash{Symbol => Object}] Command final output
|
|
43
|
+
# - stdout [String] Full stdout
|
|
44
|
+
# - stderr [String] Full stderr
|
|
45
|
+
# - exit_status [Integer] Exit status
|
|
46
|
+
def run_cmd(cmd, expected_exit_status: 0)
|
|
47
|
+
stdout_lines = []
|
|
48
|
+
stderr_lines = []
|
|
49
|
+
exit_status = nil
|
|
50
|
+
Open3.popen3(cmd) do |stdin, stdout, stderr, wait_thr|
|
|
51
|
+
stdin.close
|
|
52
|
+
[
|
|
53
|
+
# Parse stdout
|
|
54
|
+
Thread.new do
|
|
55
|
+
stdout.each_line do |line|
|
|
56
|
+
stdout_lines << line
|
|
57
|
+
end
|
|
58
|
+
end,
|
|
59
|
+
# Parse stderr
|
|
60
|
+
Thread.new do
|
|
61
|
+
stderr.each_line do |line|
|
|
62
|
+
stderr_lines << line
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
].each(&:join)
|
|
66
|
+
exit_status = wait_thr.value.exitstatus
|
|
67
|
+
log_debug "Command `#{cmd}` exited with status: #{exit_status}"
|
|
68
|
+
if !expected_exit_status.nil? && exit_status != expected_exit_status
|
|
69
|
+
raise UnexpectedExitStatusError, "Command `#{cmd}` exited with status #{exit_status} (expected #{expected_exit_status})"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
{
|
|
73
|
+
stdout: stdout_lines.join,
|
|
74
|
+
stderr: stderr_lines.join,
|
|
75
|
+
exit_status:
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get a Git instance on the current directory.
|
|
80
|
+
# Keep a cache of it.
|
|
81
|
+
#
|
|
82
|
+
# @return [Git::Base] The git instance
|
|
83
|
+
def git
|
|
84
|
+
@git ||= Git.open(Dir.pwd)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Return a list of patch description of diffs in the git staging area.
|
|
88
|
+
#
|
|
89
|
+
# @return [String] Patches in the staging area
|
|
90
|
+
def git_diff_cached
|
|
91
|
+
# TODO: Use ruby-git when the --cached feature will be implemented
|
|
92
|
+
`git diff --cached`.strip
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Get a current files diffs
|
|
96
|
+
#
|
|
97
|
+
# @param base [String, Symbol] Git base (sha, objectish...) with which we diff, or :cached to only get diff of the staging area.
|
|
98
|
+
def artifact_files_diffs(base = 'HEAD')
|
|
99
|
+
if base == :cached
|
|
100
|
+
<<~EO_ARTIFACT
|
|
101
|
+
### git diff --cached
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
#{git_diff_cached}
|
|
105
|
+
```
|
|
106
|
+
EO_ARTIFACT
|
|
107
|
+
else
|
|
108
|
+
<<~EO_ARTIFACT
|
|
109
|
+
### New untracked files
|
|
110
|
+
|
|
111
|
+
#{git.status.untracked.keys.map do |file|
|
|
112
|
+
<<~EO_UNTRACKED_FILE
|
|
113
|
+
#### #{file}
|
|
114
|
+
```
|
|
115
|
+
#{File.read(file)}
|
|
116
|
+
```
|
|
117
|
+
EO_UNTRACKED_FILE
|
|
118
|
+
end.join("\n")}
|
|
119
|
+
|
|
120
|
+
### git diff
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
#{git.diff(base)}
|
|
124
|
+
```
|
|
125
|
+
EO_ARTIFACT
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get a Github Octokit API instance.
|
|
130
|
+
# Keep a cache of it.
|
|
131
|
+
#
|
|
132
|
+
# @return [Octokit::Client] The Octokit client
|
|
133
|
+
def github
|
|
134
|
+
@github ||= Octokit::Client.new(access_token: Config.github_token)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Get the Github remote from the Git remotes.
|
|
138
|
+
# Keep a cache of it.
|
|
139
|
+
#
|
|
140
|
+
# @return [Git::Remote, nil] The Github remote instance, or nil if none
|
|
141
|
+
def github_remote
|
|
142
|
+
@github_remote ||= git.remotes.find { |remote| remote.url.match(%r{github\.com[:/].+\.git}) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Get the current repository name from the Git remote URL.
|
|
146
|
+
# Keep a cache of it.
|
|
147
|
+
#
|
|
148
|
+
# @return [String, nil] The Github repository name in the format "owner/repo", or nil if none
|
|
149
|
+
def github_repo
|
|
150
|
+
@github_repo ||= github_remote && github_remote.url.match(%r{github\.com[:/](.+)\.git})[1]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Get the Ruby gem name from the gemspec file, if any.
|
|
154
|
+
# Returns nil if no gemspec exists.
|
|
155
|
+
#
|
|
156
|
+
# @return [String, nil] The gem name, or nil
|
|
157
|
+
def gem_name
|
|
158
|
+
@gem_name ||= begin
|
|
159
|
+
gemspec_files = Dir['*.gemspec']
|
|
160
|
+
Gem::Specification.load(gemspec_files.first).name unless gemspec_files.empty?
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Allow user to review and edit content before using it
|
|
165
|
+
#
|
|
166
|
+
# @param reviews_dir [String] Directory that can be used to store files to be reviewed
|
|
167
|
+
# @param name [String] Name used for the temporary file
|
|
168
|
+
# @param description [String] Description shown to the user
|
|
169
|
+
# @param editable [Boolean] Indicates if user can edit the content
|
|
170
|
+
# @param promptable [Boolean] Indicates if user can issue a prompt as an answer
|
|
171
|
+
# @param content [String] Initial content to present
|
|
172
|
+
# @return [Array<String>] 2 values are returned:
|
|
173
|
+
# - [String] Content after user review (same as content if editable is false)
|
|
174
|
+
# - [String] User prompt
|
|
175
|
+
def review_content(
|
|
176
|
+
reviews_dir: "#{Config.data_dir}/reviews",
|
|
177
|
+
name: 'content.txt',
|
|
178
|
+
description: 'Content to be reviewed',
|
|
179
|
+
editable: true,
|
|
180
|
+
promptable: false,
|
|
181
|
+
content: ''
|
|
182
|
+
)
|
|
183
|
+
content_file = "#{reviews_dir}/#{Time.now.utc.strftime('%F-%H-%M-%S')}-#{name}"
|
|
184
|
+
FileUtils.mkdir_p File.dirname(content_file)
|
|
185
|
+
File.write(content_file, content)
|
|
186
|
+
begin
|
|
187
|
+
Launchy.open(content_file)
|
|
188
|
+
puts
|
|
189
|
+
puts <<~EO_STDOUT
|
|
190
|
+
Review the following content: #{description}.
|
|
191
|
+
#{
|
|
192
|
+
(
|
|
193
|
+
(editable ? ['Modify the file and save it to take your changes into consideration'] : []) + [
|
|
194
|
+
'Hit Enter to continue',
|
|
195
|
+
'Hit Ctrl-C to cancel and interrupt'
|
|
196
|
+
] + (promptable ? ['Any other input will be used to prompt again the generation of this content'] : [])
|
|
197
|
+
).map { |option| "* #{option}" }.join("\n")
|
|
198
|
+
}
|
|
199
|
+
EO_STDOUT
|
|
200
|
+
user_prompt = $stdin.gets
|
|
201
|
+
[
|
|
202
|
+
editable ? File.read(content_file).strip : content,
|
|
203
|
+
user_prompt.strip
|
|
204
|
+
]
|
|
205
|
+
ensure
|
|
206
|
+
FileUtils.rm_f content_file unless Config.debug
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module XAeonAgents
|
|
2
|
+
# Mixin adding logging capabilities
|
|
3
|
+
module Logger
|
|
4
|
+
class << self
|
|
5
|
+
# Global debug switch.
|
|
6
|
+
attr_accessor :debug
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Log a message if debug was activated
|
|
10
|
+
#
|
|
11
|
+
# @param msg [String, nil] Message to be displayed, or nil if the message is given lazily through a code block
|
|
12
|
+
# @yield [#call -> String] Optional code returning a [String] for lazy evaluation
|
|
13
|
+
# @yieldreturn [String] The message to be displayed
|
|
14
|
+
def log_debug(msg = nil)
|
|
15
|
+
return unless Logger.debug
|
|
16
|
+
|
|
17
|
+
msg = yield if block_given?
|
|
18
|
+
puts "[DEBUG] - #{msg}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require 'ruby_llm/message'
|
|
2
|
+
require 'ruby_llm/thinking'
|
|
3
|
+
require 'ruby_llm/providers/openai'
|
|
4
|
+
require 'ruby_llm/providers/openrouter/chat'
|
|
5
|
+
require 'ruby_llm/providers/openrouter/models'
|
|
6
|
+
require 'ruby_llm/providers/openrouter/streaming'
|
|
7
|
+
require 'ruby_llm/providers/openrouter/images'
|
|
8
|
+
|
|
9
|
+
module XAeonAgents
|
|
10
|
+
# Collection of providers for ai-agents
|
|
11
|
+
module Providers
|
|
12
|
+
# Cline API integration.
|
|
13
|
+
class Cline < RubyLLM::Providers::OpenAI
|
|
14
|
+
# @return [String] The base API URL
|
|
15
|
+
def api_base
|
|
16
|
+
@config.cline_api_base || 'https://api.cline.bot/api/v1'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Hash{String => String}] HTTP headers to add to the queries
|
|
20
|
+
def headers
|
|
21
|
+
{
|
|
22
|
+
'Authorization' => "Bearer #{@config.cline_api_key}"
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Parses the completion response from the Cline API into a [RubyLLM::Message].
|
|
27
|
+
#
|
|
28
|
+
# Handles empty responses, API errors, message extraction, content/thinking parsing,
|
|
29
|
+
# token usage tracking (including cached and reasoning tokens), and tool calls.
|
|
30
|
+
#
|
|
31
|
+
# @param response [Faraday::Response] The raw HTTP response from the Cline API.
|
|
32
|
+
# @return [RubyLLM::Message, nil] The parsed message, or nil if the response body is empty
|
|
33
|
+
# or no message data is present.
|
|
34
|
+
def parse_completion_response(response)
|
|
35
|
+
data = response.body
|
|
36
|
+
return if data.empty?
|
|
37
|
+
|
|
38
|
+
raise Error.new(response, data['error']) if data['error']
|
|
39
|
+
|
|
40
|
+
message_data = data.dig('data', 'choices', 0, 'message')
|
|
41
|
+
return unless message_data
|
|
42
|
+
|
|
43
|
+
usage = data.dig('data', 'usage') || {}
|
|
44
|
+
cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
|
|
45
|
+
thinking_tokens = usage.dig('completion_tokens_details', 'reasoning_tokens')
|
|
46
|
+
content, thinking_from_blocks = extract_content_and_thinking(message_data['content'])
|
|
47
|
+
thinking_text = thinking_from_blocks || extract_thinking_text(message_data)
|
|
48
|
+
thinking_signature = extract_thinking_signature(message_data)
|
|
49
|
+
|
|
50
|
+
RubyLLM::Message.new(
|
|
51
|
+
role: :assistant,
|
|
52
|
+
content: content,
|
|
53
|
+
thinking: RubyLLM::Thinking.build(text: thinking_text, signature: thinking_signature),
|
|
54
|
+
tool_calls: parse_tool_calls(message_data['tool_calls']),
|
|
55
|
+
input_tokens: usage['prompt_tokens'],
|
|
56
|
+
output_tokens: usage['completion_tokens'],
|
|
57
|
+
cached_tokens: cached_tokens,
|
|
58
|
+
cache_creation_tokens: 0,
|
|
59
|
+
thinking_tokens: thinking_tokens,
|
|
60
|
+
model_id: data.dig('data', 'model'),
|
|
61
|
+
raw: response
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# @return [Array<Symbol>] The required configuration keys for the Cline provider.
|
|
67
|
+
def configuration_requirements
|
|
68
|
+
%i[cline_api_base cline_api_key]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Array<Symbol>] The available configuration keys for the Cline provider.
|
|
72
|
+
def configuration_options
|
|
73
|
+
%i[cline_api_base cline_api_key]
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require 'zeitwerk'
|
|
2
|
+
|
|
3
|
+
Zeitwerk::Loader.for_gem.setup
|
|
4
|
+
|
|
5
|
+
# As most agents come from composable_agents, require it here just once.
|
|
6
|
+
require 'composable_agents'
|
|
7
|
+
|
|
8
|
+
# Provide skills, tools, agents and workflows that allow AI to be used in X-Aeon projects.
|
|
9
|
+
module XAeonAgents
|
|
10
|
+
# Return the current AI agent name
|
|
11
|
+
#
|
|
12
|
+
# @return [String] The current AI agent name
|
|
13
|
+
def self.agent_name
|
|
14
|
+
# TODO: Make this adaptable to different agents using plugins
|
|
15
|
+
require 'sqlite3'
|
|
16
|
+
require 'json'
|
|
17
|
+
|
|
18
|
+
# Find VSCode globalStorage database
|
|
19
|
+
state_db = "#{ENV['VSCODE_PORTABLE'] ? "#{ENV['VSCODE_PORTABLE']}/user-data" : "#{ENV.fetch('APPDATA', nil)}/Code"}/User/globalStorage/state.vscdb"
|
|
20
|
+
raise "Cannot find #{state_db}" unless File.exist?(state_db)
|
|
21
|
+
|
|
22
|
+
# Open SQLite database and query for our extension's key
|
|
23
|
+
db = SQLite3::Database.new(state_db)
|
|
24
|
+
db.results_as_hash = true
|
|
25
|
+
row = db.get_first_row('SELECT value FROM ItemTable WHERE key = ?', 'saoudrizwan.claude-dev')
|
|
26
|
+
db.close
|
|
27
|
+
raise 'Key \'saoudrizwan.claude-dev\' not found in database.' unless row
|
|
28
|
+
|
|
29
|
+
"Cline (#{JSON.parse(row['value'])['actModeOpenRouterModelId']})"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Return the agent signature that is added in any description authored by the AI agent
|
|
33
|
+
#
|
|
34
|
+
# @return [String] AI agent signature
|
|
35
|
+
def self.agent_signature
|
|
36
|
+
"\n\nCo-authored by: #{agent_name}"
|
|
37
|
+
end
|
|
38
|
+
end
|