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/chat_loop.rb CHANGED
@@ -1,27 +1,53 @@
1
- # lib/aia/chat_loop.rb
2
1
  # frozen_string_literal: true
3
2
 
3
+ # lib/aia/chat_loop.rb
4
+ #
5
+ # Thin interactive chat shell for AIA v2.
6
+ # Delegates to StreamingRunner, MentionRouter, and SpecialModeHandler
7
+ # for execution concerns. Owns the REPL loop, directive processing,
8
+ # expert routing, and output coordination.
9
+
4
10
  require "reline"
5
11
  require "pm"
6
12
 
7
13
  module AIA
8
14
  class ChatLoop
9
- include AIA::SkillUtils
15
+ include ContentExtractor
10
16
 
11
- def initialize(chat_processor, ui_presenter, directive_processor)
12
- @chat_processor = chat_processor
13
- @ui_presenter = ui_presenter
17
+ def initialize(robot, ui_presenter, directive_processor,
18
+ session_tracker: nil, alias_registry: nil, filters: {})
19
+ @robot = robot
20
+ @ui_presenter = ui_presenter
14
21
  @directive_processor = directive_processor
22
+ @tracker = session_tracker || SessionTracker.new
23
+ @alias_registry = alias_registry || ModelAliasRegistry.new
24
+ @model_switch_handler = ModelSwitchHandler.new(@alias_registry, @ui_presenter)
25
+ @filters = filters
26
+
27
+ @streaming_runner = StreamingRunner.new
28
+ @mention_router = MentionRouter.new(
29
+ ui_presenter: @ui_presenter,
30
+ tracker: @tracker,
31
+ streaming_runner: @streaming_runner
32
+ )
33
+ @special_mode_handler = SpecialModeHandler.new(
34
+ robot: @robot,
35
+ ui_presenter: @ui_presenter,
36
+ tracker: @tracker
37
+ )
38
+ @tool_filter_strategy = ToolFilterStrategy.new(
39
+ filters: filters,
40
+ ui_presenter: @ui_presenter
41
+ )
15
42
  end
16
43
 
17
44
  # Start the interactive chat session
18
45
  def start(skip_context_files: false)
19
46
  setup_session
20
- process_role_context
21
- process_skill_context
22
47
  process_initial_context(skip_context_files)
23
- handle_piped_input
24
48
  run_loop
49
+ rescue StandardError => e
50
+ AIA.debug_warn("ChatLoop error: #{e.class}: #{e.message}", exc: e)
25
51
  ensure
26
52
  @ui_presenter.display_chat_end
27
53
  end
@@ -29,223 +55,337 @@ module AIA
29
55
  private
30
56
 
31
57
  def setup_session
32
- initialize_ui
33
- setup_signals
34
- Reline::HISTORY.clear
35
- end
36
-
37
- def initialize_ui
38
58
  puts "\nEntering interactive chat mode..."
39
59
  @ui_presenter.display_chat_header
40
- end
41
-
42
- def setup_signals
43
60
  Signal.trap("INT") { exit }
61
+ @ui_presenter.load_chat_history
44
62
  end
45
63
 
46
- def process_role_context
47
- role = AIA.config.prompts.role
48
- return if role.nil? || role.empty?
64
+ def process_initial_context(skip_context_files)
65
+ return if skip_context_files || !AIA.config.context_files || AIA.config.context_files.empty?
49
66
 
50
- prompt_handler = AIA::PromptHandler.new
67
+ context = AIA.config.context_files.map do |file|
68
+ File.read(file) rescue "Error reading file: #{file}"
69
+ end.join("\n\n")
51
70
 
52
- # fetch_role applies the role file's metadata (including any model: key) to
53
- # AIA.config. Preserve the user's chosen model so the chat session keeps
54
- # using the model from the CLI, not one embedded in the role file.
55
- saved_models = AIA.config.models
56
- role_parsed = prompt_handler.fetch_role(role)
57
- AIA.config.models = saved_models
71
+ return if context.empty?
72
+
73
+ result, streamed_content, _elapsed = @streaming_runner.run(@robot, context)
74
+ present_result(result,
75
+ streamed_content: streamed_content,
76
+ ui_presenter: @ui_presenter)
77
+ end
58
78
 
