aia 1.1.1 → 2.0.0.0.pre.alpha

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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +231 -0
  4. data/.quality/flay_baseline.txt +1 -0
  5. data/.quality/flog_baseline.txt +29 -0
  6. data/.quality/reek_baseline.txt +80 -0
  7. data/.rubocop.yml +116 -0
  8. data/.version +1 -1
  9. data/CHANGELOG.md +259 -50
  10. data/IMPLEMENTATION_PLAN.md +506 -0
  11. data/README.md +266 -238
  12. data/Rakefile +118 -5
  13. data/architecture_review.md +314 -0
  14. data/bin/aia +16 -0
  15. data/docs/AGENTS.md +40 -0
  16. data/docs/advanced-prompting.md +67 -3
  17. data/docs/cli-reference.md +312 -56
  18. data/docs/configuration.md +130 -19
  19. data/docs/contributing.md +56 -2
  20. data/docs/directives-reference.md +593 -78
  21. data/docs/faq.md +85 -3
  22. data/docs/guides/available-models.md +1 -1
  23. data/docs/guides/basic-usage.md +6 -6
  24. data/docs/guides/chat.md +40 -16
  25. data/docs/guides/crew.md +239 -0
  26. data/docs/guides/executable-prompts.md +1 -1
  27. data/docs/guides/index.md +1 -0
  28. data/docs/guides/models.md +15 -0
  29. data/docs/index.md +29 -2
  30. data/docs/installation.md +44 -17
  31. data/docs/mcp-integration.md +40 -0
  32. data/docs/prompt_management.md +85 -86
  33. data/docs/security.md +47 -0
  34. data/docs/special_projects_guide.md +386 -0
  35. data/docs/tools-and-mcp-examples.md +23 -0
  36. data/docs/workflows-and-pipelines.md +84 -7
  37. data/examples/.gitignore +1 -0
  38. data/examples/00_setup_aia.sh +27 -44
  39. data/examples/11_multi_model.sh +4 -14
  40. data/examples/12_token_usage.sh +3 -12
  41. data/examples/18_tools.sh +10 -2
  42. data/examples/22_chat_mode.sh +0 -10
  43. data/examples/23_verify.sh +139 -0
  44. data/examples/24_decompose.sh +139 -0
  45. data/examples/25_spawn.sh +139 -0
  46. data/examples/26_debate.sh +97 -0
  47. data/examples/27_mention_routing.sh +157 -0
  48. data/examples/28_model_switching.sh +106 -0
  49. data/examples/29_agent_harness.sh +177 -0
  50. data/examples/README.md +65 -0
  51. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  52. data/examples/aia_config.yml +1 -1
  53. data/examples/aia_config_orchestrator.yml +45 -0
  54. data/examples/common.sh +18 -6
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  57. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  58. data/examples/rules/01_classify_ruby.rb +16 -0
  59. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  60. data/examples/rules/03_gate_prompt_length.rb +19 -0
  61. data/examples/rules/04_tool_selection.rb +41 -0
  62. data/examples/rules/README.md +30 -0
  63. data/examples/run_all.sh +48 -15
  64. data/examples/tools/word_count_tool.rb +1 -1
  65. data/lib/AGENTS.md +57 -0
  66. data/lib/aia/chat_loop.rb +304 -164
  67. data/lib/aia/config/cli_parser.rb +174 -111
  68. data/lib/aia/config/defaults.yml +62 -33
  69. data/lib/aia/config/mcp_parser.rb +39 -46
  70. data/lib/aia/config/model_spec.rb +34 -2
  71. data/lib/aia/config/validator.rb +108 -142
  72. data/lib/aia/config.rb +110 -145
  73. data/lib/aia/content_extractor.rb +153 -0
  74. data/lib/aia/cost_calculator.rb +38 -0
  75. data/lib/aia/crew.rb +164 -0
  76. data/lib/aia/debate_handler.rb +166 -0
  77. data/lib/aia/delegate_handler.rb +112 -0
  78. data/lib/aia/directive.rb +33 -18
  79. data/lib/aia/directive_processor.rb +16 -7
  80. data/lib/aia/directives/configuration_directives.rb +160 -20
  81. data/lib/aia/directives/context_directives.rb +38 -26
  82. data/lib/aia/directives/execution_directives.rb +136 -4
  83. data/lib/aia/directives/model_directives.rb +76 -34
  84. data/lib/aia/directives/trakflow_directives.rb +44 -0
  85. data/lib/aia/directives/utility_directives.rb +203 -6
  86. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  87. data/lib/aia/errors.rb +15 -0
  88. data/lib/aia/fact_asserter.rb +27 -0
  89. data/lib/aia/fzf.rb +9 -31
  90. data/lib/aia/handler_context.rb +17 -0
  91. data/lib/aia/handler_protocol.rb +19 -0
  92. data/lib/aia/history_transfer.rb +55 -0
  93. data/lib/aia/input_collector.rb +3 -3
  94. data/lib/aia/layered_orchestrator.rb +448 -0
  95. data/lib/aia/logger.rb +24 -4
  96. data/lib/aia/mcp_config_normalizer.rb +35 -0
  97. data/lib/aia/mcp_connection_manager.rb +305 -0
  98. data/lib/aia/mcp_discovery.rb +44 -0
  99. data/lib/aia/mcp_grouper.rb +33 -0
  100. data/lib/aia/mcp_utility.rb +57 -0
  101. data/lib/aia/mention_router.rb +260 -0
  102. data/lib/aia/model_alias_registry.rb +97 -0
  103. data/lib/aia/model_switch_handler.rb +100 -0
  104. data/lib/aia/network_builder.rb +155 -0
  105. data/lib/aia/network_memory_manager.rb +55 -0
  106. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  107. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  108. data/lib/aia/pipeline_orchestrator.rb +262 -0
  109. data/lib/aia/plugin_loader.rb +170 -0
  110. data/lib/aia/plugin_monitor.rb +208 -0
  111. data/lib/aia/prompt_decomposer.rb +157 -0
  112. data/lib/aia/prompt_handler.rb +19 -39
  113. data/lib/aia/robot_builder.rb +51 -0
  114. data/lib/aia/robot_factory.rb +334 -0
  115. data/lib/aia/robot_namer.rb +116 -0
  116. data/lib/aia/session.rb +83 -17
  117. data/lib/aia/session_tracker.rb +209 -0
  118. data/lib/aia/similarity_scorer.rb +39 -0
  119. data/lib/aia/skill_utils.rb +105 -1
  120. data/lib/aia/spawn_handler.rb +129 -0
  121. data/lib/aia/spawn_spec_parser.rb +65 -0
  122. data/lib/aia/special_mode_handler.rb +302 -0
  123. data/lib/aia/startup_coordinator.rb +150 -0
  124. data/lib/aia/streaming_runner.rb +169 -0
  125. data/lib/aia/system_prompt_assembler.rb +88 -0
  126. data/lib/aia/task_coordinator.rb +202 -0
  127. data/lib/aia/task_decomposer.rb +57 -0
  128. data/lib/aia/task_executor.rb +51 -0
  129. data/lib/aia/tfidf_math.rb +27 -0
  130. data/lib/aia/tool_filter/tfidf.rb +116 -0
  131. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  132. data/lib/aia/tool_filter.rb +82 -0
  133. data/lib/aia/tool_filter_registry.rb +30 -0
  134. data/lib/aia/tool_filter_strategy.rb +143 -0
  135. data/lib/aia/tool_loader.rb +210 -0
  136. data/lib/aia/tool_utility.rb +30 -0
  137. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  138. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  139. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  140. data/lib/aia/tools/task_board_tool.rb +114 -0
  141. data/lib/aia/trakflow_bridge.rb +173 -0
  142. data/lib/aia/turn_state.rb +94 -0
  143. data/lib/aia/ui_presenter.rb +166 -198
  144. data/lib/aia/utility.rb +134 -87
  145. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  146. data/lib/aia/verification_network.rb +58 -0
  147. data/lib/aia.rb +108 -63
  148. data/mkdocs.yml +1 -0
  149. metadata +179 -56
  150. data/justfile +0 -215
  151. data/lib/aia/adapter/chat_execution.rb +0 -242
  152. data/lib/aia/adapter/error_handler.rb +0 -68
  153. data/lib/aia/adapter/gem_activator.rb +0 -57
  154. data/lib/aia/adapter/mcp_connector.rb +0 -274
  155. data/lib/aia/adapter/modality_handlers.rb +0 -167
  156. data/lib/aia/adapter/model_registry.rb +0 -81
  157. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  158. data/lib/aia/adapter/provider_configurator.rb +0 -59
  159. data/lib/aia/adapter/tool_filter.rb +0 -85
  160. data/lib/aia/adapter/tool_loader.rb +0 -90
  161. data/lib/aia/chat_processor_service.rb +0 -178
  162. data/lib/aia/prompt_pipeline.rb +0 -183
  163. data/lib/aia/ruby_llm_adapter.rb +0 -95
  164. data/lib/extensions/openstruct_merge.rb +0 -48
  165. data/lib/extensions/ruby_llm/.irbrc +0 -56
  166. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  167. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  168. data/lib/refinements/string.rb +0 -16
  169. 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 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,
