aia 2.0.0.0.pre.alpha → 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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +3 -223
  4. data/.reek.yml +160 -0
  5. data/.rubocop_strict.yml +15 -0
  6. data/.version +1 -1
  7. data/CHANGELOG.md +10 -5
  8. data/README.md +1 -1
  9. data/Rakefile +0 -113
  10. data/_typos.toml +10 -0
  11. data/aia.gemspec +92 -0
  12. data/config/aia.yml +13 -0
  13. data/lib/aia/chat_loop.rb +8 -52
  14. data/lib/aia/config/cli_parser.rb +66 -57
  15. data/lib/aia/config/mcp_parser.rb +27 -19
  16. data/lib/aia/config/validator.rb +83 -94
  17. data/lib/aia/config.rb +3 -2
  18. data/lib/aia/content_extractor.rb +2 -0
  19. data/lib/aia/cost_calculator.rb +1 -0
  20. data/lib/aia/debate_handler.rb +28 -20
  21. data/lib/aia/delegate_handler.rb +7 -3
  22. data/lib/aia/directive.rb +10 -8
  23. data/lib/aia/directives/configuration_directives.rb +115 -101
  24. data/lib/aia/directives/context_directives.rb +31 -28
  25. data/lib/aia/directives/execution_directives.rb +18 -13
  26. data/lib/aia/directives/model_directives.rb +134 -154
  27. data/lib/aia/directives/trakflow_directives.rb +27 -9
  28. data/lib/aia/directives/utility_directives.rb +71 -71
  29. data/lib/aia/directives/web_and_file_directives.rb +63 -50
  30. data/lib/aia/layered_orchestrator.rb +66 -43
  31. data/lib/aia/logger.rb +21 -21
  32. data/lib/aia/mcp_connection_manager.rb +50 -40
  33. data/lib/aia/mcp_server_config.rb +30 -0
  34. data/lib/aia/mcp_utility.rb +7 -4
  35. data/lib/aia/mention_router.rb +7 -50
  36. data/lib/aia/network_builder.rb +10 -5
  37. data/lib/aia/network_memory_manager.rb +6 -6
  38. data/lib/aia/pipeline_orchestrator.rb +20 -10
  39. data/lib/aia/plugin_monitor.rb +9 -6
  40. data/lib/aia/prompt_decomposer.rb +5 -3
  41. data/lib/aia/prompt_handler.rb +43 -49
  42. data/lib/aia/robot_factory.rb +12 -8
  43. data/lib/aia/robot_namer.rb +2 -8
  44. data/lib/aia/session.rb +7 -3
  45. data/lib/aia/session_tracker.rb +39 -41
  46. data/lib/aia/similarity_scorer.rb +5 -3
  47. data/lib/aia/special_mode_handler.rb +52 -32
  48. data/lib/aia/speech.rb +67 -0
  49. data/lib/aia/startup_coordinator.rb +1 -0
  50. data/lib/aia/streaming_runner.rb +6 -3
  51. data/lib/aia/system_prompt_assembler.rb +11 -7
  52. data/lib/aia/task_coordinator.rb +10 -5
  53. data/lib/aia/timing.rb +15 -0
  54. data/lib/aia/tool_filter.rb +1 -0
  55. data/lib/aia/tool_filter_strategy.rb +28 -25
  56. data/lib/aia/tool_introspection.rb +17 -0
  57. data/lib/aia/tool_loader.rb +12 -6
  58. data/lib/aia/tools/task_board_tool.rb +1 -0
  59. data/lib/aia/trakflow_bridge.rb +5 -3
  60. data/lib/aia/turn_state.rb +1 -0
  61. data/lib/aia/ui_presenter.rb +67 -59
  62. data/lib/aia/utility.rb +5 -3
  63. data/lib/aia/variable_input_collector.rb +1 -0
  64. data/lib/aia/verification_network.rb +1 -2
  65. data/lib/aia.rb +16 -0
  66. metadata +15 -9
  67. data/.quality/flay_baseline.txt +0 -1
  68. data/.quality/flog_baseline.txt +0 -29
  69. data/.quality/reek_baseline.txt +0 -80
@@ -10,6 +10,7 @@ module AIA
10
10
  class MentionRouter
11
11
  include ContentExtractor
12
12
  include HandlerProtocol
13
+ include Speech
13
14
 
14
15
  # Reserved @mention that broadcasts the message to every crew member.
15
16
  BROADCAST_TOKEN = 'crew'
@@ -25,6 +26,7 @@ module AIA
25
26
  #
26
27
  # @param context [HandlerContext] — reads context.robot and context.prompt
27
28
  # @return [Boolean]
