aia 1.1.0 → 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 (170) 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 +266 -42
  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 +19 -0
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/project_summary +2 -2
  57. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  58. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  59. data/examples/rules/01_classify_ruby.rb +16 -0
  60. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  61. data/examples/rules/03_gate_prompt_length.rb +19 -0
  62. data/examples/rules/04_tool_selection.rb +41 -0
  63. data/examples/rules/README.md +30 -0
  64. data/examples/run_all.sh +48 -15
  65. data/examples/tools/word_count_tool.rb +1 -1
  66. data/lib/AGENTS.md +57 -0
  67. data/lib/aia/chat_loop.rb +306 -159
  68. data/lib/aia/config/cli_parser.rb +174 -111
  69. data/lib/aia/config/defaults.yml +62 -33
  70. data/lib/aia/config/mcp_parser.rb +39 -46
  71. data/lib/aia/config/model_spec.rb +34 -2
  72. data/lib/aia/config/validator.rb +121 -138
  73. data/lib/aia/config.rb +110 -145
  74. data/lib/aia/content_extractor.rb +153 -0
  75. data/lib/aia/cost_calculator.rb +38 -0
  76. data/lib/aia/crew.rb +164 -0
  77. data/lib/aia/debate_handler.rb +166 -0
  78. data/lib/aia/delegate_handler.rb +112 -0
  79. data/lib/aia/directive.rb +33 -18
  80. data/lib/aia/directive_processor.rb +16 -7
  81. data/lib/aia/directives/configuration_directives.rb +160 -20
  82. data/lib/aia/directives/context_directives.rb +38 -26
  83. data/lib/aia/directives/execution_directives.rb +136 -4
  84. data/lib/aia/directives/model_directives.rb +76 -34
  85. data/lib/aia/directives/trakflow_directives.rb +44 -0
  86. data/lib/aia/directives/utility_directives.rb +203 -6
  87. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  88. data/lib/aia/errors.rb +15 -0
  89. data/lib/aia/fact_asserter.rb +27 -0
  90. data/lib/aia/fzf.rb +9 -31
  91. data/lib/aia/handler_context.rb +17 -0
  92. data/lib/aia/handler_protocol.rb +19 -0
  93. data/lib/aia/history_transfer.rb +55 -0
  94. data/lib/aia/input_collector.rb +3 -3
  95. data/lib/aia/layered_orchestrator.rb +448 -0
  96. data/lib/aia/logger.rb +24 -4
  97. data/lib/aia/mcp_config_normalizer.rb +35 -0
  98. data/lib/aia/mcp_connection_manager.rb +305 -0
  99. data/lib/aia/mcp_discovery.rb +44 -0
  100. data/lib/aia/mcp_grouper.rb +33 -0
  101. data/lib/aia/mcp_utility.rb +57 -0
  102. data/lib/aia/mention_router.rb +260 -0
  103. data/lib/aia/model_alias_registry.rb +97 -0
  104. data/lib/aia/model_switch_handler.rb +100 -0
  105. data/lib/aia/network_builder.rb +155 -0
  106. data/lib/aia/network_memory_manager.rb +55 -0
  107. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  108. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  109. data/lib/aia/pipeline_orchestrator.rb +262 -0
  110. data/lib/aia/plugin_loader.rb +170 -0
  111. data/lib/aia/plugin_monitor.rb +208 -0
  112. data/lib/aia/prompt_decomposer.rb +157 -0
  113. data/lib/aia/prompt_handler.rb +19 -39
  114. data/lib/aia/robot_builder.rb +51 -0
  115. data/lib/aia/robot_factory.rb +334 -0
  116. data/lib/aia/robot_namer.rb +116 -0
  117. data/lib/aia/session.rb +83 -17
  118. data/lib/aia/session_tracker.rb +209 -0
  119. data/lib/aia/similarity_scorer.rb +39 -0
  120. data/lib/aia/skill_utils.rb +105 -1
  121. data/lib/aia/spawn_handler.rb +129 -0
  122. data/lib/aia/spawn_spec_parser.rb +65 -0
  123. data/lib/aia/special_mode_handler.rb +302 -0
  124. data/lib/aia/startup_coordinator.rb +150 -0
  125. data/lib/aia/streaming_runner.rb +169 -0
  126. data/lib/aia/system_prompt_assembler.rb +88 -0
  127. data/lib/aia/task_coordinator.rb +202 -0
  128. data/lib/aia/task_decomposer.rb +57 -0
  129. data/lib/aia/task_executor.rb +51 -0
  130. data/lib/aia/tfidf_math.rb +27 -0
  131. data/lib/aia/tool_filter/tfidf.rb +116 -0
  132. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  133. data/lib/aia/tool_filter.rb +82 -0
  134. data/lib/aia/tool_filter_registry.rb +30 -0
  135. data/lib/aia/tool_filter_strategy.rb +143 -0
  136. data/lib/aia/tool_loader.rb +210 -0
  137. data/lib/aia/tool_utility.rb +30 -0
  138. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  139. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  140. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  141. data/lib/aia/tools/task_board_tool.rb +114 -0
  142. data/lib/aia/trakflow_bridge.rb +173 -0
  143. data/lib/aia/turn_state.rb +94 -0
  144. data/lib/aia/ui_presenter.rb +166 -198
  145. data/lib/aia/utility.rb +134 -87
  146. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  147. data/lib/aia/verification_network.rb +58 -0
  148. data/lib/aia.rb +108 -63
  149. data/mkdocs.yml +1 -0
  150. metadata +179 -56
  151. data/justfile +0 -215
  152. data/lib/aia/adapter/chat_execution.rb +0 -242
  153. data/lib/aia/adapter/error_handler.rb +0 -68
  154. data/lib/aia/adapter/gem_activator.rb +0 -57
  155. data/lib/aia/adapter/mcp_connector.rb +0 -274
  156. data/lib/aia/adapter/modality_handlers.rb +0 -167
  157. data/lib/aia/adapter/model_registry.rb +0 -81
  158. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  159. data/lib/aia/adapter/provider_configurator.rb +0 -59
  160. data/lib/aia/adapter/tool_filter.rb +0 -85
  161. data/lib/aia/adapter/tool_loader.rb +0 -90
  162. data/lib/aia/chat_processor_service.rb +0 -164
  163. data/lib/aia/prompt_pipeline.rb +0 -183
  164. data/lib/aia/ruby_llm_adapter.rb +0 -95
  165. data/lib/extensions/openstruct_merge.rb +0 -48
  166. data/lib/extensions/ruby_llm/.irbrc +0 -56
  167. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  168. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  169. data/lib/refinements/string.rb +0 -16
  170. 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,216 +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?
