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.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +9 -1
  3. data/.loki +11 -0
  4. data/.reek.yml +160 -0
  5. data/.rubocop.yml +116 -0
  6. data/.rubocop_strict.yml +15 -0
  7. data/.version +1 -1
  8. data/CHANGELOG.md +269 -55
  9. data/IMPLEMENTATION_PLAN.md +506 -0
  10. data/README.md +267 -239
  11. data/Rakefile +5 -5
  12. data/_typos.toml +10 -0
  13. data/aia.gemspec +92 -0
  14. data/architecture_review.md +314 -0
  15. data/bin/aia +16 -0
  16. data/config/aia.yml +13 -0
  17. data/docs/AGENTS.md +40 -0
  18. data/docs/advanced-prompting.md +67 -3
  19. data/docs/cli-reference.md +312 -56
  20. data/docs/configuration.md +130 -19
  21. data/docs/contributing.md +56 -2
  22. data/docs/directives-reference.md +593 -78
  23. data/docs/faq.md +85 -3
  24. data/docs/guides/available-models.md +1 -1
  25. data/docs/guides/basic-usage.md +6 -6
  26. data/docs/guides/chat.md +40 -16
  27. data/docs/guides/crew.md +239 -0
  28. data/docs/guides/executable-prompts.md +1 -1
  29. data/docs/guides/index.md +1 -0
  30. data/docs/guides/models.md +15 -0
  31. data/docs/index.md +29 -2
  32. data/docs/installation.md +44 -17
  33. data/docs/mcp-integration.md +40 -0
  34. data/docs/prompt_management.md +85 -86
  35. data/docs/security.md +47 -0
  36. data/docs/special_projects_guide.md +386 -0
  37. data/docs/tools-and-mcp-examples.md +23 -0
  38. data/docs/workflows-and-pipelines.md +84 -7
  39. data/examples/.gitignore +1 -0
  40. data/examples/00_setup_aia.sh +27 -44
  41. data/examples/11_multi_model.sh +4 -14
  42. data/examples/12_token_usage.sh +3 -12
  43. data/examples/18_tools.sh +10 -2
  44. data/examples/22_chat_mode.sh +0 -10
  45. data/examples/23_verify.sh +139 -0
  46. data/examples/24_decompose.sh +139 -0
  47. data/examples/25_spawn.sh +139 -0
  48. data/examples/26_debate.sh +97 -0
  49. data/examples/27_mention_routing.sh +157 -0
  50. data/examples/28_model_switching.sh +106 -0
  51. data/examples/29_agent_harness.sh +177 -0
  52. data/examples/README.md +65 -0
  53. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  54. data/examples/aia_config.yml +1 -1
  55. data/examples/aia_config_orchestrator.yml +45 -0
  56. data/examples/common.sh +18 -6
  57. data/examples/context/tech_stack.md +2 -2
  58. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  59. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  60. data/examples/rules/01_classify_ruby.rb +16 -0
  61. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  62. data/examples/rules/03_gate_prompt_length.rb +19 -0
  63. data/examples/rules/04_tool_selection.rb +41 -0
  64. data/examples/rules/README.md +30 -0
  65. data/examples/run_all.sh +48 -15
  66. data/examples/tools/word_count_tool.rb +1 -1
  67. data/lib/AGENTS.md +57 -0
  68. data/lib/aia/chat_loop.rb +263 -167
  69. data/lib/aia/config/cli_parser.rb +217 -145
  70. data/lib/aia/config/defaults.yml +62 -33
  71. data/lib/aia/config/mcp_parser.rb +52 -51
  72. data/lib/aia/config/model_spec.rb +34 -2
  73. data/lib/aia/config/validator.rb +171 -216
  74. data/lib/aia/config.rb +111 -145
  75. data/lib/aia/content_extractor.rb +155 -0
  76. data/lib/aia/cost_calculator.rb +39 -0
  77. data/lib/aia/crew.rb +164 -0
  78. data/lib/aia/debate_handler.rb +174 -0
  79. data/lib/aia/delegate_handler.rb +116 -0
  80. data/lib/aia/directive.rb +43 -26
  81. data/lib/aia/directive_processor.rb +16 -7
  82. data/lib/aia/directives/configuration_directives.rb +214 -60
  83. data/lib/aia/directives/context_directives.rb +67 -52
  84. data/lib/aia/directives/execution_directives.rb +141 -4
  85. data/lib/aia/directives/model_directives.rb +163 -141
  86. data/lib/aia/directives/trakflow_directives.rb +62 -0
  87. data/lib/aia/directives/utility_directives.rb +227 -30
  88. data/lib/aia/directives/web_and_file_directives.rb +126 -77
  89. data/lib/aia/errors.rb +15 -0
  90. data/lib/aia/fact_asserter.rb +27 -0
  91. data/lib/aia/fzf.rb +9 -31
  92. data/lib/aia/handler_context.rb +17 -0
  93. data/lib/aia/handler_protocol.rb +19 -0
  94. data/lib/aia/history_transfer.rb +55 -0
  95. data/lib/aia/input_collector.rb +3 -3
  96. data/lib/aia/layered_orchestrator.rb +471 -0
  97. data/lib/aia/logger.rb +45 -25
  98. data/lib/aia/mcp_config_normalizer.rb +35 -0
  99. data/lib/aia/mcp_connection_manager.rb +315 -0
  100. data/lib/aia/mcp_discovery.rb +44 -0
  101. data/lib/aia/mcp_grouper.rb +33 -0
  102. data/lib/aia/mcp_server_config.rb +30 -0
  103. data/lib/aia/mcp_utility.rb +60 -0
  104. data/lib/aia/mention_router.rb +217 -0
  105. data/lib/aia/model_alias_registry.rb +97 -0
  106. data/lib/aia/model_switch_handler.rb +100 -0
  107. data/lib/aia/network_builder.rb +160 -0
  108. data/lib/aia/network_memory_manager.rb +55 -0
  109. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  110. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  111. data/lib/aia/pipeline_orchestrator.rb +272 -0
  112. data/lib/aia/plugin_loader.rb +170 -0
  113. data/lib/aia/plugin_monitor.rb +211 -0
  114. data/lib/aia/prompt_decomposer.rb +159 -0
  115. data/lib/aia/prompt_handler.rb +56 -82
  116. data/lib/aia/robot_builder.rb +51 -0
  117. data/lib/aia/robot_factory.rb +338 -0
  118. data/lib/aia/robot_namer.rb +110 -0
  119. data/lib/aia/session.rb +87 -17
  120. data/lib/aia/session_tracker.rb +207 -0
  121. data/lib/aia/similarity_scorer.rb +41 -0
  122. data/lib/aia/skill_utils.rb +105 -1
  123. data/lib/aia/spawn_handler.rb +129 -0
  124. data/lib/aia/spawn_spec_parser.rb +65 -0
  125. data/lib/aia/special_mode_handler.rb +322 -0
  126. data/lib/aia/speech.rb +67 -0
  127. data/lib/aia/startup_coordinator.rb +151 -0
  128. data/lib/aia/streaming_runner.rb +172 -0
  129. data/lib/aia/system_prompt_assembler.rb +92 -0
  130. data/lib/aia/task_coordinator.rb +207 -0
  131. data/lib/aia/task_decomposer.rb +57 -0
  132. data/lib/aia/task_executor.rb +51 -0
  133. data/lib/aia/tfidf_math.rb +27 -0
  134. data/lib/aia/timing.rb +15 -0
  135. data/lib/aia/tool_filter/tfidf.rb +116 -0
  136. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  137. data/lib/aia/tool_filter.rb +83 -0
  138. data/lib/aia/tool_filter_registry.rb +30 -0
  139. data/lib/aia/tool_filter_strategy.rb +146 -0
  140. data/lib/aia/tool_introspection.rb +17 -0
  141. data/lib/aia/tool_loader.rb +216 -0
  142. data/lib/aia/tool_utility.rb +30 -0
  143. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  144. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  145. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  146. data/lib/aia/tools/task_board_tool.rb +115 -0
  147. data/lib/aia/trakflow_bridge.rb +175 -0
  148. data/lib/aia/turn_state.rb +95 -0
  149. data/lib/aia/ui_presenter.rb +182 -206
  150. data/lib/aia/utility.rb +136 -87
  151. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
  152. data/lib/aia/verification_network.rb +57 -0
  153. data/lib/aia.rb +124 -63
  154. data/mkdocs.yml +1 -0
  155. metadata +187 -58
  156. data/justfile +0 -215
  157. data/lib/aia/adapter/chat_execution.rb +0 -242
  158. data/lib/aia/adapter/error_handler.rb +0 -68
  159. data/lib/aia/adapter/gem_activator.rb +0 -57
  160. data/lib/aia/adapter/mcp_connector.rb +0 -274
  161. data/lib/aia/adapter/modality_handlers.rb +0 -167
  162. data/lib/aia/adapter/model_registry.rb +0 -81
  163. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  164. data/lib/aia/adapter/provider_configurator.rb +0 -59
  165. data/lib/aia/adapter/tool_filter.rb +0 -85
  166. data/lib/aia/adapter/tool_loader.rb +0 -90
  167. data/lib/aia/chat_processor_service.rb +0 -178
  168. data/lib/aia/prompt_pipeline.rb +0 -183
  169. data/lib/aia/ruby_llm_adapter.rb +0 -95
  170. data/lib/extensions/openstruct_merge.rb +0 -48
  171. data/lib/extensions/ruby_llm/.irbrc +0 -56
  172. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  173. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  174. data/lib/refinements/string.rb +0 -16
  175. 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
