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
data/lib/aia/config.rb CHANGED
@@ -18,7 +18,7 @@
18
18
  # 5. Embedded directives (/config)
19
19
  #
20
20
  # When -c / --config-file is used, it REPLACES sources 2 and 3:
21
- # defaults → config file → CLI arguments → embedded directives
21
+ # defaults -> config file -> CLI arguments -> embedded directives
22
22
 
23
23
  require 'myway_config'
24
24
  require 'yaml'
@@ -28,7 +28,7 @@ require_relative 'config/model_spec'
28
28
  require_relative 'config/mcp_parser'
29
29
 
30
30
  module AIA
31
- # Backward compatibility alias — existing code and tests reference AIA::ConfigSection
31
+ # Backward compatibility alias
32
32
  ConfigSection = MywayConfig::ConfigSection
33
33
 
34
34
  class Config < MywayConfig::Base
@@ -36,7 +36,7 @@ module AIA
36
36
  env_prefix :aia
37
37
  defaults_path File.expand_path('config/defaults.yml', __dir__)
38
38
 
39
- # AIA is a CLI tool, not a Rails app — no environment sections needed.
39
+ # AIA is a CLI tool, not a Rails app -- no environment sections needed.
40
40
  class << self
41
41
  def validate_environment!
42
42
  # no-op: AIA has no environment sections in defaults.yml
@@ -49,7 +49,8 @@ module AIA
49
49
 
50
50
  # Nested section attributes (defined as hashes, converted to ConfigSection)
51
51
  attr_config :service, :llm, :prompts, :roles, :skills, :output, :audio, :image, :embedding,
52
- :tools, :flags, :registry, :paths, :logger
52
+ :tools, :flags, :registry, :paths, :logger, :rules, :concurrency,
53
+ :tool_filter
53
54
 
54
55
  # Array/collection attributes
55
56
  attr_config :models, :pipeline, :require_libs, :mcp_servers, :mcp_use, :mcp_skip, :context_files
@@ -58,17 +59,18 @@ module AIA
58
59
  attr_accessor :prompt_id, :stdin_content, :remaining_args, :dump_file,
59
60
  :completion, :mcp_list, :list_tools, :list_skills,
60
61
  :executable_prompt_content,
61
- :tool_names, :loaded_tools,
62
+ :tool_names, :loaded_tools, :loaded_plugins,
62
63
  :log_level_override, :log_file_override,
63
- :connected_mcp_servers, # Array of successfully connected MCP server names
64
- :failed_mcp_servers # Array of {name:, error:} hashes for failed MCP servers
64
+ :connected_mcp_servers, # Array of successfully connected MCP server names
65
+ :mcp_server_tool_counts, # Hash of name => tool_count for connected MCP servers
66
+ :failed_mcp_servers # Array of {name:, error:} hashes for failed MCP servers
65
67
 
66
68
  # ==========================================================================
67
69
  # Type Coercion
68
70
  # ==========================================================================
69
71
 
70
72
  # Convert array of hashes to array of ModelSpec objects