49
-
50
- prompt_handler = AIA::PromptHandler.new
51
- role_parsed = prompt_handler.fetch_role(role)
52
- return if role_parsed.nil?
53
-
54
- role_content = role_parsed.to_s
55
- return if role_content.nil? || role_content.strip.empty?
64
+ def process_initial_context(skip_context_files)
65
+ return if skip_context_files || !AIA.config.context_files || AIA.config.context_files.empty?
56
66
 
57
- return unless AIA.client.respond_to?(:chats)
67
+ context = AIA.config.context_files.map do |file|
68
+ File.read(file) rescue "Error reading file: #{file}"
69
+ end.join("\n\n")
58
70
 
59
- system_msg = RubyLLM::Message.new(role: :system, content: role_content)
71
+ return if context.empty?
60
72
 
61
- AIA.client.chats.each_value do |chat|
62
- next if chat.messages.any? { |m| m.role == :system }
63
- chat.add_message(system_msg)
64
- end
73
+ result, streamed_content, _elapsed = @streaming_runner.run(@robot, context)
74
+ present_result(result,
75
+ streamed_content: streamed_content,
76
+ ui_presenter: @ui_presenter)
65
77
  end
66
78
 
67
- def process_skill_context
68
- skills = AIA.config.prompts.skills
69
- return if skills.nil? || skills.empty?
70
-
71
- skills_dir = AIA.config.skills.dir
72
- bodies = Array(skills).filter_map do |skill_name|
73
- skill_name = skill_name.to_s.strip
74
- next if skill_name.empty?
79
+ def run_loop
80
+ loop do
81
+ follow_up_prompt = @ui_presenter.ask_question
82
+ break if should_exit_chat?(follow_up_prompt)
75
83
 