@@ -258,41 +287,28 @@ module AIA
258
287
  context_files: context_files
259
288
  }
260
289
  end
290
+ # rubocop:enable Metrics/AbcSize
261
291
 
262
292
  private
263
293
 
264
294
  # 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
273
295
  def load_extra_config(path)
274
296
  path = File.expand_path(path)
275
297
 
276
298
  unless File.exist?(path)
277
- warn "ERROR: Config file not found: #{path}"
299
+ $stderr.puts "ERROR: Config file not found: #{path}"
278
300
  exit 1
279
- return # guard for test environments that override exit
301
+ return # rubocop:disable Lint/UnreachableCode -- exit is mocked in tests
280
302
  end
281
303
 
282
- # Reset everything to bundled defaults so the user's personal
283
- # config (~/.config/aia/aia.yml) does not bleed through.
284
304
  reset_to_defaults
285
305
 
286
- raw = YAML.safe_load(
287
- File.read(path),
288
- permitted_classes: [Symbol],
306
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol],
289
307
  symbolize_names: true,
290
- aliases: true
291
- ) || {}
308
+ aliases: true) || {}
292
309
 
293
310
  config_hash = raw.key?(:defaults) ? (raw[:defaults] || {}) : raw
294
311
 
295
- # Store the path for reference (visible in --dump output)
296
312
  paths[:extra_config_file] = path
