aia 1.1.1 → 2.0.0.0.pre.beta2
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 +4 -4
- data/.envrc +9 -1
- data/.loki +11 -0
- data/.reek.yml +160 -0
- data/.rubocop.yml +116 -0
- data/.rubocop_strict.yml +15 -0
- data/.version +1 -1
- data/CHANGELOG.md +269 -55
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +267 -239
- data/Rakefile +5 -5
- data/_typos.toml +10 -0
- data/aia.gemspec +92 -0
- data/architecture_review.md +314 -0
- data/bin/aia +16 -0
- data/config/aia.yml +13 -0
- data/docs/AGENTS.md +40 -0
- data/docs/advanced-prompting.md +67 -3
- data/docs/cli-reference.md +312 -56
- data/docs/configuration.md +130 -19
- data/docs/contributing.md +56 -2
- data/docs/directives-reference.md +593 -78
- data/docs/faq.md +85 -3
- data/docs/guides/available-models.md +1 -1
- data/docs/guides/basic-usage.md +6 -6
- data/docs/guides/chat.md +40 -16
- data/docs/guides/crew.md +239 -0
- data/docs/guides/executable-prompts.md +1 -1
- data/docs/guides/index.md +1 -0
- data/docs/guides/models.md +15 -0
- data/docs/index.md +29 -2
- data/docs/installation.md +44 -17
- data/docs/mcp-integration.md +40 -0
- data/docs/prompt_management.md +85 -86
- data/docs/security.md +47 -0
- data/docs/special_projects_guide.md +386 -0
- data/docs/tools-and-mcp-examples.md +23 -0
- data/docs/workflows-and-pipelines.md +84 -7
- data/examples/.gitignore +1 -0
- data/examples/00_setup_aia.sh +27 -44
- data/examples/11_multi_model.sh +4 -14
- data/examples/12_token_usage.sh +3 -12
- data/examples/18_tools.sh +10 -2
- data/examples/22_chat_mode.sh +0 -10
- data/examples/23_verify.sh +139 -0
- data/examples/24_decompose.sh +139 -0
- data/examples/25_spawn.sh +139 -0
- data/examples/26_debate.sh +97 -0
- data/examples/27_mention_routing.sh +157 -0
- data/examples/28_model_switching.sh +106 -0
- data/examples/29_agent_harness.sh +177 -0
- data/examples/README.md +65 -0
- data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
- data/examples/aia_config.yml +1 -1
- data/examples/aia_config_orchestrator.yml +45 -0
- data/examples/common.sh +18 -6
- data/examples/context/tech_stack.md +2 -2
- data/examples/prompts_dir/roles/orchestrator.md +21 -0
- data/examples/requirements/sinatra_taskflow_app.md +139 -0
- data/examples/rules/01_classify_ruby.rb +16 -0
- data/examples/rules/02_prefer_claude_for_code.rb +19 -0
- data/examples/rules/03_gate_prompt_length.rb +19 -0
- data/examples/rules/04_tool_selection.rb +41 -0
- data/examples/rules/README.md +30 -0
- data/examples/run_all.sh +48 -15
- data/examples/tools/word_count_tool.rb +1 -1
- data/lib/AGENTS.md +57 -0
- data/lib/aia/chat_loop.rb +263 -167
- data/lib/aia/config/cli_parser.rb +217 -145
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +52 -51
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +171 -216
- data/lib/aia/config.rb +111 -145
- data/lib/aia/content_extractor.rb +155 -0
- data/lib/aia/cost_calculator.rb +39 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +174 -0
- data/lib/aia/delegate_handler.rb +116 -0
- data/lib/aia/directive.rb +43 -26
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +214 -60
- data/lib/aia/directives/context_directives.rb +67 -52
- data/lib/aia/directives/execution_directives.rb +141 -4
- data/lib/aia/directives/model_directives.rb +163 -141
- data/lib/aia/directives/trakflow_directives.rb +62 -0
- data/lib/aia/directives/utility_directives.rb +227 -30
- data/lib/aia/directives/web_and_file_directives.rb +126 -77
- data/lib/aia/errors.rb +15 -0
- data/lib/aia/fact_asserter.rb +27 -0
- data/lib/aia/fzf.rb +9 -31
- data/lib/aia/handler_context.rb +17 -0
- data/lib/aia/handler_protocol.rb +19 -0
- data/lib/aia/history_transfer.rb +55 -0
- data/lib/aia/input_collector.rb +3 -3
- data/lib/aia/layered_orchestrator.rb +471 -0
- data/lib/aia/logger.rb +45 -25
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +315 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_server_config.rb +30 -0
- data/lib/aia/mcp_utility.rb +60 -0
- data/lib/aia/mention_router.rb +217 -0
- data/lib/aia/model_alias_registry.rb +97 -0
- data/lib/aia/model_switch_handler.rb +100 -0
- data/lib/aia/network_builder.rb +160 -0
- data/lib/aia/network_memory_manager.rb +55 -0
- data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
- data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
- data/lib/aia/pipeline_orchestrator.rb +272 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +211 -0
- data/lib/aia/prompt_decomposer.rb +159 -0
- data/lib/aia/prompt_handler.rb +56 -82
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +338 -0
- data/lib/aia/robot_namer.rb +110 -0
- data/lib/aia/session.rb +87 -17
- data/lib/aia/session_tracker.rb +207 -0
- data/lib/aia/similarity_scorer.rb +41 -0
- data/lib/aia/skill_utils.rb +105 -1
- data/lib/aia/spawn_handler.rb +129 -0
- data/lib/aia/spawn_spec_parser.rb +65 -0
- data/lib/aia/special_mode_handler.rb +322 -0
- data/lib/aia/speech.rb +67 -0
- data/lib/aia/startup_coordinator.rb +151 -0
- data/lib/aia/streaming_runner.rb +172 -0
- data/lib/aia/system_prompt_assembler.rb +92 -0
- data/lib/aia/task_coordinator.rb +207 -0
- data/lib/aia/task_decomposer.rb +57 -0
- data/lib/aia/task_executor.rb +51 -0
- data/lib/aia/tfidf_math.rb +27 -0
- data/lib/aia/timing.rb +15 -0
- data/lib/aia/tool_filter/tfidf.rb +116 -0
- data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
- data/lib/aia/tool_filter.rb +83 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +146 -0
- data/lib/aia/tool_introspection.rb +17 -0
- data/lib/aia/tool_loader.rb +216 -0
- data/lib/aia/tool_utility.rb +30 -0
- data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
- data/lib/aia/tools/recruit_robot_tool.rb +60 -0
- data/lib/aia/tools/reskill_robot_tool.rb +44 -0
- data/lib/aia/tools/task_board_tool.rb +115 -0
- data/lib/aia/trakflow_bridge.rb +175 -0
- data/lib/aia/turn_state.rb +95 -0
- data/lib/aia/ui_presenter.rb +182 -206
- data/lib/aia/utility.rb +136 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
- data/lib/aia/verification_network.rb +57 -0
- data/lib/aia.rb +124 -63
- data/mkdocs.yml +1 -0
- metadata +187 -58
- data/justfile +0 -215
- data/lib/aia/adapter/chat_execution.rb +0 -242
- data/lib/aia/adapter/error_handler.rb +0 -68
- data/lib/aia/adapter/gem_activator.rb +0 -57
- data/lib/aia/adapter/mcp_connector.rb +0 -274
- data/lib/aia/adapter/modality_handlers.rb +0 -167
- data/lib/aia/adapter/model_registry.rb +0 -81
- data/lib/aia/adapter/multi_model_chat.rb +0 -218
- data/lib/aia/adapter/provider_configurator.rb +0 -59
- data/lib/aia/adapter/tool_filter.rb +0 -85
- data/lib/aia/adapter/tool_loader.rb +0 -90
- data/lib/aia/chat_processor_service.rb +0 -178
- data/lib/aia/prompt_pipeline.rb +0 -183
- data/lib/aia/ruby_llm_adapter.rb +0 -95
- data/lib/extensions/openstruct_merge.rb +0 -48
- data/lib/extensions/ruby_llm/.irbrc +0 -56
- data/lib/extensions/ruby_llm/modalities.rb +0 -36
- data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
- data/lib/refinements/string.rb +0 -16
- data/main.just +0 -76
|
@@ -5,40 +5,19 @@ require 'word_wrapper'
|
|
|
5
5
|
|
|
6
6
|
module AIA
|
|
7
7
|
class UtilityDirectives < Directive
|
|
8
|
-
desc "List available tools (optional filter by name substring)"
|
|
8
|
+
desc "List available tools (optional filter by name or description substring)"
|
|
9
9
|
def tools(args = [], context_manager = nil)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
width = TTY::Screen.width - indent - 2
|
|
13
|
-
filter = args.first&.downcase
|
|
10
|
+
raw_filter = args.first
|
|
11
|
+
loaded_tools = Array(AIA.config.loaded_tools) + all_mcp_tools
|
|
14
12
|
|
|
15
|
-
loaded_tools = AIA.config.loaded_tools || []
|
|
16
13
|
if loaded_tools.empty?
|
|
17
14
|
puts "No tools are available"
|
|
18
15
|
else
|
|
19
|
-
tools_to_display = loaded_tools
|
|
20
|
-
|
|
21
|
-
if filter
|
|
22
|
-
tools_to_display = tools_to_display.select do |tool|
|
|
23
|
-
name = tool.respond_to?(:name) ? tool.name : tool.class.name
|
|
24
|
-
name.downcase.include?(filter)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
16
|
+
tools_to_display = filter_tools(loaded_tools, raw_filter&.downcase)
|
|
28
17
|
if tools_to_display.empty?
|
|
29
|
-
puts "No tools match the filter: #{
|
|
18
|
+
puts "No tools match the filter: #{raw_filter}"
|
|
30
19
|
else
|
|
31
|
-
|
|
32
|
-
header = filter ? "Available Tools (filtered by '#{args.first}')" : "Available Tools"
|
|
33
|
-
puts header
|
|
34
|
-
puts "=" * header.length
|
|
35
|
-
|
|
36
|
-
tools_to_display.each do |tool|
|
|
37
|
-
name = tool.respond_to?(:name) ? tool.name : tool.class.name
|
|
38
|
-
puts "\n#{name}"
|
|
39
|
-
puts "-" * name.size
|
|
40
|
-
puts WordWrapper::MinimumRaggedness.new(width, tool.description).wrap.split("\n").map { |s| spaces + s + "\n" }.join
|
|
41
|
-
end
|
|
20
|
+
print_tools_report(tools_to_display, raw_filter)
|
|
42
21
|
end
|
|
43
22
|
end
|
|
44
23
|
puts
|
|
@@ -46,9 +25,58 @@ module AIA
|
|
|
46
25
|
''
|
|
47
26
|
end
|
|
48
27
|
|
|
49
|
-
desc "
|
|
50
|
-
def
|
|
51
|
-
|
|
28
|
+
desc "List loaded plugin basenames"
|
|
29
|
+
def plugins(args = [], context_manager = nil)
|
|
30
|
+
loaded_plugins = Array(AIA.config&.loaded_plugins)
|
|
31
|
+
|
|
32
|
+
if loaded_plugins.empty?
|
|
33
|
+
puts "No plugins are loaded"
|
|
34
|
+
else
|
|
35
|
+
puts
|
|
36
|
+
puts "Loaded Plugins"
|
|
37
|
+
puts "=============="
|
|
38
|
+
loaded_plugins.each { |name| puts name }
|
|
39
|
+
end
|
|
40
|
+
puts
|
|
41
|
+
|
|
42
|
+
''
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc "Show MCP server connection status and available tools"
|
|
46
|
+
def mcp(args = [], context_manager = nil)
|
|
47
|
+
connected = AIA.config&.connected_mcp_servers || []
|
|
48
|
+
failed = AIA.config&.failed_mcp_servers || []
|
|
49
|
+
|
|
50
|
+
puts
|
|
51
|
+
puts "MCP Server Status"
|
|
52
|
+
puts "================="
|
|
53
|
+
puts "Defined: #{AIA::Utility.effective_mcp_server_names.size} " \
|
|
54
|
+
"Connected: #{connected.size} Failed: #{failed.size}"
|
|
55
|
+
puts
|
|
56
|
+
|
|
57
|
+
print_connected_servers(connected) if connected.any?
|
|
58
|
+
print_failed_servers(failed) if failed.any?
|
|
59
|
+
|
|
60
|
+
''
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
desc "Show active robot configuration"
|
|
64
|
+
def robots(args = [], context_manager = nil)
|
|
65
|
+
client = AIA.client
|
|
66
|
+
unless client
|
|
67
|
+
puts "No active robots"
|
|
68
|
+
return ''
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
puts
|
|
72
|
+
if client.network?
|
|
73
|
+
show_network(client)
|
|
74
|
+
else
|
|
75
|
+
show_single_robot(client)
|
|
76
|
+
end
|
|
77
|
+
puts
|
|
78
|
+
|
|
79
|
+
''
|
|
52
80
|
end
|
|
53
81
|
|
|
54
82
|
desc "Display ASCII robot art"
|
|
@@ -61,5 +89,174 @@ module AIA
|
|
|
61
89
|
def help(args = nil, context_manager = nil)
|
|
62
90
|
AIA::Directive.help
|
|
63
91
|
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def filter_tools(tools, filter)
|
|
96
|
+
return tools unless filter
|
|
97
|
+
|
|
98
|
+
tools.select do |tool|
|
|
99
|
+
"#{ToolIntrospection.tool_name(tool)} #{ToolIntrospection.tool_description(tool)}"
|
|
100
|
+
.downcase.include?(filter)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def print_tools_report(tools_to_display, raw_filter)
|
|
105
|
+
puts
|
|
106
|
+
header = raw_filter ? "Available Tools (filtered by '#{raw_filter}')" : "Available Tools"
|
|
107
|
+
puts header
|
|
108
|
+
puts "=" * header.length
|
|
109
|
+
|
|
110
|
+
indent = 4
|
|
111
|
+
width = TTY::Screen.width - indent - 2
|
|
112
|
+
tools_to_display.each { |tool| print_tool_entry(tool, width, " " * indent) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def print_tool_entry(tool, width, spaces)
|
|
116
|
+
name = ToolIntrospection.tool_name(tool)
|
|
117
|
+
puts "\n#{name}"
|
|
118
|
+
puts "-" * name.size
|
|
119
|
+
puts WordWrapper::MinimumRaggedness.new(width, tool.description).wrap.split("\n").map { |s| spaces + s + "\n" }.join
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def print_connected_servers(connected)
|
|
123
|
+
grouped = tools_by_server(connected, all_mcp_tools)
|
|
124
|
+
|
|
125
|
+
puts "Connected Servers:"
|
|
126
|
+
connected.each do |name|
|
|
127
|
+
tools = grouped[name] || []
|
|
128
|
+
puts " #{name} (#{tools.size} tools)"
|
|
129
|
+
tools.each { |tool| puts " - #{ToolIntrospection.tool_name(tool)}" }
|
|
130
|
+
end
|
|
131
|
+
puts
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Group MCP tools under the connected server each one came from.
|
|
135
|
+
def tools_by_server(connected, mcp_tools)
|
|
136
|
+
grouped = connected.to_h { |name| [name, []] }
|
|
137
|
+
mcp_tools.each do |tool|
|
|
138
|
+
server_name = tool.respond_to?(:mcp) ? tool.mcp : nil
|
|
139
|
+
grouped[server_name] << tool if server_name && grouped.key?(server_name)
|
|
140
|
+
end
|
|
141
|
+
grouped
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def print_failed_servers(failed)
|
|
145
|
+
puts "Failed Servers:"
|
|
146
|
+
failed.each do |f|
|
|
147
|
+
puts " #{f[:name] || f['name']}: #{f[:error] || f['error']}"
|
|
148
|
+
end
|
|
149
|
+
puts
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# :reek:TooManyStatements -- mode classification plus sequential report of every crew member
|
|
153
|
+
def show_network(network)
|
|
154
|
+
cfg = AIA.config
|
|
155
|
+
robot_count = network.robot_count
|
|
156
|
+
mode = if cfg.flags.consensus
|
|
157
|
+
"Consensus"
|
|
158
|
+
elsif cfg.pipeline.length > 1
|
|
159
|
+
"Pipeline"
|
|
160
|
+
elsif cfg.models.length > 1
|
|
161
|
+
"Parallel"
|
|
162
|
+
else
|
|
163
|
+
"Crew" # single-model session wrapped as a one-member crew
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
header = "Active Robots"
|
|
167
|
+
puts header
|
|
168
|
+
puts "=" * header.length
|
|
169
|
+
noun = robot_count == 1 ? "robot" : "robots"
|
|
170
|
+
puts "Mode: #{mode} (#{robot_count} #{noun})"
|
|
171
|
+
|
|
172
|
+
network.crew.each do |bot|
|
|
173
|
+
puts
|
|
174
|
+
show_robot_detail(bot)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def show_single_robot(bot)
|
|
179
|
+
header = "Active Robot"
|
|
180
|
+
puts header
|
|
181
|
+
puts "=" * header.length
|
|
182
|
+
puts "Mode: Single"
|
|
183
|
+
puts
|
|
184
|
+
show_robot_detail(bot)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# :reek:TooManyStatements -- one labeled line per robot attribute (model, wage, tool counts, role)
|
|
188
|
+
def show_robot_detail(bot)
|
|
189
|
+
puts " #{bot.name}"
|
|
190
|
+
|
|
191
|
+
model_name = bot.model || 'unknown'
|
|
192
|
+
provider = bot.respond_to?(:provider) ? bot.provider : nil
|
|
193
|
+
provider ||= chat_provider(bot)
|
|
194
|
+
model_line = provider ? "#{model_name} (#{provider})" : model_name
|
|
195
|
+
puts " Model: #{model_line}"
|
|
196
|
+
puts " Wage: #{wage_for(model_name)}"
|
|
197
|
+
|
|
198
|
+
local_count = Array(bot.local_tools).size
|
|
199
|
+
mcp_count = Array(bot.mcp_tools).size
|
|
200
|
+
total = local_count + mcp_count
|
|
201
|
+
tool_parts = []
|
|
202
|
+
tool_parts << "#{local_count} local" if local_count.positive?
|
|
203
|
+
tool_parts << "#{mcp_count} mcp" if mcp_count.positive?
|
|
204
|
+
puts " Tools: #{total} (#{tool_parts.join(', ')})" if total.positive?
|
|
205
|
+
puts " Tools: none" if total.zero?
|
|
206
|
+
|
|
207
|
+
role = role_for(bot)
|
|
208
|
+
puts " Role: #{role}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Cost per 1K tokens (price_per_million * 1000 / 1_000_000)
|
|
212
|
+
def wage_for(model_name)
|
|
213
|
+
model_info = RubyLLM::Models.find(model_name)
|
|
214
|
+
input = model_info&.input_price_per_million
|
|
215
|
+
output = model_info&.output_price_per_million
|
|
216
|
+
return 'N/A' unless input && output
|
|
217
|
+
|
|
218
|
+
in_per_k = input * 1000.0 / 1_000_000
|
|
219
|
+
out_per_k = output * 1000.0 / 1_000_000
|
|
220
|
+
"$#{'%.4f' % in_per_k} in / $#{'%.4f' % out_per_k} out per 1K tokens"
|
|
221
|
+
rescue StandardError
|
|
222
|
+
'N/A'
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def chat_provider(bot)
|
|
226
|
+
bot.chat_provider
|
|
227
|
+
rescue StandardError
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def role_for(bot)
|
|
232
|
+
spec = AIA.config.models.find { |s| s.name == bot.model }
|
|
233
|
+
if spec&.role?
|
|
234
|
+
spec.role
|
|
235
|
+
else
|
|
236
|
+
"(default)"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Retrieve MCP tools from robot or first robot in a Network.
|
|
241
|
+
# Falls back to RubyLLM::MCP.clients for tools registered by shared_tools
|
|
242
|
+
# or other gems that use RubyLLM::MCP.add_client directly.
|
|
243
|
+
def all_mcp_tools
|
|
244
|
+
robot = AIA.client
|
|
245
|
+
if robot
|
|
246
|
+
if robot.respond_to?(:mcp_tools) && robot.mcp_tools&.any?
|
|
247
|
+
return Array(robot.mcp_tools)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
first_robot = robot.chief
|
|
251
|
+
if first_robot.respond_to?(:mcp_tools) && first_robot.mcp_tools&.any?
|
|
252
|
+
return Array(first_robot.mcp_tools)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Fall back to RubyLLM::MCP clients (shared_tools and other gems
|
|
257
|
+
# that register MCP servers via RubyLLM::MCP.add_client)
|
|
258
|
+
return [] unless defined?(RubyLLM::MCP)
|
|
259
|
+
RubyLLM::MCP.clients.values.flat_map(&:tools)
|
|
260
|
+
end
|
|
64
261
|
end
|
|
65
262
|
end
|
|
@@ -2,126 +2,88 @@
|
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
4
|
require 'clipboard'
|
|
5
|
-
require 'yaml'
|
|
6
|
-
require 'io/console'
|
|
7
|
-
require 'word_wrapper'
|
|
8
5
|
|
|
9
6
|
module AIA
|
|
10
7
|
class WebAndFileDirectives < Directive
|
|
11
8
|
include AIA::SkillUtils
|
|
9
|
+
|
|
12
10
|
PUREMD_API_KEY = ENV.fetch('PUREMD_API_KEY', nil)
|
|
13
11
|
|
|
14
12
|
desc "Fetch and include content from a webpage"
|
|
13
|
+
# :reek:TooManyStatements -- guard, fetch via pure.md, then success/error reporting in one flow
|
|
15
14
|
def webpage(args, _context_manager = nil)
|
|
16
15
|
if PUREMD_API_KEY.nil?
|
|
17
|
-
'ERROR: PUREMD_API_KEY is required in order to include a webpage'
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
msg = 'ERROR: PUREMD_API_KEY is required in order to include a webpage'
|
|
17
|
+
AIA::LoggerManager.aia_logger.error(msg)
|
|
18
|
+
puts msg
|
|
19
|
+
return nil
|
|
20
|
+
end
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
22
|
+
url = args.shift.to_s.strip
|
|
23
|
+
puremd_url = "https://pure.md/#{url}"
|
|
25
24
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
else
|
|
29
|
-
"Error: Status was #{response.status}\n#{ap response}"
|
|
30
|
-
end
|
|
25
|
+
response = Faraday.get(puremd_url) do |req|
|
|
26
|
+
req.headers['x-puremd-api-token'] = PUREMD_API_KEY
|
|
31
27
|
end
|
|
28
|
+
|
|
29
|
+
return response.body if response.status == 200
|
|
30
|
+
|
|
31
|
+
msg = "Error: Fetching #{url} returned status #{response.status}"
|
|
32
|
+
AIA::LoggerManager.aia_logger.error(msg)
|
|
33
|
+
puts msg
|
|
34
|
+
nil
|
|
32
35
|
end
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
alias website webpage
|
|
37
|
+
alias web webpage
|
|
35
38
|
|
|
36
39
|
desc "List available AIA skills"
|
|
40
|
+
# :reek:TooManyStatements -- sequential listing: directory guard, term filtering, per-skill output
|
|
37
41
|
def skills(args = [], _context_manager = nil)
|
|
38
42
|
dir = aia_skills_dir
|
|
39
|
-
|
|
40
43
|
unless Dir.exist?(dir)
|
|
41
44
|
puts "No skills directory found at #{dir}"
|
|
42
45
|
return nil
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
positive_terms, negative_terms = parse_search_terms(Array(args))
|
|
49
|
+
entries = matching_skill_ids(dir, positive_terms, negative_terms)
|
|
46
50
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if skill_dirs.empty?
|
|
52
|
-
puts "No skills found in #{dir}"
|
|
53
|
-
return nil
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
skill_data = skill_dirs.filter_map do |name|
|
|
57
|
-
fm = parse_front_matter(File.join(dir, name, 'SKILL.md'))
|
|
58
|
-
text = "#{name} #{fm.values.join(' ').downcase}"
|
|
59
|
-
pos_ok = positive_terms.empty? || positive_terms.all? { |t| text.include?(t) }
|
|
60
|
-
neg_ok = negative_terms.none? { |t| text.include?(t) }
|
|
61
|
-
[name, fm] if pos_ok && neg_ok
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
if skill_data.empty?
|
|
65
|
-
puts "No skills matching your query"
|
|
51
|
+
if entries.empty?
|
|
52
|
+
all_terms = positive_terms + negative_terms
|
|
53
|
+
puts all_terms.empty? ? "No skills found in #{dir}" : "No skills matched: #{Array(args).join(' ')}"
|
|
66
54
|
return nil
|
|
67
55
|
end
|
|
68
56
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
puts "=" * [width, 60].min
|
|
72
|
-
|
|
73
|
-
skill_data.each do |name, fm|
|
|
74
|
-
display_name = fm['name'] || name
|
|
75
|
-
desc_text = fm['description'].to_s.strip
|
|
76
|
-
puts "\n #{display_name}"
|
|
77
|
-
unless desc_text.empty?
|
|
78
|
-
puts WordWrapper::MinimumRaggedness.new(width - 4, desc_text).wrap
|
|
79
|
-
.split("\n").map { |l| " #{l}" }.join("\n")
|
|
80
|
-
end
|
|
81
|
-
end
|
|
57
|
+
wrap_width = terminal_width - 2
|
|
58
|
+
entries.each { |skill_id| print_skill_entry(dir, skill_id, wrap_width) }
|
|
82
59
|
|
|
83
|
-
puts "\nTotal: #{skill_data.size} skill#{'s' if skill_data.size != 1}"
|
|
84
60
|
nil
|
|
85
61
|
end
|
|
86
62
|
|
|
87
|
-
desc "Include an AIA skill from the skills directory"
|
|
63
|
+
desc "Include an AIA skill from the configured skills directory"
|
|
88
64
|
def skill(args = [], _context_manager = nil)
|
|
89
65
|
args = Array(args)
|
|
90
66
|
skill_name = args.first&.strip
|
|
91
|
-
|
|
92
67
|
if skill_name.nil? || skill_name.empty?
|
|
93
|
-
|
|
94
|
-
AIA::LoggerManager.aia_logger.error("/skill requires a skill name")
|
|
95
|
-
return nil
|
|
68
|
+
return report_skill_error("Error: /skill requires a skill name. Use /skills to list available skills.")
|
|
96
69
|
end
|
|
97
70
|
|
|
98
71
|
dir = aia_skills_dir
|
|
99
|
-
|
|
100
|
-
unless Dir.exist?(dir)
|
|
101
|
-
warn "Error: Skills directory not found at #{dir}"
|
|
102
|
-
AIA::LoggerManager.aia_logger.error("Skills directory not found at #{dir}")
|
|
103
|
-
return nil
|
|
104
|
-
end
|
|
105
|
-
|
|
106
72
|
skill_dir = find_skill_dir(skill_name, dir)
|
|
107
73
|
unless skill_dir
|
|
108
|
-
|
|
109
|
-
warn "Error: No skill directory found at '#{File.expand_path(skill_name)}'. Use /skills to list available skills."
|
|
110
|
-
AIA::LoggerManager.aia_logger.error("No skill directory found at '#{File.expand_path(skill_name)}'")
|
|
111
|
-
else
|
|
112
|
-
warn "Error: No skill matching '#{skill_name}' found in #{dir}. Use /skills to list available skills."
|
|
113
|
-
AIA::LoggerManager.aia_logger.error("No skill matching '#{skill_name}' found in #{dir}")
|
|
114
|
-
end
|
|
115
|
-
return nil
|
|
74
|
+
return report_skill_error("Error: No skill matching '#{skill_name}' found in #{dir}. Use /skills to list available skills.")
|
|
116
75
|
end
|
|
117
76
|
|
|
118
77
|
return File.read(skill_dir) if File.file?(skill_dir)
|
|
119
78
|
|
|
120
79
|
skill_path = File.join(skill_dir, 'SKILL.md')
|
|
121
80
|
unless File.exist?(skill_path)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
81
|
+
msg = if path_based_id?(skill_name)
|
|
82
|
+
"Error: No skill matching '#{skill_name}' found in #{dir}. Use /skills to list available skills."
|
|
83
|
+
else
|
|
84
|
+
"Error: Skill '#{File.basename(skill_dir)}' has no SKILL.md in #{dir}."
|
|
85
|
+
end
|
|
86
|
+
return report_skill_error(msg)
|
|
125
87
|
end
|
|
126
88
|
|
|
127
89
|
File.read(skill_path)
|
|
@@ -132,21 +94,108 @@ module AIA
|
|
|
132
94
|
content = Clipboard.paste
|
|
133
95
|
content.to_s
|
|
134
96
|
rescue StandardError => e
|
|
135
|
-
"Error: Unable to paste from clipboard - #{e.message}"
|
|
97
|
+
msg = "Error: Unable to paste from clipboard - #{e.message}"
|
|
98
|
+
AIA::LoggerManager.aia_logger.error(msg)
|
|
99
|
+
puts msg
|
|
100
|
+
nil
|
|
136
101
|
end
|
|
137
|
-
|
|
102
|
+
alias clipboard paste
|
|
138
103
|
|
|
139
104
|
private
|
|
140
105
|
|
|
106
|
+
# Skill directories under dir with a SKILL.md whose front matter passes
|
|
107
|
+
# the positive/negative term filters, sorted.
|
|
108
|
+
def matching_skill_ids(dir, positive_terms, negative_terms)
|
|
109
|
+
skill_dirs = Dir.children(dir).select do |e|
|
|
110
|
+
File.directory?(File.join(dir, e)) &&
|
|
111
|
+
File.exist?(File.join(dir, e, 'SKILL.md'))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
skill_dirs.select do |e|
|
|
115
|
+
next true if positive_terms.empty? && negative_terms.empty?
|
|
116
|
+
text = read_front_matter_text(File.join(dir, e, 'SKILL.md'))
|
|
117
|
+
# text is a String; Array#intersect? would raise TypeError
|
|
118
|
+
positive_terms.all? { |t| text.include?(t) } &&
|
|
119
|
+
negative_terms.none? { |t| text.include?(t) }
|
|
120
|
+
end.sort
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_skill_entry(dir, skill_id, wrap_width)
|
|
124
|
+
fm = parse_skill_front_matter(File.join(dir, skill_id, 'SKILL.md'))
|
|
125
|
+
name = fm['name'] || ''
|
|
126
|
+
description = fm['description'] || '(no description)'
|
|
127
|
+
puts "#{skill_id}: #{name}"
|
|
128
|
+
puts word_wrap(description, width: wrap_width, indent: ' ')
|
|
129
|
+
puts
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Log and print a /skill error message; returns nil so callers can
|
|
133
|
+
# `return report_skill_error(msg)` from any guard.
|
|
134
|
+
def report_skill_error(msg)
|
|
135
|
+
AIA::LoggerManager.aia_logger.error(msg)
|
|
136
|
+
puts msg
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Resolve the AIA skills directory from config, env vars, or defaults.
|
|
141
141
|
def aia_skills_dir
|
|
142
|
-
AIA.config.skills
|
|
142
|
+
skills = AIA.respond_to?(:config) ? AIA.config&.skills : nil
|
|
143
|
+
return skills.dir if skills.respond_to?(:dir) && skills.dir
|
|
144
|
+
|
|
145
|
+
ENV.fetch('AIA_SKILLS__DIR', File.expand_path('~/.prompts/skills'))
|
|
143
146
|
end
|
|
144
147
|
|
|
145
148
|
def terminal_width
|
|
149
|
+
require 'io/console'
|
|
146
150
|
IO.console&.winsize&.last || 80
|
|
147
151
|
rescue StandardError
|
|
148
152
|
80
|
|
149
153
|
end
|
|
150
154
|
|
|
155
|
+
# Word-wrap text to width, prefixing every line with indent.
|
|
156
|
+
def word_wrap(text, width:, indent: '')
|
|
157
|
+
return "#{indent}#{text}" if text.length <= width
|
|
158
|
+
|
|
159
|
+
words = text.split
|
|
160
|
+
lines = []
|
|
161
|
+
line = +''
|
|
162
|
+
|
|
163
|
+
words.each do |word|
|
|
164
|
+
if line.empty?
|
|
165
|
+
line = word
|
|
166
|
+
elsif line.length + 1 + word.length <= width
|
|
167
|
+
line << ' ' << word
|
|
168
|
+
else
|
|
169
|
+
lines << line
|
|
170
|
+
line = word
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
lines << line unless line.empty?
|
|
174
|
+
|
|
175
|
+
lines.map { |l| "#{indent}#{l}" }.join("\n")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def read_front_matter_text(skill_md_path)
|
|
179
|
+
content = File.read(skill_md_path)
|
|
180
|
+
return '' unless content.start_with?("---")
|
|
181
|
+
|
|
182
|
+
end_pos = content.index("---", 3)
|
|
183
|
+
return '' unless end_pos
|
|
184
|
+
|
|
185
|
+
content[3...end_pos].downcase
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def parse_skill_front_matter(skill_md_path)
|
|
189
|
+
require 'yaml'
|
|
190
|
+
content = File.read(skill_md_path)
|
|
191
|
+
return {} unless content.start_with?("---")
|
|
192
|
+
|
|
193
|
+
end_pos = content.index("---", 3)
|
|
194
|
+
return {} unless end_pos
|
|
195
|
+
|
|
196
|
+
yaml_text = content[3...end_pos].strip
|
|
197
|
+
parsed = YAML.safe_load(yaml_text, symbolize_names: false) rescue {}
|
|
198
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
199
|
+
end
|
|
151
200
|
end
|
|
152
201
|
end
|
data/lib/aia/errors.rb
CHANGED
|
@@ -21,4 +21,19 @@ module AIA
|
|
|
21
21
|
|
|
22
22
|
# Raised for directive parsing or execution failures
|
|
23
23
|
class DirectiveError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised for crew recruit/drop failures
|
|
26
|
+
class CrewError < Error; end
|
|
27
|
+
|
|
28
|
+
# Raised when a quality gate blocks prompt execution
|
|
29
|
+
class GateError < Error; end
|
|
30
|
+
|
|
31
|
+
# Raised when all lead agents fail in LayeredOrchestrator
|
|
32
|
+
class OrchestratorError < Error; end
|
|
33
|
+
|
|
34
|
+
# Raised when all robots fail in a single DebateHandler round
|
|
35
|
+
class DebateError < Error; end
|
|
36
|
+
|
|
37
|
+
# Raised when all sub-tasks fail in SpecialModeHandler decompose mode
|
|
38
|
+
class DecomposeError < Error; end
|
|
24
39
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/fact_asserter.rb
|
|
4
|
+
#
|
|
5
|
+
# Thin adapter that extracts tool_name and tool_description from a tool object.
|
|
6
|
+
# Replaces the KBS FactAsserter with a plain Ruby implementation that reads
|
|
7
|
+
# the standard .name and .description interface common to all AIA tool objects.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class FactAsserter
|
|
11
|
+
# Extract the tool's name as a non-nil String.
|
|
12
|
+
#
|
|
13
|
+
# @param tool [Object] any object that responds to .name
|
|
14
|
+
# @return [String]
|
|
15
|
+
def tool_name(tool)
|
|
16
|
+
String(tool.respond_to?(:name) ? tool.name : "").strip
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Extract the tool's description as a non-nil String.
|
|
20
|
+
#
|
|
21
|
+
# @param tool [Object] any object that responds to .description
|
|
22
|
+
# @return [String]
|
|
23
|
+
def tool_description(tool)
|
|
24
|
+
String(tool.respond_to?(:description) ? tool.description : "").strip
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/aia/fzf.rb
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
require 'shellwords'
|
|
5
5
|
require 'open3'
|
|
6
|
-
require 'tempfile'
|
|
7
6
|
|
|
8
7
|
class AIA::Fzf
|
|
9
8
|
DEFAULT_PARAMETERS = %w[
|
|
@@ -12,19 +11,18 @@ class AIA::Fzf
|
|
|
12
11
|
--prompt='Search term: '
|
|
13
12
|
--delimiter :
|
|
14
13
|
--preview-window=down:50%:wrap
|
|
15
|
-
]
|
|
14
|
+
].freeze
|
|
16
15
|
|
|
17
16
|
attr_reader :list, :directory, :query, :subject, :prompt, :extension, :command
|
|
18
17
|
|
|
19
18
|
def initialize(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
list:, # Array of Strings (basenames of files w/o extension)
|
|
20
|
+
directory:, # Parent directory of the list items
|
|
21
|
+
query: '', # String, the thing be searched for
|
|
22
|
+
subject: 'Prompt IDs', # or 'Role Names'
|
|
23
|
+
prompt: 'Select one:',
|
|
24
|
+
extension: '.md'
|
|
25
|
+
)
|
|
28
26
|
@list = list
|
|
29
27
|
@directory = directory
|
|
30
28
|
@query = query
|
|
@@ -35,7 +33,6 @@ class AIA::Fzf
|
|
|
35
33
|
build_command
|
|
36
34
|
end
|
|
37
35
|
|
|
38
|
-
|
|
39
36
|
def build_command
|
|
40
37
|
fzf_options = DEFAULT_PARAMETERS.dup
|
|
41
38
|
escaped_dir = Shellwords.escape(directory)
|
|
@@ -47,29 +44,10 @@ class AIA::Fzf
|
|
|
47
44
|
@fzf_args = fzf_options
|
|
48
45
|
end
|
|
49
46
|
|
|
50
|
-
|
|
51
47
|
def run
|
|
52
48
|
input = list.join("\n")
|
|
53
|
-
selected,
|
|
54
|
-
|
|
49
|
+
selected, _status = Open3.capture2('fzf', *@fzf_args, stdin_data: input)
|
|
55
50
|
selected.strip.empty? ? nil : selected.strip
|
|
56
|
-
ensure
|
|
57
|
-
unlink_tempfile
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
##############################################
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def tempfile_path
|
|
64
|
-
@tempfile ||= Tempfile.new('fzf-input').tap do |file|
|
|
65
|
-
list.each { |item| file.puts item }
|
|
66
|
-
file.close
|
|
67
|
-
end
|
|
68
|
-
@tempfile.path
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def unlink_tempfile
|
|
72
|
-
@tempfile&.unlink
|
|
73
51
|
end
|
|
74
52
|
end
|
|
75
53
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/handler_context.rb
|
|
4
|
+
#
|
|
5
|
+
# Value object passed to every handler's handle(context) method.
|
|
6
|
+
# All fields are optional (default nil) — each handler reads only what it needs.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
HandlerContext = Struct.new(
|
|
10
|
+
:robot, # RobotLab::Robot or Network for the current turn
|
|
11
|
+
:prompt, # String — the user prompt
|
|
12
|
+
:config, # AIA config object
|
|
13
|
+
:specialist_type, # String or nil — explicit specialist role for SpawnHandler
|
|
14
|
+
:spawn_spec, # Hash or nil — explicit {name:, model:, provider:, system_prompt:} for SpawnHandler
|
|
15
|
+
keyword_init: true
|
|
16
|
+
)
|
|
17
|
+
end
|