76
- skill_path = find_skill_dir(skill_name, skills_dir)
77
- next unless skill_path
84
+ log_user_input(follow_up_prompt)
78
85
 
79
- if File.file?(skill_path)
80
- skill_body(File.read(skill_path))
81
- else
82
- md = File.join(skill_path, 'SKILL.md')
83
- skill_body(File.read(md)) if File.exist?(md)
84
- end
85
- end
86
+ processed_prompt = resolve_input(follow_up_prompt)
87
+ next if processed_prompt.nil?
86
88
 
87
- return if bodies.empty?
89
+ next if model_switch_handled?
90
+ next if special_routing_handled?(processed_prompt, @robot)
88
91
 
89
- skill_content = bodies.join("\n\n")
90
- response_data = @chat_processor.process_prompt(skill_content)
91
- content = response_data.is_a?(Hash) ? response_data[:content] : response_data
92
- @chat_processor.output_response(content)
93
- @ui_presenter.display_separator
92
+ execute_and_present(processed_prompt, @robot)
93
+ clear_turn_mcp_filter
94
+ end
94
95
  end
95
96
 
96
- def process_initial_context(skip_context_files)
97
- return if skip_context_files || !AIA.config.context_files || AIA.config.context_files.empty?
98
-
99
- context = AIA.config.context_files.map do |file|
100
- File.read(file) rescue "Error reading file: #{file}"
101
- end.join("\n\n")
102
-
103
- return if context.empty?
104
-
105
- response_data = @chat_processor.process_prompt(context)
106
- content = response_data.is_a?(Hash) ? response_data[:content] : response_data
107
-
108
- @chat_processor.output_response(content)
109
- @chat_processor.speak(content)
110
- @ui_presenter.display_separator
97
+ def should_exit_chat?(input)
98
+ input.nil? || input.strip.downcase == "exit" || input.strip.empty?
111
99
  end
112
100
 
113
- def handle_piped_input
114
- return if STDIN.tty?
115
- return unless File.exist?("/dev/tty") && File.readable?("/dev/tty") && File.writable?("/dev/tty")
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)
106
+ end
107
+ end
116
108
 
117
- begin
118
- original_stdin = STDIN.dup
119
- piped_input = STDIN.read.strip
120
- STDIN.reopen("/dev/tty")
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
121
122
 
122
- return if piped_input.empty?
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
128
+ end
123
129
 
124
- processed_input = PM.parse_string(piped_input).to_s
130
+ def model_switch_handled?
131
+ return false unless @model_switch_handler.handle(HandlerContext.new(config: AIA.config))
125
132
 
126
- response_data = @chat_processor.process_prompt(processed_input)
127
- content = response_data.is_a?(Hash) ? response_data[:content] : response_data
133
+ update_robot
134
+ true
135
+ end
128
136
 
129
- @chat_processor.output_response(content)
130
- @chat_processor.speak(content) if AIA.speak?
131
- @ui_presenter.display_separator
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
132
142
 
133
- STDIN.reopen(original_stdin)
134
- rescue Errno::ENXIO => e
135
- warn "Warning: Unable to handle piped input due to TTY unavailability: #{e.message}"
136
- return
137
- rescue StandardError => e
138
- warn "Warning: Error handling piped input: #{e.message}"
139
- return
143
+ if @mention_router.handle(HandlerContext.new(robot: active_robot, prompt: processed_prompt))
144
+ clear_turn_mcp_filter
145
+ return true
140
146
  end
147
+
148
+ false
141
149
  end
142
150
 
143
- def run_loop
144
- loop do
145
- follow_up_prompt = @ui_presenter.ask_question
151
+ def execute_and_present(processed_prompt, active_robot)
152
+ log_robot_tools(active_robot)
146
153
 
147
- break if follow_up_prompt.nil? || follow_up_prompt.strip.downcase == "exit" || follow_up_prompt.strip.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
148
158
 
149
- if AIA.config.output.file
150
- File.open(AIA.config.output.file, "a") do |file|
151
- file.puts "\nYou: #{follow_up_prompt}"
152
- end
153
- end
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
154
172
 
155
- if @directive_processor.directive?(follow_up_prompt)
156
- follow_up_prompt = process_directive(follow_up_prompt)
157
- next if follow_up_prompt.nil?
158
- end
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
159
180
 