297
313
 
298
314
  config_hash.each do |key, value|
@@ -304,7 +320,7 @@ module AIA
304
320
  when :mcp_servers
305
321
  self.mcp_servers = Array(value)
306
322
  when :service, :llm, :prompts, :roles, :skills, :output, :audio, :image, :embedding,
307
- :tools, :flags, :registry, :paths, :logger
323
+ :tools, :flags, :registry, :paths, :logger, :rules, :concurrency
308
324
  section = send(key)
309
325
  if section.is_a?(MywayConfig::ConfigSection) && value.is_a?(Hash)
310
326
  merge_into_section(section, value)
@@ -315,9 +331,6 @@ module AIA
315
331
  expand_paths
316
332
  end
317
333
 
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
334
  def reset_to_defaults
322
335
  defaults = self.class.schema
323
336
 
@@ -334,10 +347,6 @@ module AIA
334
347
  end
335
348
  end
336
349
 
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
350
  def reset_section(section, defaults_hash)
342
351
  defaults_hash.each do |key, value|
343
352
  existing = section[key.to_sym]
@@ -349,11 +358,6 @@ module AIA
349
358
  end
350
359
  end
351
360
 
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
361
  def merge_into_section(section, hash)
358
362
  hash.each do |key, value|
359
363
  existing = section[key.to_sym]
@@ -365,76 +369,59 @@ module AIA
365
369
  end
366
370
  end
367
371
 
368
- # Apply AIA_MODEL env var if set (supports comma-separated models with optional roles)
369
- # Format: MODEL[=ROLE][,MODEL[=ROLE]]...
370
372
  def apply_models_env_var
371
- models_env = ENV['AIA_MODEL']
373
+ models_env = ENV.fetch('AIA_MODEL', nil)
372
374
  return if models_env.nil? || models_env.empty?
373
375
 
374
376
  self.models = TO_MODEL_SPECS.call(models_env.split(',').map(&:strip))
375
377
  end
376
378
 
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
379
+ # Backward-compatible top-level env var for plugin directory.
380
+ # Unlike most nested config values, this uses AIA_PLUGINS_DIR per requirements.
381
+ def apply_plugins_dir_env_var
382
+ plugins_dir_env = ENV.fetch('AIA_PLUGINS_DIR', nil)
383
+ return if plugins_dir_env.nil? || plugins_dir_env.empty?
394
384
 
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
385
+ paths.plugins_dir = plugins_dir_env
386
+ end
402
387
 
