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
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/special_mode_handler.rb
4
+ #
5
+ # Handles special execution modes triggered by directives:
6
+ # /verify — two independent answers + reconciliation
7
+ # /decompose — break into parallel sub-tasks (Async::Barrier for I/O concurrency)
8
+ # /concurrent — concurrent MCP server access
9
+ # /orchestrate — 3-tier layered orchestration (orchestrator → leads → specialists)
10
+
11
+ require 'async'
12
+
13
+ module AIA
14
+ class SpecialModeHandler
15
+ include ContentExtractor
16
+
17
+ # Special-mode dispatch table. Each row is triggered by a TurnState flag and
18
+ # runs either a bespoke method (`run:`) or, for the uniform "sub-handler
19
+ # returns content" family, the shared #run_sub_handler (`sub:`). Adding a
20
+ # mode means adding a row here plus (for a bespoke mode) its handle_* method
21
+ # — never editing #handle.
22
+ MODES = [
23
+ { flag: :force_verify, run: :handle_verification },
24
+ { flag: :force_decompose, run: :handle_decomposition },
25
+ { flag: :force_concurrent_mcp, run: :handle_concurrent_mcp },
26
+ { flag: :force_debate, sub: { handler: :debate_handler, label: 'Debate' } },
27
+ { flag: :force_delegate, sub: { handler: :delegate_handler, label: 'Delegation' } },
28
+ { flag: :force_spawn, sub: { handler: :spawn_handler, label: 'Spawn',
29
+ context: lambda { |prompt, ts|
30
+ type = ts.spawn_type
31
+ spec = ts.spawn_spec
32
+ ts.spawn_type = nil
33
+ ts.spawn_spec = nil
34
+ { prompt: prompt, specialist_type: type, spawn_spec: spec }
35
+ } } },
36
+ { flag: :force_orchestrate, run: :handle_orchestration }
37
+ ].freeze
38
+
39
+ # Memoized sub-handler accessors, used to propagate a robot swap.
40
+ HANDLERS = %i[debate_handler delegate_handler spawn_handler layered_orchestrator].freeze
41
+
42
+ def initialize(robot:, ui_presenter:, tracker:)
43
+ @robot = robot
44
+ @ui_presenter = ui_presenter
45
+ @tracker = tracker
46
+ # Handlers are instantiated on first use to avoid loading their
47
+ # dependencies (async, trakflow, etc.) unless the mode is invoked.
48
+ @debate_handler = nil
49
+ @delegate_handler = nil
50
+ @spawn_handler = nil
51
+ @layered_orchestrator = nil
52
+ end
53
+
54
+ # Update the robot reference (e.g., after a model switch).
55
+ def robot=(new_robot)
56
+ @robot = new_robot
57
+ HANDLERS.each do |name|
58
+ inst = instance_variable_get(:"@#{name}")
59
+ inst.robot = new_robot if inst
60
+ end
61
+ end
62
+
63
+ # Check TurnState flags and dispatch to the appropriate handler.
64
+ # Returns true if a special mode was handled.
65
+ #
66
+ # @param prompt [String] the user prompt
67
+ # @return [Boolean]
68
+ def handle(prompt)
69
+ turn_state = AIA.turn_state
70
+ mode = MODES.find { |m| turn_state.public_send(m[:flag]) }
71
+ return false unless mode
72
+
73
+ turn_state.public_send(:"#{mode[:flag]}=", false)
74
+ if mode[:sub]
75
+ run_sub_handler(mode[:sub], prompt, turn_state)
76
+ else
77
+ send(mode[:run], prompt)
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ # Run a "sub-handler returns content" mode: debate / delegate / spawn share
84
+ # this exact shape, differing only by handler, label, and (spawn) an extra
85
+ # context field built by spec[:context].
86
+ def run_sub_handler(spec, prompt, turn_state)
87
+ ctx_args = spec[:context] ? spec[:context].call(prompt, turn_state) : { prompt: prompt }
88
+ content = send(spec[:handler]).handle(HandlerContext.new(**ctx_args))
89
+ return false unless content
90
+
91
+ display_and_save(content)
92
+ true
93
+ rescue StandardError => e
94
+ @ui_presenter.display_info("#{spec[:label]} failed: #{e.message}. Falling back to normal mode.")
95
+ false
96
+ end
97
+
98
+ def handle_verification(prompt)
99
+ require_relative 'verification_network'
100
+ @ui_presenter.display_info("Running verification (2 independent + reconciliation)...")
101
+
102
+ network = VerificationNetwork.build(AIA.config)
103
+ result = @ui_presenter.with_spinner("Verifying") { network.run(message: prompt) }
104
+
105
+ present_result(result, prompt: prompt, ui_presenter: @ui_presenter, tracker: @tracker)
106
+ true
107
+ rescue StandardError => e
108
+ @ui_presenter.display_info("Verification failed: #{e.message}. Falling back to normal mode.")
109
+ false
110
+ end
111
+
112
+ # :reek:TooManyStatements -- benchmark-instrumented pipeline: decompose, concurrent fan-out, synthesis, reporting
113
+ def handle_decomposition(prompt)
114
+ require_relative 'prompt_decomposer'
115
+ total_t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
116
+
117
+ @ui_presenter.display_info("Decomposing prompt into sub-tasks...")
118
+ decomposer = PromptDecomposer.new(@robot)
119
+ subtasks, decompose_dur = Timing.timed { decomposer.decompose(prompt) }
120
+
121
+ if subtasks.empty?
122
+ @ui_presenter.display_info("Prompt cannot be meaningfully decomposed. Running normally.")
123
+ return false
124
+ end
125
+
126
+ announce_subtasks(subtasks)
127
+
128
+ results, timings, raw_subtasks, parallel_wall = run_and_time_subtasks(subtasks)
129
+
130
+ raise DecomposeError, "All sub-tasks failed" if results.all?(&:nil?)
131
+
132
+ @ui_presenter.display_info("Synthesizing results...")
133
+ final, synth_dur = Timing.timed { decomposer.synthesize(prompt, results.compact) }
134
+
135
+ total_wall = Process.clock_gettime(Process::CLOCK_MONOTONIC) - total_t0
136
+
137
+ display_decompose_benchmark(decompose_dur: decompose_dur, timings: timings,
138
+ synth_dur: synth_dur, parallel_wall: parallel_wall,
139
+ total_wall: total_wall)
140
+ display_decompose_token_metrics(raw_subtasks, final, total_wall)
141
+
142
+ present_result(final, prompt: prompt, ui_presenter: @ui_presenter, tracker: @tracker)
143
+ true
144
+ rescue StandardError => e
145
+ @ui_presenter.display_info("Decomposition failed: #{e.message}. Falling back to normal mode.")
146
+ false
147
+ end
148
+
149
+ def announce_subtasks(subtasks)
150
+ @ui_presenter.display_info("Decomposed into #{subtasks.size} sub-tasks:")
151
+ subtasks.each_with_index { |t, i| @ui_presenter.display_info(" #{i + 1}. #{t}") }
152
+ end
153
+
154
+ # Fan out sub-tasks concurrently, capturing per-task and wall-clock timing.
155
+ # Returns [results, timings, raw_subtasks, parallel_wall].
156
+ def run_and_time_subtasks(subtasks)
157
+ timings = Array.new(subtasks.size, 0.0)
158
+ raw_subtasks = Array.new(subtasks.size)
159
+ results, parallel_wall = Timing.timed { run_subtasks_concurrently(subtasks, timings, raw_subtasks) }
160
+ [results, timings, raw_subtasks, parallel_wall]
161
+ end
162
+
163
+ # Run all sub-tasks concurrently, recording per-task timing and raw results
164
+ # into the caller-supplied arrays. Returns extracted contents (nil per failure).
165
+ # :reek:DuplicateMethodCall -- clock_gettime is read at task start and again at each outcome; each read must be "now"
166
+ # :reek:TooManyStatements -- async fan-out with per-task timing capture on both success and failure paths
167
+ def run_subtasks_concurrently(subtasks, timings, raw_subtasks)
168
+ Sync do
169
+ barrier = Async::Barrier.new
170
+ tasks = subtasks.each_with_index.map do |task, i|
171
+ barrier.async do
172
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
+ @ui_presenter.display_info("Processing sub-task #{i + 1}...")
174
+ r = @robot.run(task, mcp: :inherit, tools: :inherit)
175
+ timings[i] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
176
+ raw_subtasks[i] = r
177
+ extract_content(r)
178
+ rescue => e
179
+ timings[i] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
180
+ @ui_presenter.display_info(" Sub-task #{i + 1} failed: #{e.message}")
181
+ nil
182
+ end
183
+ end
184
+ barrier.wait
185
+ tasks.map(&:wait)
186
+ end
187
+ end
188
+
189
+ # Print the decomposition benchmark table.
190
+ def display_decompose_benchmark(decompose_dur:, timings:, synth_dur:, parallel_wall:, total_wall:)
191
+ serial_est = timings.sum
192
+ parallel_speedup = serial_est / parallel_wall
193
+
194
+ lines = ["", "Benchmark:"]
195
+ lines << " Decompose probe : #{format('%.2f', decompose_dur)}s"
196
+ timings.each_with_index { |t, i| lines << " Sub-task #{i + 1} : #{format('%.2f', t)}s" }
197
+ lines << " Synthesis : #{format('%.2f', synth_dur)}s"
198
+ lines << " ─────────────────────────────────────────"
199
+ lines << " Sub-tasks serial est. : #{format('%.2f', serial_est)}s (sum of sub-task times)"
200
+ lines << " Sub-tasks concurrent : #{format('%.2f', parallel_wall)}s (#{format('%.1f', parallel_speedup)}x parallel speedup)"
201
+ lines << " Total flow wall time : #{format('%.2f', total_wall)}s (compare against --tokens Time for a normal run)"
202
+ @ui_presenter.display_info(lines.join("\n"))
203
+ end
204
+
205
+ # :reek:TooManyStatements -- ordered eligibility gates (server count, discovery, grouping) before the network run
206
+ def handle_concurrent_mcp(prompt)
207
+ cfg = AIA.config
208
+ return false unless (cfg.mcp_servers || []).size > 1
209
+
210
+ discovery = MCPDiscovery.new
211
+ relevant = discovery.discover(cfg)
212
+ return false if relevant.size <= 1
213
+
214
+ grouper = MCPGrouper.new
215
+ groups = grouper.group(relevant)
216
+ return false if groups.size < 2
217
+
218
+ @ui_presenter.display_info("Running concurrent MCP across #{groups.size} server groups...")
219
+
220
+ network = RobotFactory.build_concurrent_mcp_network(cfg, groups)
221
+ result = @ui_presenter.with_spinner("Processing (concurrent)") { network.run(message: prompt) }
222
+ extract_content(result)
223
+
224
+ present_result(result, prompt: prompt, ui_presenter: @ui_presenter, tracker: @tracker)
225
+ true
226
+ rescue StandardError => e
227
+ @ui_presenter.display_info("Concurrent MCP failed: #{e.message}. Falling back to normal mode.")
228
+ false
229
+ end
230
+
231
+ def handle_orchestration(prompt)
232
+ @ui_presenter.display_info("Starting 3-tier layered orchestration...")
233
+
234
+ content = layered_orchestrator.handle(HandlerContext.new(robot: @robot, prompt: prompt))
235
+
236
+ if content
237
+ display_and_save(content)
238
+ else
239
+ @ui_presenter.display_separator
240
+ end
241
+
242
+ true # always consume the prompt; never fall through to normal mode
243
+ rescue StandardError => e
244
+ @ui_presenter.display_info("Orchestration failed: #{e.class}: #{e.message}")
245
+ @ui_presenter.display_separator
246
+ true
247
+ end
248
+
249
+ def debate_handler
250
+ @debate_handler ||= begin
251
+ require_relative 'debate_handler'
252
+ DebateHandler.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
253
+ end
254
+ end
255
+
256
+ def delegate_handler
257
+ @delegate_handler ||= begin
258
+ require_relative 'delegate_handler'
259
+ require_relative 'task_decomposer'
260
+ require_relative 'task_executor'
261
+ DelegateHandler.new(
262
+ robot: @robot, ui_presenter: @ui_presenter,
263
+ tracker: @tracker, task_coordinator: AIA.task_coordinator
264
+ )
265
+ end
266
+ end
267
+
268
+ def spawn_handler
269
+ @spawn_handler ||= begin
270
+ require_relative 'spawn_handler'
271
+ SpawnHandler.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
272
+ end
273
+ end
274
+
275
+ def layered_orchestrator
276
+ @layered_orchestrator ||= begin
277
+ require_relative 'layered_orchestrator'
278
+ LayeredOrchestrator.new(robot: @robot, ui_presenter: @ui_presenter, tracker: @tracker)
279
+ end
280
+ end
281
+
282
+ # Display content, write to output file, and print separator.
283
+ # Used by handlers that receive pre-extracted content from sub-handlers.
284
+ def display_and_save(content)
285
+ @ui_presenter.display_ai_response(content)
286
+ output_to_file(content)
287
+ @ui_presenter.display_separator
288
+ end
289
+
290
+ # Aggregate token counts from all sub-task raw results plus the synthesis
291
+ # result and display a combined metrics table. Only runs when --tokens is set.
292
+ # :reek:TooManyStatements -- sums token usage across sub-task results plus synthesis, then prints the metrics block
293
+ def display_decompose_token_metrics(raw_subtasks, synthesis_result, total_elapsed)
294
+ return unless AIA.config.flags.tokens
295
+
296
+ total_input = 0
297
+ total_output = 0
298
+
299
+ raw_subtasks.compact.each do |r|
300
+ raw = r.respond_to?(:raw) ? r.raw : nil
301
+ next unless raw.respond_to?(:input_tokens) && raw.input_tokens
302
+ total_input += raw.input_tokens || 0
303
+ total_output += raw.output_tokens || 0
304
+ end
305
+
306
+ synth_raw = synthesis_result.respond_to?(:raw) ? synthesis_result.raw : nil
307
+ if synth_raw.respond_to?(:input_tokens) && synth_raw.input_tokens
308
+ total_input += synth_raw.input_tokens || 0
309
+ total_output += synth_raw.output_tokens || 0
310
+ end
311
+
312
+ return if total_input.zero? && total_output.zero?
313
+
314
+ @ui_presenter.display_token_metrics({
315
+ model_id: AIA.config.models.first.name,
316
+ input_tokens: total_input,
317
+ output_tokens: total_output,
318
+ elapsed: total_elapsed
319
+ })
320
+ end
321
+ end
322
+ end
data/lib/aia/speech.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/speech.rb
4
+ #
5
+ # Shared text-to-speech behavior for interactive handlers (ChatLoop,
6
+ # MentionRouter). ContentExtractor duck-calls #speak on any includer
7
+ # that responds to it, so both sides route through this one pipeline.
8
+
9
+ module AIA
10
+ module Speech
11
+ private
12
+
13
+ # :reek:TooManyStatements -- two-stage TTS pipeline (convert, then play) with spinner and tempfile cleanup
14
+ def speak(content)
15
+ return unless AIA.speak?
16
+
17
+ audio = AIA.config.audio
18
+ command = audio.speak_command || 'say'
19
+ voice = audio.voice
20
+ text = content.to_s
21
+ env = {}
22
+ env['SPEECH_MODEL'] = audio.speech_model if audio.speech_model
23
+
24
+ if command == 'say'
25
+ # Local TTS: say converts and plays in one step
26
+ run_with_spinner("Speaking...") do
27
+ if voice && !voice.to_s.strip.empty?
28
+ system(env, command, '-v', voice, text)
29
+ else
30
+ system(env, command, text)
31
+ end
32
+ end
33
+ else
34
+ # Custom TTS script: stage 2 = convert text → audio file,
35
+ # stage 3 = play the file. AIA passes the output path as $2.
36
+ require 'tempfile'
37
+ tmpfile = Tempfile.new(['aia-tts-', '.mp3'])
38
+ tmpfile.close
39
+ path = tmpfile.path
40
+ begin
41
+ run_with_spinner("Converting to audio...") do
42
+ system(env, command, text, path)
43
+ end
44
+ if File.size?(path)
45
+ run_with_spinner("Speaking...") do
46
+ system('afplay', path)
47
+ end
48
+ end
49
+ ensure
50
+ tmpfile.unlink
51
+ end
52
+ end
53
+ rescue StandardError => e
54
+ $stderr.puts "Warning: Speech failed: #{e.message}"
55
+ end
56
+
57
+ def run_with_spinner(message)
58
+ spinner = TTY::Spinner.new("[:spinner] #{message}", format: :bouncing_ball, output: $stderr)
59
+ spinner.auto_spin
60
+ begin
61
+ yield
62
+ ensure
63
+ spinner.stop
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/startup_coordinator.rb
4
+ #
5
+ # Handles all session startup tasks: MCP connection, tool loading,
6
+ # filter initialization, task coordination, and bus attachment.
7
+ # Extracted from Session to give Session a single responsibility.
8
+
9
+ require "fileutils"
10
+ require "tty-spinner"
11
+
12
+ module AIA
13
+ class StartupCoordinator
14
+ attr_reader :filters, :mcp_manager
15
+
16
+ def initialize(robot:, ui_presenter:)
17
+ @robot = robot
18
+ @ui = ui_presenter
19
+ @filters = {}
20
+ @mcp_manager = nil
21
+ end
22
+
23
+ # Run all startup coordination tasks.
24
+ #
25
+ # @param config [AIA::Config]
26
+ def run(config)
27
+ connect_mcp_servers(config)
28
+
29
+ tools = all_available_tools(config)
30
+
31
+ spinner = TTY::Spinner.new(" [:spinner] Initializing tool filters...", format: :dots)
32
+ spinner.auto_spin
33
+ @filters = ToolFilterRegistry.build_from_config(config, tools)
34
+ spinner.success
35
+
36
+ initialize_task_coordinator if trakflow_available?
37
+
38
+ attach_bus_if_network
39
+ end
40
+
41
+ private
42
+
43
+ # Connect ALL configured MCP servers eagerly at startup, then absorb any
44
+ # MCP clients registered via --require (e.g., shared_tools/mcp/* files).
45
+ # Uses MCPDiscovery to determine which servers to connect (respects
46
+ # --mcp-use, --mcp-skip, and KBS mcp_activate decisions), then normalizes
47
+ # each server config via MCPConfigNormalizer before handing off to
48
+ # MCPConnectionManager.
49
+ # :reek:TooManyStatements -- MCP wiring sequence: discovery, validation, normalize fallback, connect, absorb, inject
50
+ def connect_mcp_servers(config)
51
+ return if config.flags.no_mcp
52
+
53
+ discovered = MCPDiscovery.new.discover(config)
54
+ validate_mcp_use_names(config, discovered) if Array(config.mcp_use).any?
55
+
56
+ # Prefer the robot's pre-built mcp_config (already normalized) when
57
+ # present, but always apply --mcp-skip so that flag is honoured.
58
+ # Fall back to the MCPDiscovery result (which includes both filters).
59
+ servers = if @robot.respond_to?(:mcp_config) && @robot.mcp_config.is_a?(Array)
60
+ skip_list = Array(config.mcp_skip)
61
+ @robot.mcp_config.reject { |s| skip_list.include?(AIA::Utility.server_name(s)) }
62
+ else
63
+ discovered.map { |s| MCPConfigNormalizer.normalize(s) }
64
+ end
65
+
66
+ @mcp_manager = MCPConnectionManager.new
67
+ @mcp_manager.connect_all(servers) if servers.is_a?(Array) && servers.any?
68
+ @mcp_manager.absorb_ruby_llm_mcp_clients
69
+ return unless @mcp_manager.connected?
70
+
71
+ @mcp_manager.update_config
72
+ @mcp_manager.inject_into(@robot) if @mcp_manager.any_tools?
73
+ end
74
+
75
+ # Initialize TaskCoordinator, auto-creating the TrakFlow project if needed.
76
+ def initialize_task_coordinator
77
+ require_relative 'task_coordinator'
78
+ require_relative 'trakflow_bridge'
79
+ require_relative 'tools/task_board_tool'
80
+ require_relative 'tools/delegate_to_foreman_tool'
81
+
82
+ ensure_trakflow_initialized unless TrakFlow.initialized?
83
+
84
+ AIA.task_coordinator = TaskCoordinator.new
85
+ rescue StandardError => e
86
+ AIA.debug_warn("TaskCoordinator initialization failed (non-fatal): #{e.message}", exc: e)
87
+ end
88
+
89
+ # Return true if the trak_flow gem is available; false if it cannot be loaded.
90
+ def trakflow_available?
91
+ defined?(TrakFlow) || begin
92
+ require 'trak_flow'
93
+ true
94
+ rescue LoadError
95
+ false
96
+ end
97
+ end
98
+
99
+ # Bootstrap a TrakFlow project so the task board is always available.
100
+ # Uses the default database path (~/.config/trak_flow/tf.db).
101
+ def ensure_trakflow_initialized
102
+ trakflow_dir = TrakFlow.trak_flow_dir
103
+ FileUtils.mkdir_p(trakflow_dir)
104
+
105
+ db = TrakFlow::Storage::Database.new
106
+ db.connect
107
+
108
+ TrakFlow.reset_root!
109
+ end
110
+
111
+ # Attach a shared TypedBus to multi-model networks.
112
+ def attach_bus_if_network
113
+ return unless @robot.network?
114
+
115
+ RobotFactory.attach_bus(@robot)
116
+ rescue StandardError => e
117
+ AIA.debug_warn("Bus attachment failed (non-fatal): #{e.message}", exc: e)
118
+ end
119
+
120
+ # Warn when --mcp-use names don't match any configured server.
121
+ # Helps users catch typos before a silent empty-tools session.
122
+ def validate_mcp_use_names(config, discovered_servers)
123
+ requested = Array(config.mcp_use)
124
+ available = Array(config.mcp_servers).map { |s| AIA::Utility.server_name(s) }
125
+ found = discovered_servers.map { |s| AIA::Utility.server_name(s) }
126
+ missing = requested - found
127
+
128
+ return if missing.empty?
129
+
130
+ $stderr.puts "WARNING: --mcp-use specified server(s) not found in config: #{missing.join(', ')}"
131
+ $stderr.puts " Available servers: #{available.join(', ')}" if available.any?
132
+ end
133
+
134
+ # Collect all tools available to the robot: local + MCP.
135
+ def all_available_tools(config)
136
+ local = Array(config.loaded_tools)
137
+ mcp = collect_mcp_tools
138
+ local + mcp
139
+ end
140
+
141
+ # Retrieve MCP tools from the connection manager.
142
+ # MCPConnectionManager builds RobotLab::Tool instances via connect_one and
143
+ # absorb_ruby_llm_mcp_clients — those are what get injected into the robot
144
+ # and what robot_lab filters by name. RubyLLM::MCP.clients is a different
145
+ # registry and does not contain the tools connected here.
146
+ def collect_mcp_tools
147
+ return [] unless @mcp_manager
148
+ @mcp_manager.connected_tools
149
+ end
150
+ end
151
+ end