29
+ # :reek:TooManyStatements -- mention routing: scan, resolve, report unknowns, dispatch; one decision chain
28
30
  def handle(context) # rubocop:disable Naming/PredicateMethod
29
31
  robot = context.robot
30
32
  prompt = context.prompt
@@ -120,10 +122,11 @@ module AIA
120
122
 
121
123
  # A single addressed robot streams its reply token-by-token.
122
124
  def run_streamed(bot, prompt)
125
+ name = bot.name
123
126
  result, streamed_content, elapsed = @streaming_runner.run(
124
127
  bot, prompt,
125
- header: "\nAI (#{bot.name}):\n ",
126
- spinner_message: "#{bot.name} processing..."
128
+ header: "\nAI (#{name}):\n ",
129
+ spinner_message: "#{name} processing..."
127
130
  )
128
131
  content = streamed_content || extract_content(result)
129
132
 
@@ -137,7 +140,7 @@ module AIA
137
140
  finalize_reply(bot, prompt, result, content, elapsed)
138
141
  content
139
142
  rescue StandardError => e
140
- @ui_presenter.display_info("Error from #{bot.name}: #{e.class}: #{e.message}")
143
+ @ui_presenter.display_info("Error from #{name}: #{e.class}: #{e.message}")
141
144
  nil
142
145
  end
143
146
 
@@ -156,6 +159,7 @@ module AIA
156
159
 
157
160
  # Run one crew member to completion (off the streaming path), in a worker
158
161
  # thread. Returns [bot, result, elapsed, error] — no shared state touched.
162
+ # :reek:DuplicateMethodCall -- each clock_gettime must read the clock at that moment (start, success end, failure end)
159
163
  def run_member(bot, prompt)
160
164
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
165
  result = bot.run(prompt, mcp: :inherit, tools: :inherit)
@@ -209,52 +213,5 @@ module AIA
209
213
  return message.model if message.respond_to?(:model) && message.model
210
214
  nil
211
215
  end
212
-
213
- def speak(content)
214
- return unless AIA.speak?
215
-
216
- audio = AIA.config.audio
217
- command = audio.speak_command || 'say'
218
- env = {}
219
- env['SPEECH_MODEL'] = audio.speech_model if audio.speech_model
220
-
221
- if command == 'say'
222
- run_with_spinner("Speaking...") do
223
- if audio.voice && !audio.voice.to_s.strip.empty?
224
- system(env, command, '-v', audio.voice, content.to_s)
225
- else
226
- system(env, command, content.to_s)
227
- end
228
- end
229
- else
230
- require 'tempfile'
231
- tmpfile = Tempfile.new(['aia-tts-', '.mp3'])
232
- tmpfile.close
233
- begin
234
- run_with_spinner("Converting to audio...") do
235
- system(env, command, content.to_s, tmpfile.path)
236
- end
237
- if File.size?(tmpfile.path)
238
- run_with_spinner("Speaking...") do
239
- system('afplay', tmpfile.path)
240
- end
241
- end
242
- ensure
243
- tmpfile.unlink
244
- end
245
- end
246
- rescue StandardError => e
247
- $stderr.puts "Warning: Speech failed: #{e.message}"
248
- end
249
-
250
- def run_with_spinner(message)
251
- spinner = TTY::Spinner.new("[:spinner] #{message}", format: :bouncing_ball, output: $stderr)
252
- spinner.auto_spin
253
- begin
254
- yield
255
- ensure
256
- spinner.stop
257
- end
258
- end
259
216
  end
260
217
  end
@@ -15,6 +15,7 @@ module AIA
15
15
  # @param config [AIA::Config]
16
16
  # @param namer [RobotNamer]
17
17
  # @return [RobotLab::Network]
18
+ # :reek:TooManyStatements -- gathers shared robot inputs then declares one network task per pipeline step
18
19
  def build_pipeline_network(config, namer)
19
20
  prompts = config.pipeline
20
21
  run_config = RobotFactory.build_run_config(config)
@@ -66,13 +67,15 @@ module AIA
66
67
  # @param config [AIA::Config]
67
68
  # @param namer [RobotNamer]
68
69
  # @return [RobotLab::Network]
70
+ # :reek:TooManyStatements -- gathers shared robot inputs, declares one task per roster member plus the synthesizer
69
71
  def build_consensus_network(config, namer)
70
72
  run_config = RobotFactory.build_run_config(config)
71
- tools = ToolLoader.filtered_tools(config)
72
- mcp = RobotFactory.mcp_server_configs(config)
73
- primary = config.models.first
73
+ tools = ToolLoader.filtered_tools(config)
74
+ mcp = RobotFactory.mcp_server_configs(config)
75
+ models = config.models
76
+ primary = models.first
74
77
  aia_config = config