403
- if skills.dir
404
- skills.dir = File.expand_path(skills.dir)
405
- end
388
+ def expand_paths
389
+ expand_path_on(paths, :aia_dir)
390
+ expand_path_on(paths, :config_file)
391
+ expand_path_on(paths, :plugins_dir)
392
+ expand_path_on(prompts, :dir)
393
+ expand_path_on(prompts, :roles_dir)
394
+ expand_path_on(roles, :dir)
395
+ expand_path_on(skills, :dir)
396
+ expand_path_on(tools, :dir)
397
+ expand_path_on(output, :history_file)
398
+ expand_path_on(rules, :dir)
399
+ end
406
400
 
407
- if tools.dir
408
- tools.dir = File.expand_path(tools.dir)
409
- end
401
+ def expand_path_on(obj, method_name)
402
+ return unless obj.respond_to?(method_name)
403
+ val = obj.send(method_name)
404
+ return unless val
405
+ obj.send(:"#{method_name}=", File.expand_path(val))
410
406
  end
411
407
 
412
408
  def ensure_arrays
413
- # Ensure array fields are actually arrays
414
409
  self.pipeline = [] if pipeline.nil?
415
410
  self.require_libs = [] if require_libs.nil?
416
411
  self.context_files = [] if context_files.nil?
417
412
  self.mcp_servers = [] if mcp_servers.nil?
418
413
  self.mcp_use = [] if mcp_use.nil?
419
414
  self.mcp_skip = [] if mcp_skip.nil?
420
-
421
- # Ensure tools.paths is an array
422
415
  tools.paths = [] if tools.paths.nil?
423
-
424
- # Ensure prompts.skills is an array
425
416
  prompts.skills = [] if prompts.respond_to?(:skills) && prompts.skills.nil?
426
417
  end
427
418
 
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
419
  def process_mcp_files(mcp_files)
432
420
  return if mcp_files.nil? || mcp_files.empty?
433
421
 
434
422
  servers_from_files = McpParser.parse_files(mcp_files)
435
423
  return if servers_from_files.empty?
436
424
 
437
- # Merge with existing mcp_servers (CLI files take precedence)
438
425
  self.mcp_servers = (mcp_servers || []) + servers_from_files
439
426
  end
440
427
 
@@ -442,32 +429,10 @@ module AIA
442
429
  section = parts[0].to_sym
443
430
  key = parts[1].to_sym
444
431
 
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
432
+ target = respond_to?(section) ? send(section) : nil
433
+ return unless target.respond_to?("#{key}=")
434
+
435
+ target.send("#{key}=", value)
471
436
  end
472
437
  end
473
438
  end