71
- TO_MODEL_SPECS = ->(v) {
73
+ TO_MODEL_SPECS = lambda { |v|
72
74
  return [] if v.nil?
73
75
  return v if v.is_a?(Array) && v.first.is_a?(ModelSpec)
74
76
 
@@ -113,8 +115,12 @@ module AIA
113
115
  embedding: config_section_coercion(:embedding),
114
116
  tools: config_section_coercion(:tools),
115
117
  flags: config_section_coercion(:flags),
118
+ logger: config_section_coercion(:logger),
116
119
  registry: config_section_coercion(:registry),
117
120
  paths: config_section_coercion(:paths),
121
+ rules: config_section_coercion(:rules),
122
+ concurrency: config_section_coercion(:concurrency),
123
+ tool_filter: config_section_coercion(:tool_filter),
118
124
 
119
125
  # Arrays
120
126
  models: TO_MODEL_SPECS,
@@ -152,50 +158,65 @@ module AIA
152
158
  # Mapping of flat CLI keys to their nested config locations
153
159
  CLI_TO_NESTED_MAP = {
154
160
  # flags section
155
- chat: [:flags, :chat],
156
- cost: [:flags, :cost],
157
- fuzzy: [:flags, :fuzzy],
158
- tokens: [:flags, :tokens],
159
- no_mcp: [:flags, :no_mcp],
160
- terse: [:flags, :terse],
161
- debug: [:flags, :debug],
162
- verbose: [:flags, :verbose],
163
- consensus: [:flags, :consensus],
161
+ chat: %i[flags chat],
162
+ cost: %i[flags cost],
163
+ fuzzy: %i[flags fuzzy],
164
+ tokens: %i[flags tokens],
165
+ no_mcp: %i[flags no_mcp],
166
+ debug: %i[flags debug],
167
+ verbose: %i[flags verbose],
168
+ consensus: %i[flags consensus],
169
+ track_pipeline: %i[flags track_pipeline],
170
+ expert_routing: %i[flags expert_routing],
171
+ auto_tool_filter: %i[flags auto_tool_filter],
172
+ tool_filter_load: %i[flags tool_filter_load],
173
+ tool_filter_save: %i[flags tool_filter_save],
174
+ allow_ruby_eval: %i[flags allow_ruby_eval],
175
+ thinking: %i[flags thinking],
176
+ tool_filter_timeout_s: %i[tool_filter timeout_s],
177
+ concurrent_auto: %i[concurrency auto],
164
178
  # llm section
165
- temperature: [:llm, :temperature],
166
- max_tokens: [:llm, :max_tokens],
167
- top_p: [:llm, :top_p],
168
- frequency_penalty: [:llm, :frequency_penalty],
169
- presence_penalty: [:llm, :presence_penalty],
179
+ temperature: %i[llm temperature],
180
+ max_tokens: %i[llm max_tokens],
181
+ top_p: %i[llm top_p],
182
+ frequency_penalty: %i[llm frequency_penalty],
183
+ presence_penalty: %i[llm presence_penalty],
170
184
  # prompts section
171
- prompts_dir: [:prompts, :dir],
172
- roles_prefix: [:prompts, :roles_prefix],
173
- role: [:prompts, :role],
174
- skills_dir: [:skills, :dir],
175
- skills_prefix: [:prompts, :skills_prefix],
176
- skills: [:prompts, :skills],
177
- parameter_regex: [:prompts, :parameter_regex],
178
- system_prompt: [:prompts, :system_prompt],
185
+ prompts_dir: %i[prompts dir],
186
+ roles_prefix: %i[prompts roles_prefix],
187
+ role: %i[prompts role],
188
+ skills_dir: %i[skills dir],
189
+ skills: %i[prompts skills],
190
+ tools_prefix: %i[prompts tools_prefix],
191
+ tool: %i[prompts tool],
192
+ system_prompt: %i[prompts system_prompt],
179
193
  # output section
180
- output: [:output, :file],
181
- history_file: [:output, :history_file],
182
- append: [:output, :append],
183
- markdown: [:output, :markdown],
184
- # audio section
185
- speak: [:audio, :speak],
186
- voice: [:audio, :voice],
187
- speech_model: [:audio, :speech_model],
188
- transcription_model: [:audio, :transcription_model],
194
+ output: %i[output file],
195
+ history_file: %i[output history_file],
196
+ append: %i[output append],
197
+ markdown: %i[output markdown],
198
+ # audio section (speak is a flag, not audio config)
199
+ speak: %i[flags speak],
200
+ voice: %i[audio voice],
201
+ speak_command: %i[audio speak_command],
202
+ speech_model: %i[audio speech_model],
203
+ transcription_model: %i[audio transcription_model],
189
204
  # image section
190
- image_size: [:image, :size],
191
- image_quality: [:image, :quality],
192
- image_style: [:image, :style],
205
+ paint: %i[flags paint],
206
+ image_model: %i[image model],
207
+ image_size: %i[image size],
208
+ image_quality: %i[image quality],
209
+ image_style: %i[image style],
193
210
  # tools section
194
- tool_paths: [:tools, :paths],
195
- allowed_tools: [:tools, :allowed],
196
- rejected_tools: [:tools, :rejected],
211
+ tool_paths: %i[tools paths],
212
+ allowed_tools: %i[tools allowed],
213
+ rejected_tools: %i[tools rejected],
197
214
  # registry section
198
- refresh: [:registry, :refresh],
215
+ refresh: %i[registry refresh],
216
+ # paths section
217
+ plugins_dir: %i[paths plugins_dir],
218
+ # rules section
219
+ rules_enabled: %i[rules enabled]
199
220
  }.freeze
200
221
 
201
222
  def initialize(overrides: {})
@@ -207,7 +228,9 @@ module AIA
207
228
  load_extra_config(extra_config_path) if extra_config_path
208
229
 
209
230
  apply_models_env_var unless overrides[:models]
231
+ apply_plugins_dir_env_var unless overrides[:plugins_dir]
210
232
  apply_overrides(overrides) if overrides && !overrides.empty?
233
+ expand_paths
211
234
  process_mcp_files(overrides[:mcp_files]) if overrides[:mcp_files]
212
235
  end
213
236
 
@@ -223,6 +246,8 @@ module AIA
223
246
  section, nested_key = CLI_TO_NESTED_MAP[key_sym]
224
247
  section_obj = send(section)
225
248
  section_obj.send("#{nested_key}=", value) if section_obj.respond_to?("#{nested_key}=")
249
+ elsif key_sym == :models
250
+ self.models = TO_MODEL_SPECS.call(Array(value))
226
251
  elsif respond_to?("#{key}=")
227
252
  send("#{key}=", value)
228
253
  elsif key.to_s.include?('__')
@@ -234,6 +259,7 @@ module AIA
234
259
  end
235
260
 
236
261
  # Convert config to hash (for dump, etc.)
262
+ # rubocop:disable-next Metrics/AbcSize
237
263
  def to_h
238
264
  {
239
265
  service: service.to_h,
@@ -248,8 +274,11 @@ module AIA
248
274
  embedding: embedding.to_h,
249
275
  tools: tools.to_h,
250
276
  flags: flags.to_h,
277
+ logger: logger.to_h,
251
278
  registry: registry.to_h,
252
279
  paths: paths.to_h,
280
+ rules: rules.to_h,
281
+ concurrency: concurrency.to_h,
253
282
  pipeline: pipeline,
254
283
  require_libs: require_libs,
255
284
  mcp_servers: mcp_servers,
@@ -262,37 +291,25 @@ module AIA
262
291
  private
263
292
 
264
293
  # Load a config file that REPLACES the user's personal config.
265
- # Resets all values to bundled defaults first, then applies the
266
- # file's values on top. This means -c gives you a clean slate
267
- # based on defaults + the specified file only.
268
- #
269
- # Uses the same YAML structure as defaults.yml (nested sections).
270
- # Supports both flat format and wrapped (defaults:) format.
271
- #
272
- # @param path [String] path to the YAML config file
294
+ # :reek:TooManyStatements -- single case dispatch mapping YAML keys to typed config setters; one branch per key family
295
+ # :reek:DuplicateMethodCall -- Array(value) sits in mutually exclusive case branches; hoisting it would coerce hash-section values too
273
296
  def load_extra_config(path)
274
297
  path = File.expand_path(path)
275
298
 
276
299
  unless File.exist?(path)
277
- warn "ERROR: Config file not found: #{path}"
300
+ $stderr.puts "ERROR: Config file not found: #{path}"
278
301
  exit 1
279
- return # guard for test environments that override exit
302
+ return # rubocop:disable Lint/UnreachableCode -- exit is mocked in tests
280
303
  end
281
304
 
282
- # Reset everything to bundled defaults so the user's personal
283
- # config (~/.config/aia/aia.yml) does not bleed through.
284
305
  reset_to_defaults
285
306
 
286
- raw = YAML.safe_load(
287
- File.read(path),
288
- permitted_classes: [Symbol],
307
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol],
289
308
  symbolize_names: true,
290
- aliases: true
291
- ) || {}
309
+ aliases: true) || {}
292
310
 
293
311
  config_hash = raw.key?(:defaults) ? (raw[:defaults] || {}) : raw
294
312
 
295
- # Store the path for reference (visible in --dump output)
296
313
  paths[:extra_config_file] = path
297
314
 
298
315
  config_hash.each do |key, value|
@@ -304,7 +321,7 @@ module AIA
304
321
  when :mcp_servers
305
322
  self.mcp_servers = Array(value)
306
323
  when :service, :llm, :prompts, :roles, :skills, :output, :audio, :image, :embedding,
307
- :tools, :flags, :registry, :paths, :logger
324
+ :tools, :flags, :registry, :paths, :logger, :rules, :concurrency
308
325
  section = send(key)
309
326
  if section.is_a?(MywayConfig::ConfigSection) && value.is_a?(Hash)
310
327
  merge_into_section(section, value)
@@ -315,9 +332,6 @@ module AIA
315
332
  expand_paths
316
333
  end
317
334
 
318
- # Reset all configuration values to the bundled defaults from
319
- # defaults.yml, discarding anything set by the user's personal
320
- # config file or environment variables.
321
335
  def reset_to_defaults
322
336
  defaults = self.class.schema
323
337
 
@@ -334,10 +348,6 @@ module AIA
334
348
  end
335
349
  end
336
350
 
337
- # Recursively reset a ConfigSection to match the given defaults hash.
338
- #
339
- # @param section [MywayConfig::ConfigSection] target section
340
- # @param defaults_hash [Hash] default values to restore
341
351
  def reset_section(section, defaults_hash)
342
352
  defaults_hash.each do |key, value|
343
353
  existing = section[key.to_sym]
@@ -349,11 +359,6 @@ module AIA
349
359
  end
350
360
  end
351
361
 
352
- # Recursively merge a hash into a ConfigSection, preserving
353
- # existing values for keys not present in the hash.
354
- #
355
- # @param section [MywayConfig::ConfigSection] target section
356
- # @param hash [Hash] values to merge in
357
362
  def merge_into_section(section, hash)
358
363
  hash.each do |key, value|
359
364
  existing = section[key.to_sym]
@@ -365,76 +370,59 @@ module AIA
365
370
  end
366
371
  end
367
372
 
368
- # Apply AIA_MODEL env var if set (supports comma-separated models with optional roles)
369
- # Format: MODEL[=ROLE][,MODEL[=ROLE]]...
370
373
  def apply_models_env_var
371
- models_env = ENV['AIA_MODEL']
374
+ models_env = ENV.fetch('AIA_MODEL', nil)
372
375
  return if models_env.nil? || models_env.empty?
373
376
 
374
377
  self.models = TO_MODEL_SPECS.call(models_env.split(',').map(&:strip))
375
378
  end
376
379
 
377
- def expand_paths
378
- # Expand ~ in paths
379
- if paths.aia_dir
380
- paths.aia_dir = File.expand_path(paths.aia_dir)
381
- end
382
-
383
- if paths.config_file
384
- paths.config_file = File.expand_path(paths.config_file)
385
- end
386
-
387
- if prompts.dir
388
- prompts.dir = File.expand_path(prompts.dir)
389
- end
390
-
391
- if prompts.roles_dir
392
- prompts.roles_dir = File.expand_path(prompts.roles_dir)
393
- end
380
+ # Backward-compatible top-level env var for plugin directory.
381
+ # Unlike most nested config values, this uses AIA_PLUGINS_DIR per requirements.
382
+ def apply_plugins_dir_env_var
383
+ plugins_dir_env = ENV.fetch('AIA_PLUGINS_DIR', nil)
384
+ return if plugins_dir_env.nil? || plugins_dir_env.empty?
394
385
 
395
- if output.history_file
396
- output.history_file = File.expand_path(output.history_file)
397
- end
398
-
399
- if roles.dir
400
- roles.dir = File.expand_path(roles.dir)
401
- end
386
+ paths.plugins_dir = plugins_dir_env
387
+ end
402
388
 
403
- if skills.dir
404
- skills.dir = File.expand_path(skills.dir)
405
- end
389
+ def expand_paths
390
+ expand_path_on(paths, :aia_dir)
391
+ expand_path_on(paths, :config_file)
392
+ expand_path_on(paths, :plugins_dir)
393
+ expand_path_on(prompts, :dir)
394
+ expand_path_on(prompts, :roles_dir)
395
+ expand_path_on(roles, :dir)
396
+ expand_path_on(skills, :dir)
397
+ expand_path_on(tools, :dir)
398
+ expand_path_on(output, :history_file)
399
+ expand_path_on(rules, :dir)
400
+ end
406
401
 
407
- if tools.dir
408
- tools.dir = File.expand_path(tools.dir)
409
- end
402
+ def expand_path_on(obj, method_name)
403
+ return unless obj.respond_to?(method_name)
404
+ val = obj.send(method_name)
405
+ return unless val
406
+ obj.send(:"#{method_name}=", File.expand_path(val))
410
407
  end
411
408
 
412
409
  def ensure_arrays
413
- # Ensure array fields are actually arrays
414
410
  self.pipeline = [] if pipeline.nil?
415
411
  self.require_libs = [] if require_libs.nil?
416
412
  self.context_files = [] if context_files.nil?
417
413
  self.mcp_servers = [] if mcp_servers.nil?
418
414
  self.mcp_use = [] if mcp_use.nil?
419
415
  self.mcp_skip = [] if mcp_skip.nil?
420
-
421
- # Ensure tools.paths is an array
422
416
  tools.paths = [] if tools.paths.nil?
423
-
424
- # Ensure prompts.skills is an array
425
417
  prompts.skills = [] if prompts.respond_to?(:skills) && prompts.skills.nil?
426
418
  end
427
419
 
428
- # Process MCP JSON files and merge servers into mcp_servers
429
- #
430
- # @param mcp_files [Array<String>] paths to MCP JSON configuration files
431
420
  def process_mcp_files(mcp_files)
432
421
  return if mcp_files.nil? || mcp_files.empty?
433
422
 
434
423
  servers_from_files = McpParser.parse_files(mcp_files)
435
424
  return if servers_from_files.empty?
436
425
 
437
- # Merge with existing mcp_servers (CLI files take precedence)
438
426
  self.mcp_servers = (mcp_servers || []) + servers_from_files
439
427
  end
440
428
 
@@ -442,32 +430,10 @@ module AIA
442
430
  section = parts[0].to_sym
443
431
  key = parts[1].to_sym
444
432
 
445
- case section
446
- when :llm
447
- llm.send("#{key}=", value) if llm.respond_to?("#{key}=")
448
- when :prompts
449
- prompts.send("#{key}=", value) if prompts.respond_to?("#{key}=")
450
- when :output
451
- output.send("#{key}=", value) if output.respond_to?("#{key}=")
452
- when :audio
453
- audio.send("#{key}=", value) if audio.respond_to?("#{key}=")
454
- when :image
455
- image.send("#{key}=", value) if image.respond_to?("#{key}=")
456
- when :embedding
457
- embedding.send("#{key}=", value) if embedding.respond_to?("#{key}=")
458
- when :tools
459
- tools.send("#{key}=", value) if tools.respond_to?("#{key}=")
460
- when :flags
461
- flags.send("#{key}=", value) if flags.respond_to?("#{key}=")
462
- when :registry
463
- registry.send("#{key}=", value) if registry.respond_to?("#{key}=")
464
- when :paths
465
- paths.send("#{key}=", value) if paths.respond_to?("#{key}=")
466
- when :roles
467
- roles.send("#{key}=", value) if roles.respond_to?("#{key}=")
468
- when :skills
469
- skills.send("#{key}=", value) if skills.respond_to?("#{key}=")
470
- end
433
+ target = respond_to?(section) ? send(section) : nil
434
+ return unless target.respond_to?("#{key}=")
435
+
436
+ target.send("#{key}=", value)
471
437
  end
472
438
  end
473
439
  end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/content_extractor.rb
4
+ #
5
+ # Shared module for extracting text content from various response types.
6
+ # Handles RobotLab::RobotResult, SimpleFlow::Result, and plain strings.
7
+ # Include this module instead of defining extract_content locally.
8
+
9
+ module AIA
10
+ module ContentExtractor
11
+ # Extract text content from a response object.
12
+ #
13
+ # @param result the response (RobotResult, SimpleFlow::Result, String, etc.)
14
+ # @return [String] the extracted text
15
+ def extract_content(result)
16
+ if defined?(SimpleFlow::Result) && result.is_a?(SimpleFlow::Result)
17
+ return extract_network_content(result)
18
+ end
19
+
20
+ if result.respond_to?(:reply)
21
+ result.reply
22
+ elsif result.respond_to?(:last_text_content)
23
+ result.last_text_content
24
+ elsif result.respond_to?(:content)
25
+ result.content
26
+ else
27
+ result.to_s
28
+ end
29
+ end
30
+
31
+ # Extract content from a Network's SimpleFlow::Result.
32
+ # Each robot's result is stored in context under its task name.
33
+ # Includes per-robot timing when available.
34
+ #
35
+ # @param flow_result [SimpleFlow::Result]
36
+ # @return [String]
37
+ # :reek:TooManyStatements -- one pass duck-types reply/content/duration into labeled sections
38
+ def extract_network_content(flow_result)
39
+ parts = []
40
+ flow_result.context.each do |task_name, robot_result|
41
+ next if task_name == :run_params
42
+
43
+ content = if robot_result.respond_to?(:reply)
44
+ robot_result.reply
45
+ elsif robot_result.respond_to?(:content)
46
+ robot_result.content
47
+ else
48
+ robot_result.to_s
49
+ end
50
+ next unless content && !content.empty?
51
+
52
+ duration = robot_result.respond_to?(:duration) ? robot_result.duration : nil
53
+ robot_name = robot_result.respond_to?(:robot_name) ? robot_result.robot_name : nil
54
+ label = robot_name && robot_name != task_name.to_s ? "#{robot_name} [#{task_name}]" : task_name.to_s
55
+ header = if duration
56
+ "**#{label}** (#{format_duration(duration)}):"
57
+ else
58
+ "**#{label}:**"
59
+ end
60
+ parts << "#{header}\n#{content}"
61
+ end
62
+ parts.join("\n\n")
63
+ end
64
+
65
+ # Store structured robot results into a network's shared memory.
66
+ # Each robot's output is written as a hash under result_<task_name>.
67
+ #
68
+ # @param flow_result [SimpleFlow::Result]
69
+ # @param network [RobotLab::Network]
70
+ def store_results_in_memory(flow_result, network)
71
+ return unless network.respond_to?(:memory)
72
+
73
+ memory = network.memory
74
+ flow_result.context.each do |task_name, robot_result|
75
+ next if task_name == :run_params
76
+ next unless robot_result.respond_to?(:reply)
77
+
78
+ memory.current_writer = task_name.to_s
79
+ memory.set(:"result_#{task_name}", {
80
+ content: robot_result.reply,
81
+ model: robot_result.respond_to?(:robot_name) ? robot_result.robot_name : nil,
82
+ duration: robot_result.respond_to?(:duration) ? robot_result.duration : nil
83
+ })
84
+ end
85
+ end
86
+
87
+ # Present an AI result: extract content, record the turn, display,
88
+ # write to output file, show metrics, speak, and print separator.
89
+ # Callers pass only the keyword args they need; everything optional
90
+ # defaults to nil / false so this works for both full ChatLoop turns
91
+ # and simpler SpecialModeHandler dispatches.
92
+ #
93
+ # @param result the response object
94
+ # @param streamed_content [String, nil] pre-streamed content (skips display_ai_response)
95
+ # @param prompt [String, nil] user prompt for tracker
96
+ # @param elapsed [Float, nil] elapsed seconds
97
+ # @param ui_presenter [UIPresenter] for display calls
98
+ # @param tracker [SessionTracker, nil] to record the turn
99
+ # @param decisions [Hash, nil] routing decisions for tracker
100
+ # @return [String] the extracted content
101
+ # :reek:LongParameterList { max_params: 7 } -- presentation seam: each keyword is an independent optional aspect
102
+ def present_result(result, ui_presenter:, streamed_content: nil, prompt: nil, elapsed: nil, tracker: nil, decisions: nil)
103
+ content = streamed_content || extract_content(result)
104
+
105
+ if tracker && prompt
106
+ tracker.record_turn(
107
+ model: AIA.config.models.first.name,
108
+ input: prompt,
109
+ result: result,
110
+ decisions: decisions,
111
+ elapsed: elapsed
112
+ )
113
+ end
114
+
115
+ if streamed_content
116
+ puts
117
+ else
118
+ ui_presenter.display_ai_response(content)
119
+ end
120
+
121
+ output_to_file(content)
122
+ display_metrics(result, elapsed: elapsed) if respond_to?(:display_metrics, true)
123
+ speak(content) if respond_to?(:speak, true)
124
+ ui_presenter.display_separator
125
+
126
+ content
127
+ end
128
+
129
+ # Write AI response content to the configured output file.
130
+ #
131
+ # @param content [String] the AI response text
132
+ def output_to_file(content)
133
+ out_file = AIA.config.output.file
134
+ return unless out_file
135
+
136
+ File.open(out_file, 'a') { |f| f.puts "\nAI: #{content}" }
137
+ end
138
+
139
+ # Format a duration in seconds to a human-readable string.
140
+ #
141
+ # @param seconds [Float, nil]
142
+ # @return [String]
143
+ def format_duration(seconds)
144
+ return "0.0s" unless seconds
145
+
146
+ if seconds < 60
147
+ "%.1fs" % seconds
148
+ else
149
+ minutes = (seconds / 60).to_i
150
+ secs = seconds % 60
151
+ "#{minutes}m %04.1fs" % secs
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/cost_calculator.rb
4
+ #
5
+ # Shared cost calculation logic used by SessionTracker and UIPresenter.
6
+ # Looks up per-million token pricing from RubyLLM::Models and returns
7
+ # a breakdown hash.
8
+
9
+ module AIA
10
+ module CostCalculator
11
+ # @param model_id [String, nil]
12
+ # @param input_tokens [Integer]
13
+ # @param output_tokens [Integer]
14
+ # @return [Hash] { available: true, total_cost:, input_cost:, output_cost: }
15
+ # or { available: false } when pricing is unavailable
16
+ # :reek:TooManyStatements -- linear price lookup: guard, registry fetch, per-direction cost math, result hash
17
+ def self.calculate(model_id:, input_tokens:, output_tokens:)
18
+ return { available: false } unless model_id && defined?(RubyLLM::Models)
19
+
20
+ # RubyLLM::Models.find raises ModelNotFoundError on an unknown id (it never
21
+ # returns nil), so the unknown-model case is handled by the rescue below.
22
+ model_info = RubyLLM::Models.find(model_id)
23
+
24
+ input_price = model_info.respond_to?(:input_price_per_million) ? model_info.input_price_per_million : nil
25
+ output_price = model_info.respond_to?(:output_price_per_million) ? model_info.output_price_per_million : nil
26
+ return { available: false } unless input_price && output_price
27
+
28
+ input_cost = input_tokens * input_price / 1_000_000.0
29
+ output_cost = output_tokens * output_price / 1_000_000.0
30
+
31
+ { available: true, total_cost: input_cost + output_cost, input_cost: input_cost, output_cost: output_cost }
32
+ rescue RubyLLM::ModelNotFoundError
33
+ # Expected when pricing for the model isn't in the registry — not an error.
34
+ { available: false }
35
+ rescue StandardError => e
36
+ { available: false, error: e.message }
37
+ end
38
+ end
39
+ end