- indent = 4
11
- spaces = " " * indent
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: #{args.first}"
18
+ puts "No tools match the filter: #{raw_filter}"
30
19
  else
31
- puts
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 "Add instruction for concise responses"
50
- def terse(args, context_manager = nil)
51
- "" # DEPRECATED: terse mode has been removed
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
- else
19
- url = args.shift.to_s.strip
20
- puremd_url = "https://pure.md/#{url}"
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
- response = Faraday.get(puremd_url) do |req|
23
- req.headers['x-puremd-api-token'] = PUREMD_API_KEY
24
- end
22
+ url = args.shift.to_s.strip
23
+ puremd_url = "https://pure.md/#{url}"
25
24
 
26
- if 200 == response.status
27
- response.body
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
- alias_method :website, :webpage
34
- alias_method :web, :webpage
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
- skill_dirs = Dir.children(dir)
48
- .select { |e| Dir.exist?(File.join(dir, e)) }
49
- .sort
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
- width = terminal_width
70
- puts "\nAvailable Skills (#{dir}):"
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
- warn "Error: /skill requires a skill name. Use /skills to list available skills."
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
- if path_based_id?(skill_name)
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
- warn "Error: Skill directory '#{File.basename(skill_dir)}' has no SKILL.md. Use /skills to list available skills."
123
- AIA::LoggerManager.aia_logger.error("Skill directory '#{File.basename(skill_dir)}' has no SKILL.md")
124
- return nil
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
- alias_method :clipboard, :paste
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.dir
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
- list:, # Array of Strings (basenames of files w/o extension)
21
- directory:, # Parent directory of the list items
22
- query: '', # String, the thing be searched for
23
- subject: 'Prompt IDs', # or 'Role Names'
24
- prompt: 'Select one:',
25
- extension: '.md'
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, status = Open3.capture2('fzf', *@fzf_args, stdin_data: input)
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