59
- return if role_parsed.nil?
79
+ def run_loop
80
+ loop do
81
+ follow_up_prompt = @ui_presenter.ask_question
82
+ break if should_exit_chat?(follow_up_prompt)
60
83
 
61
- role_content = role_parsed.to_s
62
- return if role_content.nil? || role_content.strip.empty?
84
+ log_user_input(follow_up_prompt)
63
85
 
64
- return unless AIA.client.respond_to?(:chats)
86
+ processed_prompt = resolve_input(follow_up_prompt)
87
+ next if processed_prompt.nil?
65
88
 
66
- system_msg = RubyLLM::Message.new(role: :system, content: role_content)
89
+ next if model_switch_handled?
90
+ next if special_routing_handled?(processed_prompt, @robot)
67
91
 
68
- AIA.client.chats.each_value do |chat|
69
- next if chat.messages.any? { |m| m.role == :system }
70
- chat.add_message(system_msg)
92
+ execute_and_present(processed_prompt, @robot)
93
+ clear_turn_mcp_filter
71
94
  end
72
95
  end
73
96
 
74
- def process_skill_context
75
- skills = AIA.config.prompts.skills
76
- return if skills.nil? || skills.empty?
77
-
78
- skills_dir = AIA.config.skills.dir
79
- bodies = Array(skills).filter_map do |skill_name|
80
- skill_name = skill_name.to_s.strip
81
- next if skill_name.empty?
82
-
83
- skill_path = find_skill_dir(skill_name, skills_dir)
84
- next unless skill_path
97
+ def should_exit_chat?(input)
98
+ input.nil? || input.strip.downcase == "exit" || input.strip.empty?
99
+ end
85
100
 
86
- if File.file?(skill_path)
87
- skill_body(File.read(skill_path))
88
- else
89
- md = File.join(skill_path, 'SKILL.md')
90
- skill_body(File.read(md)) if File.exist?(md)
91
- end
101
+ def resolve_input(follow_up_prompt)
102
+ if follow_up_prompt.strip.start_with?('/')
103
+ resolve_directive(follow_up_prompt)
104
+ else
105
+ parse_prompt_string(follow_up_prompt)
92
106
  end
107
+ end
93
108
 
94
- return if bodies.empty?
109
+ def resolve_directive(follow_up_prompt)
110
+ if @directive_processor.directive?(follow_up_prompt)
111
+ result = process_directive(follow_up_prompt)
112
+ # A directive (e.g. /model, /config) may have rebuilt AIA.client;
113
+ # re-bind so the next prompt uses the new model.
114
+ rebind_robot_if_rebuilt
115
+ result
116
+ else
117
+ name = follow_up_prompt.strip.split.first
118
+ @ui_presenter.display_info("Unknown directive: #{name} (use /help to see available directives)")
119
+ nil
120
+ end
121
+ end
95
122
 
96
- skill_content = bodies.join("\n\n")
97
- response_data = @chat_processor.process_prompt(skill_content)
98
- content = response_data.is_a?(Hash) ? response_data[:content] : response_data
99
- @chat_processor.output_response(content)
100
- @ui_presenter.display_separator
123
+ def parse_prompt_string(raw)
124
+ PM.parse_string(raw).to_s
125
+ rescue StandardError => e
126
+ @ui_presenter.display_info("Error: #{e.class}: #{e.message}")
127
+ nil
101
128
  end
102
129
 
103
- def process_initial_context(skip_context_files)
104
- return if skip_context_files || !AIA.config.context_files || AIA.config.context_files.empty?
130
+ def model_switch_handled?
131
+ return false unless @model_switch_handler.handle(HandlerContext.new(config: AIA.config))
105
132
 