160
- begin
161
- processed_prompt = PM.parse_string(follow_up_prompt).to_s
162
- rescue StandardError => e
163
- @ui_presenter.display_info("Error: #{e.class}: #{e.message}")
164
- next
165
- end
181
+ def increment_turn_counter
182
+ return unless @robot.respond_to?(:memory) && @robot.memory.respond_to?(:data)
166
183
 
167
- response_data = @chat_processor.process_prompt(processed_prompt)
184
+ data = @robot.memory.data
185
+ count = data.respond_to?(:turn_count) ? (data.turn_count || 0) : 0
186
+ data.turn_count = count + 1
187
+ end
168
188
 
169
- if response_data.is_a?(Hash)
170
- content = response_data[:content]
171
- metrics = response_data[:metrics]
172
- multi_metrics = response_data[:multi_metrics]
173
- else
174
- content = response_data
175
- metrics = nil
176
- multi_metrics = nil
177
- end
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
178
193
 
179
- @ui_presenter.display_ai_response(content)
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
180
199
 
181
- if AIA.config.flags.tokens
182
- if multi_metrics
183
- @ui_presenter.display_multi_model_metrics(multi_metrics)
184
- elsif metrics
185
- @ui_presenter.display_token_metrics(metrics)
186
- end
187
- 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
188
205
 
189
- @chat_processor.speak(content)
190
- @ui_presenter.display_separator
191
- 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
192
211
  end
193
212
 
194
213
  def process_directive(follow_up_prompt)
195
214
  directive_output = @directive_processor.process(follow_up_prompt, nil)
196
215
 
197
- if follow_up_prompt.strip.start_with?("/clear", "/checkpoint", "/restore", "/review", "/context")
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)
198
221
  @ui_presenter.display_info(directive_output) unless directive_output.nil? || directive_output.strip.empty?
199
222
  return nil
200
223
  end
201
224
 
202
225
  return nil if directive_output.nil? || directive_output.strip.empty?
203
226
 
204
- handle_successful_directive(follow_up_prompt, directive_output)
205
- end
206
-
207
- def handle_successful_directive(follow_up_prompt, directive_output)
208
227
  puts "\n#{directive_output}\n"
209
228
  "I executed this directive: #{follow_up_prompt}\nHere's the output: #{directive_output}\nLet's continue our conversation."
210
229
  end
211
230
 
212
- # Parse multi-model response into per-model responses (ADR-002 revised + ADR-005)
213
- def parse_multi_model_response(combined_response)
214
- return {} if combined_response.nil? || combined_response.empty?
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
215
238
 
216
- responses = {}
217
- current_model = nil
218
- current_content = []
239
+ if defined?(SimpleFlow::Result) && result.is_a?(SimpleFlow::Result)
240
+ display_network_metrics(result)
241
+ return
242
+ end
219
243
 
220
- combined_response.each_line do |line|
221
- if line =~ /^from:\s+(.+)$/
222
- if current_model
223
- responses[current_model] = current_content.join.strip
224
- end
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
256
+
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
+ }
297
+ end
225
298
 
226
- display_name = $1.strip
227
- internal_id = display_name.sub(/\s+\([^)]+\)\s*$/, '')
228
- internal_id = internal_id.sub(/\s+#/, '#')
299
+ return if metrics_list.empty?
229
300
 
230
- current_model = internal_id
231
- current_content = []
232
- elsif current_model
233
- current_content << line
234
- end
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] }
235
305
  end
236
306
 
237
- if current_model
238
- responses[current_model] = current_content.join.strip
307
+ @ui_presenter.display_multi_model_metrics(metrics_list)
308
+ end
309
+
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
316
+ end
317
+
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?
322
+
323
+ # active_robot is always a Network; resolve to the chief for tool inspection.
324
+ target = robot.respond_to?(:chief) ? robot.chief : robot
325
+
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
337
+
338
+ File.open(out_file, "a") { |f| f.puts "\nYou: #{input}" }
339
+ end
340
+
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
375
+ end
239
376
  end
377
+ rescue StandardError => e
378
+ $stderr.puts "Warning: Speech failed: #{e.message}"
379
+ end
240
380
 
241
- responses
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
388
+ end
242
389
  end
243
390
  end
244
391
  end