75
- roster = config.models.map { |spec| { name: namer.name_for(spec.name), spec: spec } }
78
+ roster = models.map { |spec| { name: namer.name_for(spec.name), spec: spec } }
76
79
  build_robot = ->(entry) { build_roster_robot(entry, roster, tools, mcp, run_config, aia_config) }
77
80
 
78
81
  RobotLab.create_network(name: "aia-consensus") do
@@ -89,11 +92,12 @@ module AIA
89
92
  config: run_config
90
93
  )
91
94
  task :consensus, synthesizer,
92
- depends_on: config.models.map { |s| s.internal_id.to_sym }
95
+ depends_on: models.map { |s| s.internal_id.to_sym }
93
96
  end
94
97
  end
95
98
 
96
99
  # Shared helper: builds a single roster robot (used by parallel and consensus networks).
100
+ # :reek:LongParameterList { max_params: 6 } -- pass-through of the shared per-network build inputs
97
101
  def build_roster_robot(entry, roster, tools, mcp, run_config, aia_config)
98
102
  spec = entry[:spec]
99
103
  identity = SystemPromptAssembler.build_identity_prompt(entry[:name], spec, roster)
@@ -116,6 +120,7 @@ module AIA
116
120
  # @param namer [RobotNamer]
117
121
  # @param server_groups [Array<Array<Hash>>]
118
122
  # @return [RobotLab::Network]
123
+ # :reek:TooManyStatements -- gathers shared robot inputs, declares one task per server group plus the synthesizer
119
124
  def build_concurrent_mcp_network(config, namer, server_groups)
120
125
  run_config = RobotFactory.build_run_config(config)
121
126
  tools = ToolLoader.filtered_tools(config)
@@ -16,12 +16,12 @@ module AIA
16
16
  def initialize_memory(network, config)
17
17
  return network unless network.respond_to?(:memory)
18
18
 
19
- memory = network.memory
20
- memory.data.session_id = SecureRandom.hex(8)
21
- memory.data.model_count = config.models.size
22
- memory.data.model_names = config.models.map(&:name)
23
- memory.data.mode = config.flags.consensus ? :consensus : :parallel
24
- memory.data.turn_count = 0
19
+ data = network.memory.data
20
+ data.session_id = SecureRandom.hex(8)
21
+ data.model_count = config.models.size
22
+ data.model_names = config.models.map(&:name)
23
+ data.mode = config.flags.consensus ? :consensus : :parallel
24
+ data.turn_count = 0
25
25
 
26
26
  network
27
27
  end
@@ -25,6 +25,8 @@ module AIA
25
25
  # Process all prompts in the pipeline.
26
26
  #
27
27
  # @param config [AIA::Config]
28
+ # :reek:TooManyStatements -- shift-based loop so front-matter pipeline edits take effect; tracking interleaved
29
+ # :reek:DuplicateMethodCall -- config.pipeline is deliberately re-read each iteration because prompt front matter mutates it mid-loop
28
30
  def process(config)
29
31
  bridge = TrakFlowBridge.new
30
32
  tracking = bridge.available? && config.flags.track_pipeline
@@ -82,11 +84,14 @@ module AIA
82
84
  end
83
85
  end
84
86
 
87
+ # :reek:TooManyStatements -- image request assembly, spinner-wrapped generation, then save/display of the result
85
88
  def generate_image(prompt_text, config)
86
89
  img_cfg = config.image
90
+ quality = img_cfg.quality
91
+ style = img_cfg.style
87
92
  params = {}
88
- params[:quality] = img_cfg.quality if img_cfg.quality && !img_cfg.quality.to_s.strip.empty?
89
- params[:style] = img_cfg.style if img_cfg.style && !img_cfg.style.to_s.strip.empty?
93
+ params[:quality] = quality if quality && !quality.to_s.strip.empty?
94
+ params[:style] = style if style && !style.to_s.strip.empty?
90
95
 
91
96
  image = @ui.with_spinner("Generating image") do