106
- context = AIA.config.context_files.map do |file|
107
- File.read(file) rescue "Error reading file: #{file}"
108
- end.join("\n\n")
133
+ update_robot
134
+ true
135
+ end
109
136
 
110
- return if context.empty?
137
+ def special_routing_handled?(processed_prompt, active_robot)
138
+ if @special_mode_handler.handle(processed_prompt)
139
+ clear_turn_mcp_filter
140
+ return true
141
+ end
111
142
 
112
- response_data = @chat_processor.process_prompt(context)
113
- content = response_data.is_a?(Hash) ? response_data[:content] : response_data
143
+ if @mention_router.handle(HandlerContext.new(robot: active_robot, prompt: processed_prompt))
144
+ clear_turn_mcp_filter
145
+ return true
146
+ end
114
147
 
115
- @chat_processor.output_response(content)
116
- @chat_processor.speak(content)
117
- @ui_presenter.display_separator
148
+ false
118
149
  end
119
150
 
120
- def handle_piped_input
121
- return if STDIN.tty?
122
- return unless File.exist?("/dev/tty") && File.readable?("/dev/tty") && File.writable?("/dev/tty")
151
+ def execute_and_present(processed_prompt, active_robot)
152
+ log_robot_tools(active_robot)
123
153
 
124
- begin
125
- original_stdin = STDIN.dup
126
- piped_input = STDIN.read.strip
127
- STDIN.reopen("/dev/tty")
128
-
129
- return if piped_input.empty?
154
+ resolved_tools = @tool_filter_strategy.resolve(processed_prompt)
155
+ if (AIA.debug? || AIA.verbose?) && resolved_tools
156
+ puts "\nFiltered tools (#{resolved_tools.size}): #{resolved_tools.join(', ')}"
157
+ end
130
158
 
131
- processed_input = PM.parse_string(piped_input).to_s
159
+ # Plain turn runs the chief directly; aggregation modes run the whole network.
160
+ turn_target = aggregation_mode? ? active_robot : active_robot.chief
161
+ result, streamed_content, elapsed = run_streaming_turn(turn_target, processed_prompt, resolved_tools)
162
+ return unless result
163
+
164
+ increment_turn_counter
165
+ present_result(result,
166
+ streamed_content: streamed_content,
167
+ prompt: processed_prompt,
168
+ elapsed: elapsed,
169
+ ui_presenter: @ui_presenter,
170
+ tracker: @tracker)
171
+ end
132
172
 
133
- response_data = @chat_processor.process_prompt(processed_input)
134
- content = response_data.is_a?(Hash) ? response_data[:content] : response_data
173
+ def run_streaming_turn(turn_target, processed_prompt, resolved_tools)
174
+ @streaming_runner.run(turn_target, processed_prompt, tools: resolved_tools)
175
+ rescue StandardError => e
176
+ @ui_presenter.display_info("Error communicating with AI: #{e.class}: #{e.message}")
177
+ clear_turn_mcp_filter
178
+ nil
179
+ end
135
180
 
136
- @chat_processor.output_response(content)
137
- @chat_processor.speak(content) if AIA.speak?
138
- @ui_presenter.display_separator
181
+ def increment_turn_counter
182
+ return unless @robot.respond_to?(:memory) && @robot.memory.respond_to?(:data)
139
183
 
140
- STDIN.reopen(original_stdin)
141
- rescue Errno::ENXIO => e
142
- warn "Warning: Unable to handle piped input due to TTY unavailability: #{e.message}"
143
- return
144
- rescue StandardError => e
145
- warn "Warning: Error handling piped input: #{e.message}"
146
- return
147
- end
184
+ data = @robot.memory.data
185
+ count = data.respond_to?(:turn_count) ? (data.turn_count || 0) : 0
186
+ data.turn_count = count + 1
148
187
  end
149
188
 