@@ -0,0 +1,153 @@
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
+ def extract_network_content(flow_result)
38
+ parts = []
39
+ flow_result.context.each do |task_name, robot_result|
40
+ next if task_name == :run_params
41
+
42
+ content = if robot_result.respond_to?(:reply)
43
+ robot_result.reply
44
+ elsif robot_result.respond_to?(:content)
45
+ robot_result.content
46
+ else
47
+ robot_result.to_s
48
+ end
49
+ next unless content && !content.empty?
50
+
51
+ duration = robot_result.respond_to?(:duration) ? robot_result.duration : nil
52
+ robot_name = robot_result.respond_to?(:robot_name) ? robot_result.robot_name : nil
53
+ label = robot_name && robot_name != task_name.to_s ? "#{robot_name} [#{task_name}]" : task_name.to_s
54
+ header = if duration
55
+ "**#{label}** (#{format_duration(duration)}):"
56
+ else
57
+ "**#{label}:**"
58
+ end
59
+ parts << "#{header}\n#{content}"
60
+ end
61
+ parts.join("\n\n")
62
+ end
63
+
64
+ # Store structured robot results into a network's shared memory.
65
+ # Each robot's output is written as a hash under result_<task_name>.
66
+ #
67
+ # @param flow_result [SimpleFlow::Result]
68
+ # @param network [RobotLab::Network]
69
+ def store_results_in_memory(flow_result, network)
70
+ return unless network.respond_to?(:memory)
71
+
72
+ memory = network.memory
73
+ flow_result.context.each do |task_name, robot_result|
74
+ next if task_name == :run_params
75
+ next unless robot_result.respond_to?(:reply)
76
+
77
+ memory.current_writer = task_name.to_s
78
+ memory.set(:"result_#{task_name}", {
79
+ content: robot_result.reply,
80
+ model: robot_result.respond_to?(:robot_name) ? robot_result.robot_name : nil,
81
+ duration: robot_result.respond_to?(:duration) ? robot_result.duration : nil
82
+ })
83
+ end
84
+ end
85
+
86
+ # Present an AI result: extract content, record the turn, display,
87
+ # write to output file, show metrics, speak, and print separator.
88
+ # Callers pass only the keyword args they need; everything optional
89
+ # defaults to nil / false so this works for both full ChatLoop turns
90
+ # and simpler SpecialModeHandler dispatches.
91
+ #
92
+ # @param result the response object
93
+ # @param streamed_content [String, nil] pre-streamed content (skips display_ai_response)
94
+ # @param prompt [String, nil] user prompt for tracker
95
+ # @param elapsed [Float, nil] elapsed seconds
96
+ # @param ui_presenter [UIPresenter] for display calls
97
+ # @param tracker [SessionTracker, nil] to record the turn
98
+ # @param decisions [Hash, nil] routing decisions for tracker
99
+ # @return [String] the extracted content
100
+ def present_result(result, ui_presenter:, streamed_content: nil, prompt: nil, elapsed: nil, tracker: nil, decisions: nil)
101
+ content = streamed_content || extract_content(result)
102
+
103
+ if tracker && prompt
104
+ tracker.record_turn(
105
+ model: AIA.config.models.first.name,
106
+ input: prompt,
107
+ result: result,
108
+ decisions: decisions,
109
+ elapsed: elapsed
110
+ )
111
+ end
112
+
113
+ if streamed_content
114
+ puts
115
+ else
116
+ ui_presenter.display_ai_response(content)
117
+ end
118
+
119
+ output_to_file(content)
120
+ display_metrics(result, elapsed: elapsed) if respond_to?(:display_metrics, true)
121
+ speak(content) if respond_to?(:speak, true)
122
+ ui_presenter.display_separator
123
+
124
+ content
125
+ end
126
+
127
+ # Write AI response content to the configured output file.
128
+ #
129
+ # @param content [String] the AI response text
130
+ def output_to_file(content)
131
+ out_file = AIA.config.output.file
132
+ return unless out_file
133
+
134
+ File.open(out_file, 'a') { |f| f.puts "\nAI: #{content}" }
135
+ end
136
+
137
+ # Format a duration in seconds to a human-readable string.
138
+ #
139
+ # @param seconds [Float, nil]
140
+ # @return [String]
141
+ def format_duration(seconds)
142
+ return "0.0s" unless seconds
143
+
144
+ if seconds < 60
145
+ "%.1fs" % seconds
146
+ else
147
+ minutes = (seconds / 60).to_i
148
+ secs = seconds % 60
149
+ "#{minutes}m %04.1fs" % secs
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,38 @@
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
+ def self.calculate(model_id:, input_tokens:, output_tokens:)
17
+ return { available: false } unless model_id && defined?(RubyLLM::Models)
18
+
19
+ # RubyLLM::Models.find raises ModelNotFoundError on an unknown id (it never
20
+ # returns nil), so the unknown-model case is handled by the rescue below.
21
+ model_info = RubyLLM::Models.find(model_id)
22
+
23
+ input_price = model_info.respond_to?(:input_price_per_million) ? model_info.input_price_per_million : nil
24
+ output_price = model_info.respond_to?(:output_price_per_million) ? model_info.output_price_per_million : nil
25
+ return { available: false } unless input_price && output_price
26
+
27
+ input_cost = input_tokens * input_price / 1_000_000.0
28
+ output_cost = output_tokens * output_price / 1_000_000.0
29
+
30
+ { available: true, total_cost: input_cost + output_cost, input_cost: input_cost, output_cost: output_cost }
31
+ rescue RubyLLM::ModelNotFoundError
32
+ # Expected when pricing for the model isn't in the registry — not an error.
33
+ { available: false }
34
+ rescue StandardError => e
35
+ { available: false, error: e.message }
36
+ end
37
+ end
38
+ end