92
97
  RubyLLM.paint(
@@ -124,14 +129,16 @@ module AIA
124
129
  end
125
130
 
126
131
  # Check if concurrent MCP mode should be used
132
+ # :reek:TooManyStatements -- ordered eligibility gates (server count, auto/forced, discovery, grouping threshold) before the build
127
133
  def maybe_use_concurrent_mcp(prompt_text, config)
128
134
  return nil unless (config.mcp_servers || []).size > 1
129
135
 
136
+ turn_state = AIA.turn_state
130
137
  concurrency = config.respond_to?(:concurrency) ? config.concurrency : nil
131
- return nil unless concurrency&.auto || AIA.turn_state.force_concurrent_mcp
138
+ return nil unless concurrency&.auto || turn_state.force_concurrent_mcp
132
139
 
133
- if AIA.turn_state.force_concurrent_mcp
134
- AIA.turn_state.force_concurrent_mcp = false
140
+ if turn_state.force_concurrent_mcp
141
+ turn_state.force_concurrent_mcp = false
135
142
  end
136
143
 
137
144
  discovery = MCPDiscovery.new
@@ -151,13 +158,15 @@ module AIA
151
158
  end
152
159
 
153
160
  # Build prompt text from a prompt_id
161
+ # :reek:TooManyStatements -- layered prompt assembly: parameters, role prefix, skills, stdin, context files, in a fixed order
154
162
  def build_prompt_text(prompt_id, config)
155
163
  parsed = @prompt_handler.fetch_prompt(prompt_id)
156
164
  return nil unless parsed
157
165
 
158
- if parsed.respond_to?(:parameters) && parsed.parameters && !parsed.parameters.empty?
159
- values = @input_collector.collect(parsed.parameters)
160
- values.each { |k, v| parsed.parameters[k] = v }
166
+ parameters = parsed.respond_to?(:parameters) ? parsed.parameters : nil
167
+ if parameters && !parameters.empty?
168
+ values = @input_collector.collect(parameters)
169
+ values.each { |k, v| parameters[k] = v }
161
170
  end
162
171
 
163
172
  prompt_text = parsed.to_s
@@ -180,8 +189,9 @@ module AIA
180
189
  prompt_text = "#{prompt_text}\n\n#{skill_content}" if skill_content
181
190
  end
182
191
 
183
- if config.stdin_content && !config.stdin_content.strip.empty?
184
- prompt_text = "#{prompt_text}\n\n#{config.stdin_content}"
192
+ stdin_content = config.stdin_content
193
+ if stdin_content && !stdin_content.strip.empty?
194
+ prompt_text = "#{prompt_text}\n\n#{stdin_content}"
185
195
  config.stdin_content = nil
186
196
  end
187
197
 
@@ -13,6 +13,7 @@ module AIA
13
13
  # CPU). If `listen` cannot be loaded, it falls back to a background thread that
14
14
  # polls file signatures ([mtime, size]). Either way the same #react logic drives
15
15
  # {PluginLoader}, so behavior is identical — only latency differs.
16
+ # :reek:TooManyInstanceVariables -- holds both backends' state (listener vs polling thread) plus watch config and callbacks
16
17
  class PluginMonitor
17
18
  DEFAULT_INTERVAL = 1.0 # seconds (polling fallback only)
18
19
 
@@ -83,9 +84,11 @@ module AIA
83
84
  #
84
85
  # @return [Array<Array(Symbol, String)>] e.g. [[:added, "/p/foo.rb"], ...]
85
86
  def detect_changes(old_snapshot, new_snapshot)
86
- added = (new_snapshot.keys - old_snapshot.keys).map { |path| [:added, path] }
87
- removed = (old_snapshot.keys - new_snapshot.keys).map { |path| [:removed, path] }
88
- modified = (old_snapshot.keys & new_snapshot.keys)
87
+ old_keys = old_snapshot.keys
88
+ new_keys = new_snapshot.keys
89
+ added = (new_keys - old_keys).map { |path| [:added, path] }
90
+ removed = (old_keys - new_keys).map { |path| [:removed, path] }
91
+ modified = (old_keys & new_keys)
89
92
  .reject { |path| new_snapshot[path] == old_snapshot[path] }
90
93
  .map { |path| [:modified, path] }
91
94
  added + removed + modified
@@ -115,9 +118,9 @@ module AIA
115
118
  # Map a listen callback into per-file reactions, ignoring anything that is
116
119
  # not a top-level file of the plugins directory (listen watches recursively).
117
120
  def handle_listen_events(modified, added, removed)
118
- Array(added).each { |path| react(:added, path) if top_level?(path) }
119
- Array(modified).each { |path| react(:modified, path) if top_level?(path) }
120
- Array(removed).each { |path| react(:removed, path) if top_level?(path) }
121
+ { added: added, modified: modified, removed: removed }.each do |event, paths|
122
+ Array(paths).each { |path| react(event, path) if top_level?(path) }
123
+ end
121
124
  end
122
125
 
123
126
  # listen reports resolved (symlink-free) paths, and on macOS the plugins
@@ -75,7 +75,9 @@ module AIA
75
75
  #
76
76
  # @param prompt [String] the user's prompt
77
77
  # @return [Array<String>] sub-task descriptions (empty if not decomposable)
78
+ # :reek:TooManyStatements -- schema vs JSON-fallback probe setup, run, extraction, and rescue reporting in one flow
78
79
  def decompose(prompt)
80
+ logger = AIA.logger
79
81
  probe = build_probe_robot
80
82
  use_schema = structured_output?
81
83
 
@@ -83,17 +85,17 @@ module AIA
83
85
  probe.with_schema(SUBTASKS_SCHEMA)
84
86
  full_prompt = DECOMPOSITION_PROMPT % { prompt: prompt }
85
87
  else
86
- AIA.logger.warn("PromptDecomposer: configured model does not support structured output — using JSON prompt fallback")
88
+ logger.warn("PromptDecomposer: configured model does not support structured output — using JSON prompt fallback")
87
89
  full_prompt = (DECOMPOSITION_PROMPT + FALLBACK_JSON_INSTRUCTION) % { prompt: prompt }
88
90
  end
89
91
 
90
92
  result = probe.run(full_prompt, mcp: :none, tools: :none)
91
93
  content = extract_content(result)
92
- AIA.logger.debug("PromptDecomposer#decompose content class=#{content.class}")
94
+ logger.debug("PromptDecomposer#decompose content class=#{content.class}")
93
95
  subtasks = extract_subtasks(content)
94
96
  subtasks.is_a?(Array) ? subtasks.select { |t| t.is_a?(String) && !t.empty? } : []
95
97
  rescue StandardError => e
96
- AIA.logger.warn("PromptDecomposer#decompose failed: #{e.class}: #{e.message}")
98
+ logger.warn("PromptDecomposer#decompose failed: #{e.class}: #{e.message}")
97
99
  []
98
100
  end
99
101
 
@@ -4,6 +4,7 @@ require 'pm'
4
4
  require 'erb'
5
5
 
6
6
  module AIA
7
+ # :reek:TooManyMethods -- prompt/role fetching, front-matter merging, and fzf search form one lookup pipeline of small named steps
7
8
  class PromptHandler
8
9
  include AIA::SkillUtils
9
10
 
@@ -65,11 +66,12 @@ module AIA
65
66
  return handle_missing_role("roles/") if role_id.nil?
66
67
  return fetch_role_from_path(role_id) if path_based_id?(role_id)
67
68
 
68
- unless role_id.start_with?(AIA.config.prompts.roles_prefix)
69
- role_id = "#{AIA.config.prompts.roles_prefix}/#{role_id}"
69
+ prompts_cfg = AIA.config.prompts
70
+ unless role_id.start_with?(prompts_cfg.roles_prefix)
71
+ role_id = "#{prompts_cfg.roles_prefix}/#{role_id}"
70
72
  end
71
73
 
72
- role_file_path = File.join(@prompts_dir, "#{role_id}#{AIA.config.prompts.extname}")
74
+ role_file_path = File.join(@prompts_dir, "#{role_id}#{prompts_cfg.extname}")
73
75
 
74
76
  parsed = if File.exist?(role_file_path)
75
77
  # PM.parse(role_id) cannot resolve subdirectory IDs like "roles/jersey_mike"
@@ -171,56 +173,47 @@ module AIA
171
173
  end
172
174
 
173
175
  # Apply root-level shorthand keys to AIA.config
174
- # rubocop:disable Metrics/AbcSize
176
+ # :reek:TooManyStatements -- one guarded assignment per supported front-matter shorthand key
175
177
  def apply_root_shorthands(meta_hash)
176
- # model → AIA.config.models (replace with single-model array)
177
- model_val = meta_hash['model'] || meta_hash[:model]
178
- if model_val
179
- AIA.config.models = [model_val]
180
- end
178
+ cfg = AIA.config
181
179
 
182
- # temperature → AIA.config.llm.temperature
183
- temp_val = meta_hash['temperature'] || meta_hash[:temperature]
184
- if temp_val
185
- AIA.config.llm.temperature = temp_val
186
- end
180
+ # model → cfg.models (replace with single-model array)
181
+ model_val = shorthand(meta_hash, :model)
182
+ cfg.models = [model_val] if model_val
187
183
 
188
- # top_p → AIA.config.llm.top_p
189
- top_p_val = meta_hash['top_p'] || meta_hash[:top_p]
190
- if top_p_val
191
- AIA.config.llm.top_p = top_p_val
192
- end
184
+ temp_val = shorthand(meta_hash, :temperature)
185
+ cfg.llm.temperature = temp_val if temp_val
193
186
 
194
- # next → AIA.config.pipeline (replace)
195
- next_val = meta_hash['next'] || meta_hash[:next]
196
- if next_val
197
- if AIA.config.pipeline.any?
198
- logger.info "Prompt metadata 'next: #{next_val}' overrides remaining pipeline #{AIA.config.pipeline.inspect}"
199
- end
200
- AIA.config.pipeline = [next_val]
201
- end
187
+ top_p_val = shorthand(meta_hash, :top_p)
188
+ cfg.llm.top_p = top_p_val if top_p_val
202
189
 
203
- # pipeline → AIA.config.pipeline (replace)
204
- pipeline_val = meta_hash['pipeline'] || meta_hash[:pipeline]
205
- if pipeline_val
206
- if AIA.config.pipeline.any?
207
- logger.info "Prompt metadata 'pipeline' overrides remaining pipeline #{AIA.config.pipeline.inspect}"
208
- end
209
- AIA.config.pipeline = Array(pipeline_val)
210
- end
190
+ # next / pipeline → cfg.pipeline (replace)
191
+ next_val = shorthand(meta_hash, :next)
192
+ apply_pipeline_shorthand(cfg, [next_val], "next: #{next_val}") if next_val
211
193
 
212
- # shell → AIA.config.flags.shell (and PM's shell via metadata)
213
- shell_val = meta_hash['shell'] || meta_hash[:shell]
214
- unless shell_val.nil?
215
- AIA.config.flags.shell = shell_val
216
- end
194
+ pipeline_val = shorthand(meta_hash, :pipeline)
195
+ apply_pipeline_shorthand(cfg, Array(pipeline_val), "pipeline") if pipeline_val
196
+
197
+ # shell / erb → cfg.flags (and PM's shell/erb via metadata)
198
+ shell_val = shorthand(meta_hash, :shell)
199
+ cfg.flags.shell = shell_val unless shell_val.nil?
200
+
201
+ erb_val = shorthand(meta_hash, :erb)
202
+ cfg.flags.erb = erb_val unless erb_val.nil?
203
+ end
217
204
 
218
- # erb → AIA.config.flags.erb (and PM's erb via metadata)
219
- erb_val = meta_hash['erb'] || meta_hash[:erb]
220
- return if erb_val.nil?
221
- AIA.config.flags.erb = erb_val
205
+ # Fetch a front-matter shorthand value by string or symbol key.
206
+ def shorthand(meta_hash, key)
207
+ meta_hash[key.to_s] || meta_hash[key]
208
+ end
209
+
210
+ # Replace cfg.pipeline, logging when a remaining pipeline is overridden.
211
+ def apply_pipeline_shorthand(cfg, new_pipeline, label)
212
+ if cfg.pipeline.any?
213
+ logger.info "Prompt metadata '#{label}' overrides remaining pipeline #{cfg.pipeline.inspect}"
214
+ end
215
+ cfg.pipeline = new_pipeline
222
216
  end
223
- # rubocop:enable Metrics/AbcSize
224
217
 
225
218
  def logger
226
219
  @logger ||= LoggerManager.aia_logger
@@ -353,8 +346,9 @@ module AIA
353
346
  end
354
347
 
355
348
  def search_role_id_with_fzf(initial_query)
356
- role_files = Dir.glob(File.join(@roles_dir, "*#{AIA.config.prompts.extname}"))
357
- .map { |file| File.basename(file, AIA.config.prompts.extname) }
349
+ prompts_cfg = AIA.config.prompts
350
+ role_files = Dir.glob(File.join(@roles_dir, "*#{prompts_cfg.extname}"))
351
+ .map { |file| File.basename(file, prompts_cfg.extname) }
358
352
  fzf = AIA::Fzf.new(
359
353
  list: role_files,
360
354
  directory: @prompts_dir,
@@ -369,8 +363,8 @@ module AIA
369
363
  raise "No role ID selected"
370
364
  end
371
365
 
372
- unless role.start_with?(AIA.config.prompts.roles_prefix)
373
- role = AIA.config.prompts.roles_prefix + '/' + role
366
+ unless role.start_with?(prompts_cfg.roles_prefix)
367
+ role = prompts_cfg.roles_prefix + '/' + role
374
368
  end
375
369
 
376
370
  role
@@ -14,6 +14,7 @@ require_relative 'network_builder'
14
14
  require_relative 'history_transfer'
15
15
 
16
16
  module AIA
17
+ # :reek:TooManyMethods -- factory facade: every robot/network build variant plus its run-config helpers lives behind one entry point
17
18
  class RobotFactory
18
19
  # The max_tokens default from defaults.yml — optimised for cloud APIs that treat
19
20
  # it as an output-only cap. Local providers (Ollama, LM Studio) bound input+output
@@ -114,9 +115,10 @@ module AIA
114
115
 
115
116
  # Reuse the build-time tool cache when present, else load tools fresh.
116
117
  def apply_tool_cache(config)
117
- if ToolLoader.cached_tools
118
- config.loaded_tools = ToolLoader.cached_tools
119
- config.tool_names = ToolLoader.cached_tools.map { |t| t.respond_to?(:name) ? t.name : t.class.name }.join(', ')
118
+ cached = ToolLoader.cached_tools
119
+ if cached
120
+ config.loaded_tools = cached
121
+ config.tool_names = cached.map { |t| t.respond_to?(:name) ? t.name : t.class.name }.join(', ')
120
122
  else
121
123
  ToolLoader.load_tools(config)
122
124
  end
@@ -141,14 +143,16 @@ module AIA
141
143
  # - max_tokens remains a RobotLab::RunConfig field; AIA normalizes it at
142
144
  # the RubyLLM/OpenAI provider boundary for models that need
143
145
  # max_completion_tokens
146
+ # :reek:TooManyStatements -- one guarded param per LLM tuning knob, with provider-specific max_tokens handling
144
147
  def build_run_config(config)
145
148
  model_spec = config.models.first
149
+ llm = config.llm
146
150
  params = {}
147
151
 
148
- temp = config.llm.temperature
152
+ temp = llm.temperature
149
153
  params[:temperature] = temp if temp && model_supports_temperature?(model_spec)
150
154
 
151
- max = config.llm.max_tokens
155
+ max = llm.max_tokens
152
156
  # Local providers (Ollama, LM Studio) have a small total context window
153
157
  # (input + output combined, typically 32768 tokens for qwen3-class models).
154
158
  # Sending the cloud default of 32767 as num_predict leaves almost no room
@@ -158,13 +162,13 @@ module AIA
158
162
  local = model_spec&.local_provider?
159
163
  params[:max_tokens] = max if max && !(local && max == DEFAULT_LOCAL_MAX_TOKENS)
160
164
 
161
- tp = config.llm.top_p
165
+ tp = llm.top_p
162
166
  params[:top_p] = tp if tp
163
167
 
164
- fp = config.llm.frequency_penalty
168
+ fp = llm.frequency_penalty
165
169
  params[:frequency_penalty] = fp if fp && fp != 0.0
166
170
 
167
- pp = config.llm.presence_penalty
171
+ pp = llm.presence_penalty
168
172
  params[:presence_penalty] = pp if pp && pp != 0.0
169
173
 
170
174
  RobotLab::RunConfig.new(**params)
@@ -103,14 +103,8 @@ module AIA
103
103
  end
104
104
 
105
105
  def unique_name(base)
106
- @used_names[base] ||= 0
107
- @used_names[base] += 1
108
-
109
- if @used_names[base] == 1
110
- base
111
- else
112
- "#{base}#{@used_names[base]}"
113
- end
106
+ count = @used_names[base] = (@used_names[base] || 0) + 1
107
+ count == 1 ? base : "#{base}#{count}"
114
108
  end
115
109
  end
116
110
  end
data/lib/aia/session.rb CHANGED
@@ -23,6 +23,7 @@ require_relative "tool_filter_registry"
23
23
  require_relative "chat_loop"
24
24
 
25
25
  module AIA
26
+ # :reek:TooManyInstanceVariables -- session hub wires presenter, trackers, handlers, and robot; each collaborator is a named field
26
27
  class Session
27
28
  include ContentExtractor
28
29
 
@@ -35,14 +36,17 @@ module AIA
35
36
 
36
37
  # Starts the session, processing all prompts in the pipeline and then
37
38
  # optionally starting an interactive chat session.
39
+ # :reek:TooManyStatements -- session boot script: build robot, startup coordination, optional immediate chat, pipeline run, chat handoff
38
40
  def start
41
+ cfg = AIA.config
42
+
39
43
  # Build robot or network
40
- @robot = RobotFactory.build(AIA.config)
44
+ @robot = RobotFactory.build(cfg)
41
45
  AIA.client = @robot
42
46
 
43
47
  # Run all startup coordination: MCP, tools, filters, task board, bus
44
48
  coordinator = StartupCoordinator.new(robot: @robot, ui_presenter: @ui_presenter)
45
- coordinator.run(AIA.config)
49
+ coordinator.run(cfg)
46
50
  @filters = coordinator.filters
47
51
  @mcp_manager = coordinator.mcp_manager
48
52
 
@@ -63,7 +67,7 @@ module AIA
63
67
  input_collector: @input_collector,
64
68
  ui_presenter: @ui_presenter,
65
69
  session_tracker: @session_tracker
66
- ).process(AIA.config)
70
+ ).process(cfg)
67
71
 