150
- def run_loop
151
- loop do
152
- follow_up_prompt = @ui_presenter.ask_question
189
+ # Clear per-turn MCP server filter so next turn sees all
190
+ def clear_turn_mcp_filter
191
+ AIA.turn_state.active_mcp_servers = nil
192
+ end
153
193
 
154
- break if follow_up_prompt.nil? || follow_up_prompt.strip.downcase == "exit" || follow_up_prompt.strip.empty?
194
+ # Update robot reference after a model switch and propagate to sub-components
195
+ def update_robot
196
+ @robot = AIA.client
197
+ @special_mode_handler.robot = @robot
198
+ end
155
199
 
156
- if AIA.config.output.file
157
- File.open(AIA.config.output.file, "a") do |file|
158
- file.puts "\nYou: #{follow_up_prompt}"
159
- end
160
- end
200
+ # Re-bind @robot when a directive rebuilt AIA.client into a different robot
201
+ # (e.g. /model). No-op when the client is unchanged or unset.
202
+ def rebind_robot_if_rebuilt
203
+ update_robot if AIA.client && !AIA.client.equal?(@robot)
204
+ end
161
205
 
162
- if @directive_processor.directive?(follow_up_prompt)
163
- follow_up_prompt = process_directive(follow_up_prompt)
164
- next if follow_up_prompt.nil?
165
- end
206
+ # True when the crew should run as an aggregation network (consensus /
207
+ # parallel / pipeline) rather than routing a plain turn to the chief.
208
+ def aggregation_mode?
209
+ cfg = AIA.config
210
+ cfg.flags.consensus || Array(cfg.pipeline).length > 1 || Array(cfg.models).length > 1
211
+ end
166
212
 
167
- begin
168
- processed_prompt = PM.parse_string(follow_up_prompt).to_s
169
- rescue StandardError => e
170
- @ui_presenter.display_info("Error: #{e.class}: #{e.message}")
171
- next
172
- end
213
+ def process_directive(follow_up_prompt)
214
+ directive_output = @directive_processor.process(follow_up_prompt, nil)
173
215
 
174
- response_data = @chat_processor.process_prompt(processed_prompt)
216
+ # These directives either mutate state or set a mode flag for the NEXT
217
+ # prompt. Display their confirmation and return nil so the loop skips
218
+ # forwarding them to the robot (which would fire the handler prematurely
219
+ # on wrapper text and consume the flag before the real prompt arrives).
220
+ if @directive_processor.state_setting?(follow_up_prompt)
221
+ @ui_presenter.display_info(directive_output) unless directive_output.nil? || directive_output.strip.empty?
222
+ return nil
223
+ end
175
224
 
176
- if response_data.is_a?(Hash)
177
- content = response_data[:content]
178
- metrics = response_data[:metrics]
179
- multi_metrics = response_data[:multi_metrics]
180
- else
181
- content = response_data
182
- metrics = nil
183
- multi_metrics = nil
184
- end
225
+ return nil if directive_output.nil? || directive_output.strip.empty?
185
226
 
186
- @ui_presenter.display_ai_response(content)
227
+ puts "\n#{directive_output}\n"
228
+ "I executed this directive: #{follow_up_prompt}\nHere's the output: #{directive_output}\nLet's continue our conversation."
229
+ end
187
230
 
188
- if AIA.config.flags.tokens
189
- if multi_metrics
190
- @ui_presenter.display_multi_model_metrics(multi_metrics)
191
- elsif metrics
192
- @ui_presenter.display_token_metrics(metrics)
193
- end
194
- end
231
+ # Display token metrics if enabled.
232
+ # Handles both single-robot results and multi-model network results.
233
+ #
234
+ # Token data lives on the raw RubyLLM::Message stored in
235
+ # RobotResult#raw (RobotLab::Message objects do not carry usage info).
236
+ def display_metrics(result, elapsed: nil)
237
+ return unless AIA.config.flags.tokens
195
238
 
196
- @chat_processor.speak(content)
197
- @ui_presenter.display_separator
239
+ if defined?(SimpleFlow::Result) && result.is_a?(SimpleFlow::Result)
240
+ display_network_metrics(result)
241
+ return
198
242
  end
199
- end
200
243
 
201
- def process_directive(follow_up_prompt)
202
- directive_output = @directive_processor.process(follow_up_prompt, nil)
244
+ raw = result.respond_to?(:raw) ? result.raw : nil
245
+ return unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
246
+
247
+ model_id = extract_model_id(raw) || AIA.config.models.first.name
248
+ metrics = {
249
+ model_id: model_id,
250
+ input_tokens: raw.input_tokens,
251
+ output_tokens: raw.output_tokens,
252
+ elapsed: elapsed
253
+ }
254
+ @ui_presenter.display_token_metrics(metrics)
255
+ end
203
256
 
204
- if follow_up_prompt.strip.start_with?("/clear", "/checkpoint", "/restore", "/review", "/context")
205
- @ui_presenter.display_info(directive_output) unless directive_output.nil? || directive_output.strip.empty?
206
- return nil
257
+ # Extract per-robot metrics from a network SimpleFlow::Result
258
+ # and display the multi-model cost table.
259
+ #
260
+ # Each robot_result.raw holds the original RubyLLM::Message with
261
+ # input_tokens, output_tokens, and model_id.
262
+ # Each robot_result.duration holds the elapsed seconds.
263
+ # Similarity scores compare each model's response text against the
264
+ # first model using TF-IDF cosine similarity.
265
+ def display_network_metrics(flow_result)
266
+ metrics_list = []
267
+ response_texts = []
268
+
269
+ flow_result.context.each do |task_name, robot_result|
270
+ next if task_name == :run_params
271
+ next unless robot_result.respond_to?(:raw)
272
+
273
+ raw = robot_result.raw
274
+ next unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
275
+
276
+ model_id = extract_model_id(raw)
277
+ display_name = robot_result.respond_to?(:robot_name) ? robot_result.robot_name : task_name.to_s
278
+ elapsed = robot_result.respond_to?(:duration) ? robot_result.duration : nil
279
+
280
+ # Collect response text for similarity scoring
281
+ text = if robot_result.respond_to?(:reply)
282
+ robot_result.reply.to_s
283
+ elsif robot_result.respond_to?(:content)
284
+ robot_result.content.to_s
285
+ else
286
+ ""
287
+ end
288
+ response_texts << text
289
+
290
+ metrics_list << {
291
+ model_id: model_id || display_name,
292
+ display_name: display_name,
293
+ input_tokens: raw.input_tokens || 0,
294
+ output_tokens: raw.output_tokens || 0,
295
+ elapsed: elapsed
296
+ }
207
297
  end
208
298
 
209
- return nil if directive_output.nil? || directive_output.strip.empty?
299
+ return if metrics_list.empty?
210
300
 
211
- handle_successful_directive(follow_up_prompt, directive_output)
301
+ # Compute TF-IDF similarity against the first model's response
302
+ if metrics_list.size > 1
303
+ scores = SimilarityScorer.score(response_texts)
304
+ metrics_list.each_with_index { |m, i| m[:similarity] = scores[i] }
305
+ end
306
+
307
+ @ui_presenter.display_multi_model_metrics(metrics_list)
212
308
  end
213
309
 
214
- def handle_successful_directive(follow_up_prompt, directive_output)
215
- puts "\n#{directive_output}\n"
216
- "I executed this directive: #{follow_up_prompt}\nHere's the output: #{directive_output}\nLet's continue our conversation."
310
+ # Pull the actual model identifier from a RubyLLM response message
311
+ # so cost calculation can look up pricing.
312
+ def extract_model_id(message)
313
+ return message.model_id if message.respond_to?(:model_id) && message.model_id
314
+ return message.model if message.respond_to?(:model) && message.model
315
+ nil
217
316
  end
218
317
 