68
72
  # Start chat mode after all prompts are processed
69
73
  return unless AIA.chat?
@@ -97,61 +97,26 @@ module AIA
97
97
 
98
98
  # Expand a network SimpleFlow::Result into one turn entry per robot.
99
99
  # Computes TF-IDF similarity of each response against the first.
100
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
100
+ # :reek:TooManyStatements -- collect entries and texts in one pass, then score and accumulate; splitting hides the pairing
101
101
  def record_network_turn(input:, flow_result:, decisions: nil, elapsed: nil)
102
102
  @turn_count += 1
103
103
  now = Time.now
104
104
 
105
105
  # Collect robot data in order for similarity scoring
106
- robot_entries = []
106
+ robot_entries = []
107
107
  response_texts = []
108
108
 
109
- # rubocop:disable Metrics/BlockLength
110
109
  flow_result.context.each do |task_name, robot_result|
111
110
  next if task_name == :run_params
112
111
  next unless robot_result.respond_to?(:raw)
113
112
 
114
- raw = robot_result.raw
115
- input_tokens = (raw.respond_to?(:input_tokens) && raw.input_tokens) || 0
116
- output_tokens = (raw.respond_to?(:output_tokens) && raw.output_tokens) || 0
117
- tokens = input_tokens + output_tokens
118
-
119
- model_id = extract_model_id_from_raw(raw)
120
- model_id ||= robot_result.respond_to?(:robot_name) ? robot_result.robot_name : task_name.to_s
121
-
122
- cost = tokens.positive? ? compute_cost_for_model(model_id, input_tokens, output_tokens) : 0.0
123
- robot_elapsed = robot_result.respond_to?(:duration) ? (robot_result.duration || 0) : 0
124
-
125
- text = if robot_result.respond_to?(:reply)
126
- robot_result.reply.to_s
127
- elsif robot_result.respond_to?(:content)
128
- robot_result.content.to_s
129
- else
130
- ""
131
- end
132
- # rubocop:enable Metrics/BlockLength
133
- response_texts << text
134
-
135
- robot_entries << {
136
- model: model_id,
137
- input_length: input.to_s.length,
138
- input_tokens: input_tokens,
139
- output_tokens: output_tokens,
140
- tokens: tokens,
141
- cost: cost,
142
- elapsed: robot_elapsed,
143
- decisions: decisions&.to_h,
144
- timestamp: now
145
- }
113
+ response_texts << extract_response_text(robot_result)
114
+ robot_entries << network_robot_entry(task_name, robot_result,
115
+ input: input, decisions: decisions, now: now)
146
116
  end
147
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
148
117
 
149
118
  # Compute similarity scores (first model is reference)
150
- scores = if robot_entries.size > 1
151
- SimilarityScorer.score(response_texts)
152
- else
153
- Array.new(robot_entries.size)
154
- end
119
+ scores = robot_entries.size > 1 ? SimilarityScorer.score(response_texts) : Array.new(robot_entries.size)
155
120
 
156
121
  robot_entries.each_with_index do |entry, i|
157
122
  entry[:similarity] = scores[i]
@@ -161,6 +126,39 @@ module AIA
161
126
  end
162
127
  end
163
128
 
129
+ # One turn entry for a single robot's result within a network flow.
130
+ def network_robot_entry(task_name, robot_result, input:, decisions:, now:)
131
+ raw = robot_result.raw
132
+ input_tokens = (raw.respond_to?(:input_tokens) && raw.input_tokens) || 0
133
+ output_tokens = (raw.respond_to?(:output_tokens) && raw.output_tokens) || 0
134
+ tokens = input_tokens + output_tokens
135
+
136
+ model_id = extract_model_id_from_raw(raw)
137
+ model_id ||= robot_result.respond_to?(:robot_name) ? robot_result.robot_name : task_name.to_s
138
+
139
+ {
140
+ model: model_id,
141
+ input_length: input.to_s.length,
142
+ input_tokens: input_tokens,
143
+ output_tokens: output_tokens,
144
+ tokens: tokens,
145
+ cost: tokens.positive? ? compute_cost_for_model(model_id, input_tokens, output_tokens) : 0.0,
146
+ elapsed: robot_result.respond_to?(:duration) ? (robot_result.duration || 0) : 0,
147
+ decisions: decisions&.to_h,
148
+ timestamp: now
149
+ }
150
+ end
151
+
152
+ def extract_response_text(robot_result)
153
+ if robot_result.respond_to?(:reply)
154
+ robot_result.reply.to_s
155
+ elsif robot_result.respond_to?(:content)
156
+ robot_result.content.to_s
157
+ else
158
+ ""
159
+ end
160
+ end
161
+
164
162
  def extract_model_id_from_raw(raw)
165
163
  return nil unless raw
166
164
  return raw.model_id if raw.respond_to?(:model_id) && raw.model_id