219
- # Parse multi-model response into per-model responses (ADR-002 revised + ADR-005)
220
- def parse_multi_model_response(combined_response)
221
- return {} if combined_response.nil? || combined_response.empty?
318
+ # Show the actual tools available to the robot.
319
+ # Only prints when --debug is enabled.
320
+ def log_robot_tools(robot)
321
+ return unless AIA.debug?
222
322
 
223
- responses = {}
224
- current_model = nil
225
- current_content = []
323
+ # active_robot is always a Network; resolve to the chief for tool inspection.
324
+ target = robot.respond_to?(:chief) ? robot.chief : robot
226
325
 
227
- combined_response.each_line do |line|
228
- if line =~ /^from:\s+(.+)$/
229
- if current_model
230
- responses[current_model] = current_content.join.strip
231
- end
326
+ local = Array(target.local_tools).map { |t| t.respond_to?(:name) ? t.name : t.class.name }
327
+ mcp = Array(target.mcp_tools).map { |t| t.respond_to?(:name) ? t.name : t.class.name }
328
+
329
+ $stderr.puts "[DEBUG] Tool filter strategy: #{@tool_filter_strategy.active_strategy_label}"
330
+ $stderr.puts "[DEBUG] Robot local_tools (#{local.size}): #{local.join(', ')}"
331
+ $stderr.puts "[DEBUG] Robot mcp_tools (#{mcp.size}): #{mcp.join(', ')}"
332
+ end
333
+
334
+ def log_user_input(input)
335
+ out_file = AIA.config.output.file
336
+ return unless out_file
232
337
 
233
- display_name = $1.strip
234
- internal_id = display_name.sub(/\s+\([^)]+\)\s*$/, '')
235
- internal_id = internal_id.sub(/\s+#/, '#')
338
+ File.open(out_file, "a") { |f| f.puts "\nYou: #{input}" }
339
+ end
236
340
 
237
- current_model = internal_id
238
- current_content = []
239
- elsif current_model
240
- current_content << line
341
+ def speak(content)
342
+ return unless AIA.speak?
343
+
344
+ audio = AIA.config.audio
345
+ command = audio.speak_command || 'say'
346
+ env = {}
347
+ env['SPEECH_MODEL'] = audio.speech_model if audio.speech_model
348
+
349
+ if command == 'say'
350
+ # Local TTS: say converts and plays in one step
351
+ run_with_spinner("Speaking...") do
352
+ if audio.voice && !audio.voice.to_s.strip.empty?
353
+ system(env, command, '-v', audio.voice, content.to_s)
354
+ else
355
+ system(env, command, content.to_s)
356
+ end
357
+ end
358
+ else
359
+ # Custom TTS script: stage 2 = convert text → audio file,
360
+ # stage 3 = play the file. AIA passes the output path as $2.
361
+ require 'tempfile'
362
+ tmpfile = Tempfile.new(['aia-tts-', '.mp3'])
363
+ tmpfile.close
364
+ begin
365
+ run_with_spinner("Converting to audio...") do
366
+ system(env, command, content.to_s, tmpfile.path)
367
+ end
368
+ if File.size?(tmpfile.path)
369
+ run_with_spinner("Speaking...") do
370
+ system('afplay', tmpfile.path)
371
+ end
372
+ end
373
+ ensure
374
+ tmpfile.unlink
241
375
  end
242
376
  end
377
+ rescue StandardError => e
378
+ $stderr.puts "Warning: Speech failed: #{e.message}"
379
+ end
243
380
 
244
- if current_model
245
- responses[current_model] = current_content.join.strip
381
+ def run_with_spinner(message)
382
+ spinner = TTY::Spinner.new("[:spinner] #{message}", format: :bouncing_ball, output: $stderr)
383
+ spinner.auto_spin
384
+ begin
385
+ yield
386
+ ensure
387
+ spinner.stop
246
388
  end
247
-
248
- responses
249
389
  end
250
390
  end
251
391
  end