aia 1.0.0.pre.beta → 1.1.0

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.version +1 -1
  3. data/CHANGELOG.md +89 -0
  4. data/COMMITS.md +192 -11
  5. data/README.md +327 -110
  6. data/docs/cli-reference.md +93 -10
  7. data/docs/configuration.md +29 -36
  8. data/docs/contributing.md +2 -2
  9. data/docs/directives-reference.md +49 -27
  10. data/docs/examples/index.md +2 -2
  11. data/docs/examples/mcp/index.md +93 -97
  12. data/docs/examples/prompts/automation/index.md +3 -2
  13. data/docs/examples/tools/index.md +17 -27
  14. data/docs/faq.md +9 -12
  15. data/docs/guides/basic-usage.md +4 -4
  16. data/docs/guides/chat.md +39 -34
  17. data/docs/guides/tools.md +4 -4
  18. data/docs/index.md +36 -62
  19. data/docs/installation.md +1 -1
  20. data/docs/mcp-integration.md +75 -139
  21. data/docs/prompt_management.md +88 -1
  22. data/docs/security.md +79 -81
  23. data/docs/tools-and-mcp-examples.md +8 -6
  24. data/docs/workflows-and-pipelines.md +2 -6
  25. data/examples/.gitignore +1 -0
  26. data/examples/README.md +41 -0
  27. data/examples/run_all.sh +261 -0
  28. data/lib/aia/adapter/chat_execution.rb +9 -7
  29. data/lib/aia/adapter/mcp_connector.rb +0 -29
  30. data/lib/aia/adapter/modality_handlers.rb +23 -15
  31. data/lib/aia/adapter/tool_filter.rb +21 -0
  32. data/lib/aia/adapter/tool_loader.rb +1 -9
  33. data/lib/aia/chat_loop.rb +244 -0
  34. data/lib/aia/chat_processor_service.rb +6 -3
  35. data/lib/aia/config/cli_parser.rb +56 -18
  36. data/lib/aia/config/defaults.yml +17 -2
  37. data/lib/aia/config/validator.rb +52 -11
  38. data/lib/aia/config.rb +29 -3
  39. data/lib/aia/directive.rb +29 -0
  40. data/lib/aia/directives/configuration_directives.rb +2 -1
  41. data/lib/aia/directives/execution_directives.rb +1 -1
  42. data/lib/aia/directives/model_directives.rb +28 -27
  43. data/lib/aia/directives/web_and_file_directives.rb +78 -40
  44. data/lib/aia/errors.rb +20 -1
  45. data/lib/aia/fzf.rb +8 -7
  46. data/lib/aia/input_collector.rb +24 -0
  47. data/lib/aia/prompt_handler.rb +36 -8
  48. data/lib/aia/prompt_pipeline.rb +183 -0
  49. data/lib/aia/session.rb +22 -372
  50. data/lib/aia/skill_utils.rb +61 -0
  51. data/lib/aia/ui_presenter.rb +8 -0
  52. data/lib/aia.rb +4 -0
  53. metadata +19 -45
@@ -0,0 +1,183 @@
1
+ # lib/aia/prompt_pipeline.rb
2
+ # frozen_string_literal: true
3
+
4
+ require "pm"
5
+
6
+ module AIA
7
+ class PromptPipeline
8
+ include AIA::SkillUtils
9
+
10
+ def initialize(prompt_handler, chat_processor, ui_presenter, input_collector)
11
+ @prompt_handler = prompt_handler
12
+ @chat_processor = chat_processor
13
+ @ui_presenter = ui_presenter
14
+ @input_collector = input_collector
15
+ @include_context_flag = true
16
+ end
17
+
18
+ # Process all prompts in the pipeline sequentially
19
+ def process_all
20
+ prompt_count = 0
21
+ total_prompts = AIA.config.pipeline.size
22
+
23
+ until AIA.config.pipeline.empty?
24
+ prompt_count += 1
25
+ prompt_id = AIA.config.pipeline.shift
26
+
27
+ puts "\n--- Processing prompt #{prompt_count}/#{total_prompts}: #{prompt_id} ---" if AIA.verbose? && total_prompts > 1
28
+
29
+ process_single(prompt_id)
30
+ end
31
+ end
32
+
33
+ # Process a single prompt with all its requirements
34
+ def process_single(prompt_id)
35
+ return if prompt_id.nil? || prompt_id.empty?
36
+
37
+ prompt_text = build_prompt_text(prompt_id)
38
+ return unless prompt_text
39
+
40
+ send_and_get_response(prompt_text)
41
+ end
42
+
43
+ def build_prompt_text(prompt_id)
44
+ role_id = AIA.config.prompts.role
45
+
46
+ begin
47
+ prompt_parsed = @prompt_handler.fetch_prompt(prompt_id)
48
+ rescue StandardError => e
49
+ warn "Error processing prompt '#{prompt_id}': #{e.message}"
50
+ AIA::LoggerManager.aia_logger.error("Error processing prompt '#{prompt_id}': #{e.message}")
51
+ return nil
52
+ end
53
+
54
+ return nil unless prompt_parsed
55
+
56
+ role_parsed = nil
57
+ unless role_id.nil? || role_id.empty?
58
+ begin
59
+ role_parsed = @prompt_handler.fetch_role(role_id)
60
+ rescue StandardError => e
61
+ warn "Warning: Could not load role '#{role_id}': #{e.message}"
62
+ AIA::LoggerManager.aia_logger.warn("Could not load role '#{role_id}': #{e.message}")
63
+ end
64
+ end
65
+
66
+ # Merge parameters from role and prompt
67
+ all_params = {}
68
+ all_params.merge!(role_parsed.metadata.parameters) if role_parsed&.metadata&.parameters
69
+ all_params.merge!(prompt_parsed.metadata.parameters) if prompt_parsed.metadata&.parameters
70
+
71
+ # Collect parameter values from user
72
+ values = @input_collector.collect(all_params)
73
+
74
+ # Render role, skills, and prompt.
75
+ # Order: role (personality) → skills (task instructions) → prompt (user request)
76
+ parts = []
77
+ parts << role_parsed.to_s(values) if role_parsed
78
+ load_skills(AIA.config.prompts.skills).each { |body| parts << body }
79
+ parts << prompt_parsed.to_s(values)
80
+
81
+ if @include_context_flag
82
+ # Process stdin content
83
+ if AIA.config.stdin_content && !AIA.config.stdin_content.strip.empty?
84
+ parts << PM.parse(AIA.config.stdin_content).to_s
85
+ end
86
+ end
87
+
88
+ prompt_text = parts.join("\n\n")
89
+
90
+ if @include_context_flag
91
+ prompt_text = add_context_files(prompt_text)
92
+ @include_context_flag = false
93
+ end
94
+
95
+ prompt_text
96
+ end
97
+
98
+ # Load skill bodies for the given skill IDs in order.
99
+ # Each skill lives at skills_dir/<name>/SKILL.md; supports prefix matching.
100
+ # Path-based IDs (starting with /, ~/, ./, ../) are resolved as direct paths.
101
+ # Returns only the body content (front matter stripped).
102
+ def load_skills(skill_ids)
103
+ return [] if skill_ids.nil? || skill_ids.empty?
104
+
105
+ skills_dir = AIA.config.skills.dir
106
+
107
+ Array(skill_ids).filter_map do |skill_name|
108
+ skill_name = skill_name.to_s.strip
109
+ next if skill_name.empty?
110
+
111
+ unless path_based_id?(skill_name) || Dir.exist?(skills_dir)
112
+ warn "Warning: No skill matching '#{skill_name}' found in #{skills_dir}"
113
+ next
114
+ end
115
+
116
+ skill_dir = find_skill_dir(skill_name, skills_dir)
117
+ unless skill_dir
118
+ if path_based_id?(skill_name)
119
+ warn "Warning: No skill directory found at '#{File.expand_path(skill_name)}'"
120
+ else
121
+ warn "Warning: No skill matching '#{skill_name}' found in #{skills_dir}"
122
+ end
123
+ next
124
+ end
125
+
126
+ next skill_body(File.read(skill_dir)) if File.file?(skill_dir)
127
+
128
+ skill_path = File.join(skill_dir, 'SKILL.md')
129
+ unless File.exist?(skill_path)
130
+ warn "Warning: Skill '#{skill_name}' has no SKILL.md in #{skill_dir}"
131
+ next
132
+ end
133
+
134
+ skill_body(File.read(skill_path))
135
+ end
136
+ end
137
+
138
+ # Add context files to prompt text
139
+ def add_context_files(prompt_text)
140
+ return prompt_text unless AIA.config.context_files && !AIA.config.context_files.empty?
141
+
142
+ context = AIA.config.context_files.map do |file|
143
+ File.read(file) rescue "Error reading file: #{file}"
144
+ end.join("\n\n")
145
+
146
+ "#{prompt_text}\n\nContext:\n#{context}"
147
+ end
148
+
149
+ private
150
+
151
+ # Send prompt to AI and handle the response
152
+ def send_and_get_response(prompt_text)
153
+ response_data = @chat_processor.process_prompt(prompt_text)
154
+
155
+ if response_data.is_a?(Hash)
156
+ content = response_data[:content]
157
+ metrics = response_data[:metrics]
158
+ multi_metrics = response_data[:multi_metrics]
159
+ else
160
+ content = response_data
161
+ metrics = nil
162
+ multi_metrics = nil
163
+ end
164
+
165
+ @chat_processor.output_response(content)
166
+
167
+ if AIA.config.flags.tokens
168
+ if multi_metrics
169
+ @ui_presenter.display_multi_model_metrics(multi_metrics)
170
+ elsif metrics
171
+ @ui_presenter.display_token_metrics(metrics)
172
+ end
173
+ end
174
+
175
+ # Process any directives in the response
176
+ directive_processor = DirectiveProcessor.new
177
+ if directive_processor.directive?(content)
178
+ directive_result = directive_processor.process(content, nil)
179
+ puts "\nDirective output: #{directive_result}" if directive_result && !directive_result.strip.empty?
180
+ end
181
+ end
182
+ end
183
+ end
data/lib/aia/session.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # lib/aia/session.rb
2
+ # frozen_string_literal: true
2
3
 
3
4
  require "tty-spinner"
4
5
  require "tty-screen"
@@ -13,414 +14,63 @@ require_relative "ui_presenter"
13
14
  require_relative "chat_processor_service"
14
15
  require_relative "prompt_handler"
15
16
  require_relative "utility"
17
+ require_relative "input_collector"
18
+ require_relative "prompt_pipeline"
19
+ require_relative "chat_loop"
16
20
 
17
21
  module AIA
18
22
  class Session
19
23
  def initialize(prompt_handler)
20
24
  @prompt_handler = prompt_handler
21
- @include_context_flag = true
22
25
 
23
26
  initialize_components
24
27
  setup_output_file
25
28
  end
26
29
 
27
- def initialize_components
28
- # RubyLLM's Chat instances maintain conversation history internally
29
- # via @messages array. No separate context manager needed.
30
- # Checkpoint/restore directives access Chat.@messages directly via AIA.client.chats
31
- @ui_presenter = UIPresenter.new
32
- @directive_processor = DirectiveProcessor.new
33
- @chat_processor = ChatProcessorService.new(@ui_presenter, @directive_processor)
34
- end
35
-
36
- def setup_output_file
37
- out_file = AIA.config.output.file
38
- if out_file && !out_file.nil? && !AIA.append? && File.exist?(out_file)
39
- File.open(out_file, "w") { } # Truncate the file
40
- end
41
- end
42
-
43
30
  # Starts the session, processing all prompts in the pipeline and then
44
31
  # optionally starting an interactive chat session.
45
32
  def start
46
33
  # Handle special chat-only cases first
47
34
  if should_start_chat_immediately?
48
35
  AIA::Utility.robot
49
- start_chat
36
+ @chat_loop.start
50
37
  return
51
38
  end
52
39
 
53
40
  # Process all prompts in the pipeline
54
- process_all_prompts
41
+ @prompt_pipeline.process_all
55
42
 
56
43
  # Start chat mode after all prompts are processed
57
44
  if AIA.chat?
58
45
  AIA::Utility.robot
59
46
  @ui_presenter.display_separator
60
- start_chat(skip_context_files: true)
47
+ @chat_loop.start(skip_context_files: true)
61
48
  end
62
49
  end
63
50
 
64
51
  private
65
52
 
66
- # Check if we should start chat immediately without processing any prompts
67
- def should_start_chat_immediately?
68
- return false unless AIA.chat?
69
-
70
- # If pipeline is empty or only contains empty prompt_ids, go straight to chat
71
- AIA.config.pipeline.empty? || AIA.config.pipeline.all? { |id| id.nil? || id.empty? }
72
- end
73
-
74
- # Process all prompts in the pipeline sequentially
75
- def process_all_prompts
76
- prompt_count = 0
77
- total_prompts = AIA.config.pipeline.size
78
-
79
- until AIA.config.pipeline.empty?
80
- prompt_count += 1
81
- prompt_id = AIA.config.pipeline.shift
82
-
83
- puts "\n--- Processing prompt #{prompt_count}/#{total_prompts}: #{prompt_id} ---" if AIA.verbose? && total_prompts > 1
84
-
85
- process_single_prompt(prompt_id)
86
- end
87
- end
88
-
89
- # Process a single prompt with all its requirements
90
- def process_single_prompt(prompt_id)
91
- return if prompt_id.nil? || prompt_id.empty?
92
-
93
- prompt_text = build_prompt_text(prompt_id)
94
- return unless prompt_text
95
-
96
- send_prompt_and_get_response(prompt_text)
97
- end
98
-
99
- def build_prompt_text(prompt_id)
100
- role_id = AIA.config.prompts.role
101
-
102
- begin
103
- prompt_parsed = @prompt_handler.fetch_prompt(prompt_id)
104
- rescue StandardError => e
105
- puts "Error processing prompt '#{prompt_id}': #{e.message}"
106
- return nil
107
- end
108
-
109
- return nil unless prompt_parsed
110
-
111
- role_parsed = nil
112
- unless role_id.nil? || role_id.empty?
113
- begin
114
- role_parsed = @prompt_handler.fetch_role(role_id)
115
- rescue StandardError => e
116
- puts "Warning: Could not load role '#{role_id}': #{e.message}"
117
- end
118
- end
119
-
120
- # Merge parameters from role and prompt
121
- all_params = {}
122
- all_params.merge!(role_parsed.metadata.parameters) if role_parsed&.metadata&.parameters
123
- all_params.merge!(prompt_parsed.metadata.parameters) if prompt_parsed.metadata&.parameters
124
-
125
- # Collect parameter values from user
126
- values = collect_variable_values(all_params)
127
-
128
- # Render role and prompt
129
- parts = []
130
- parts << role_parsed.to_s(values) if role_parsed
131
- parts << prompt_parsed.to_s(values)
132
-
133
- if @include_context_flag
134
- # Process stdin content
135
- if AIA.config.stdin_content && !AIA.config.stdin_content.strip.empty?
136
- parts << PM.parse(AIA.config.stdin_content).to_s
137
- end
138
-
139
- end
140
-
141
- prompt_text = parts.join("\n\n")
142
-
143
- if @include_context_flag
144
- prompt_text = add_context_files(prompt_text)
145
- @include_context_flag = false
146
- end
147
-
148
- prompt_text
149
- end
150
-
151
- # Collect variable values from user input
152
- def collect_variable_values(parameters)
153
- return {} if parameters.nil? || parameters.empty?
154
-
155
- values = {}
156
- input_manager = AIA::HistoryManager.new
157
-
158
- parameters.each do |name, default|
159
- value = input_manager.request_variable_value(
160
- variable_name: name,
161
- default_value: default,
162
- )
163
- values[name] = value
164
- end
165
-
166
- values
167
- end
168
-
169
- # Add context files to prompt text
170
- def add_context_files(prompt_text)
171
- return prompt_text unless AIA.config.context_files && !AIA.config.context_files.empty?
172
-
173
- context = AIA.config.context_files.map do |file|
174
- File.read(file) rescue "Error reading file: #{file}"
175
- end.join("\n\n")
176
-
177
- "#{prompt_text}\n\nContext:\n#{context}"
178
- end
179
-
180
- # Send prompt to AI and handle the response
181
- # RubyLLM's Chat automatically adds user messages and responses to its internal @messages
182
- def send_prompt_and_get_response(prompt_text)
183
- # Process the prompt - RubyLLM Chat maintains conversation history internally
184
- response_data = @chat_processor.process_prompt(prompt_text)
185
-
186
- # Handle response format (may include metrics)
187
- if response_data.is_a?(Hash)
188
- content = response_data[:content]
189
- metrics = response_data[:metrics]
190
- multi_metrics = response_data[:multi_metrics]
191
- else
192
- content = response_data
193
- metrics = nil
194
- multi_metrics = nil
195
- end
196
-
197
- # Output the response
198
- @chat_processor.output_response(content)
199
-
200
- # Display token usage if enabled and available
201
- if AIA.config.flags.tokens
202
- if multi_metrics
203
- @ui_presenter.display_multi_model_metrics(multi_metrics)
204
- elsif metrics
205
- @ui_presenter.display_token_metrics(metrics)
206
- end
207
- end
208
-
209
- # Process any directives in the response
210
- if @directive_processor.directive?(content)
211
- directive_result = @directive_processor.process(content, nil)
212
- puts "\nDirective output: #{directive_result}" if directive_result && !directive_result.strip.empty?
213
- end
214
- end
215
-
216
- # Starts the interactive chat session.
217
- # NOTE: there could have been an initial prompt sent into this session
218
- # via a prompt_id on the command line, piped in text, or context files.
219
- def start_chat(skip_context_files: false)
220
- setup_chat_session
221
- process_initial_context(skip_context_files)
222
- handle_piped_input
223
- run_chat_loop
224
- ensure
225
- @ui_presenter.display_chat_end
226
- end
227
-
228
- private
229
-
230
- def setup_chat_session
231
- initialize_chat_ui
232
- setup_signal_handlers
233
- Reline::HISTORY.clear
234
- end
235
-
236
- def initialize_chat_ui
237
- puts "\nEntering interactive chat mode..."
238
- @ui_presenter.display_chat_header
239
- end
240
-
241
- def setup_signal_handlers
242
- Signal.trap("INT") { exit }
243
- end
244
-
245
- def process_initial_context(skip_context_files)
246
- return if skip_context_files || !AIA.config.context_files || AIA.config.context_files.empty?
247
-
248
- context = AIA.config.context_files.map do |file|
249
- File.read(file) rescue "Error reading file: #{file}"
250
- end.join("\n\n")
251
-
252
- return if context.empty?
253
-
254
- # Process the context - RubyLLM Chat maintains conversation history internally
255
- response_data = @chat_processor.process_prompt(context)
256
-
257
- # Handle response format (may include metrics)
258
- content = response_data.is_a?(Hash) ? response_data[:content] : response_data
259
-
260
- # Output the response
261
- @chat_processor.output_response(content)
262
- @chat_processor.speak(content)
263
- @ui_presenter.display_separator
264
- end
265
-
266
- def handle_piped_input
267
- return if STDIN.tty?
268
-
269
- # Additional check: see if /dev/tty is available before attempting to use it
270
- return unless File.exist?("/dev/tty") && File.readable?("/dev/tty") && File.writable?("/dev/tty")
271
-
272
- begin
273
- original_stdin = STDIN.dup
274
- piped_input = STDIN.read.strip
275
- STDIN.reopen("/dev/tty")
276
-
277
- return if piped_input.empty?
278
-
279
- processed_input = PM.parse_string(piped_input).to_s
280
-
281
- # Process the piped input - RubyLLM Chat maintains conversation history internally
282
- response_data = @chat_processor.process_prompt(processed_input)
283
-
284
- # Handle response format (may include metrics)
285
- content = response_data.is_a?(Hash) ? response_data[:content] : response_data
286
-
287
- @chat_processor.output_response(content)
288
- @chat_processor.speak(content) if AIA.speak?
289
- @ui_presenter.display_separator
290
-
291
- STDIN.reopen(original_stdin)
292
- rescue Errno::ENXIO => e
293
- # Handle case where /dev/tty is not available (e.g., in some containerized environments)
294
- warn "Warning: Unable to handle piped input due to TTY unavailability: #{e.message}"
295
- return
296
- rescue StandardError => e
297
- # Handle any other errors gracefully
298
- warn "Warning: Error handling piped input: #{e.message}"
299
- return
300
- end
301
- end
302
-
303
- def run_chat_loop
304
- loop do
305
- follow_up_prompt = @ui_presenter.ask_question
306
-
307
- break if follow_up_prompt.nil? || follow_up_prompt.strip.downcase == "exit" || follow_up_prompt.strip.empty?
308
-
309
- if AIA.config.output.file
310
- File.open(AIA.config.output.file, "a") do |file|
311
- file.puts "\nYou: #{follow_up_prompt}"
312
- end
313
- end
314
-
315
- if @directive_processor.directive?(follow_up_prompt)
316
- follow_up_prompt = process_chat_directive(follow_up_prompt)
317
- next if follow_up_prompt.nil?
318
- end
319
-
320
- begin
321
- processed_prompt = PM.parse_string(follow_up_prompt).to_s
322
- rescue StandardError => e
323
- @ui_presenter.display_info("Error: #{e.class}: #{e.message}")
324
- next
325
- end
326
-
327
- # Process the prompt - RubyLLM Chat maintains conversation history internally
328
- # via @messages array. Each model's Chat instance tracks its own conversation.
329
- response_data = @chat_processor.process_prompt(processed_prompt)
330
-
331
- # Handle new response format with metrics
332
- if response_data.is_a?(Hash)
333
- content = response_data[:content]
334
- metrics = response_data[:metrics]
335
- multi_metrics = response_data[:multi_metrics]
336
- else
337
- content = response_data
338
- metrics = nil
339
- multi_metrics = nil
340
- end
341
-
342
- @ui_presenter.display_ai_response(content)
343
-
344
- # Display token usage if enabled and available (chat mode only)
345
- if AIA.config.flags.tokens
346
- if multi_metrics
347
- # Display metrics for each model in multi-model mode
348
- @ui_presenter.display_multi_model_metrics(multi_metrics)
349
- elsif metrics
350
- # Display metrics for single model
351
- @ui_presenter.display_token_metrics(metrics)
352
- end
353
- end
354
-
355
- @chat_processor.speak(content)
356
-
357
- @ui_presenter.display_separator
358
- end
53
+ def initialize_components
54
+ @ui_presenter = UIPresenter.new
55
+ @directive_processor = DirectiveProcessor.new
56
+ @chat_processor = ChatProcessorService.new(@ui_presenter, @directive_processor)
57
+ @input_collector = InputCollector.new
58
+ @prompt_pipeline = PromptPipeline.new(@prompt_handler, @chat_processor, @ui_presenter, @input_collector)
59
+ @chat_loop = ChatLoop.new(@chat_processor, @ui_presenter, @directive_processor)
359
60
  end
360
61
 
361
- def process_chat_directive(follow_up_prompt)
362
- # Directives now access RubyLLM's Chat.@messages directly via AIA.client
363
- # The second parameter is no longer used by checkpoint/restore/clear/review
364
- directive_output = @directive_processor.process(follow_up_prompt, nil)
365
-
366
- # Checkpoint-related directives (clear, checkpoint, restore, review) handle
367
- # everything internally via the Checkpoint module, which operates directly
368
- # on RubyLLM's Chat.@messages - no additional handling needed here.
369
- if follow_up_prompt.strip.start_with?("/clear", "/checkpoint", "/restore", "/review", "/context")
370
- @ui_presenter.display_info(directive_output) unless directive_output.nil? || directive_output.strip.empty?
371
- return nil
62
+ def setup_output_file
63
+ out_file = AIA.config.output.file
64
+ if out_file && !out_file.nil? && !AIA.append? && File.exist?(out_file)
65
+ File.open(out_file, "w") { } # Truncate the file
372
66
  end
373
-
374
- return nil if directive_output.nil? || directive_output.strip.empty?
375
-
376
- handle_successful_directive(follow_up_prompt, directive_output)
377
- end
378
-
379
- def handle_successful_directive(follow_up_prompt, directive_output)
380
- puts "\n#{directive_output}\n"
381
- "I executed this directive: #{follow_up_prompt}\nHere's the output: #{directive_output}\nLet's continue our conversation."
382
67
  end
383
68
 
384
- # Parse multi-model response into per-model responses (ADR-002 revised + ADR-005)
385
- # Input: "from: lms/model #2 (role)\nHabari!\n\nfrom: ollama/model\nKaixo!"
386
- # Output: {"lms/model#2" => "Habari!", "ollama/model" => "Kaixo!"}
387
- def parse_multi_model_response(combined_response)
388
- return {} if combined_response.nil? || combined_response.empty?
389
-
390
- responses = {}
391
- current_model = nil
392
- current_content = []
393
-
394
- combined_response.each_line do |line|
395
- if line =~ /^from:\s+(.+)$/
396
- # Save previous model's response
397
- if current_model
398
- responses[current_model] = current_content.join.strip
399
- end
400
-
401
- # Extract internal_id from display name (ADR-005)
402
- # Display format: "model_name #N (role)" or "model_name (role)" or "model_name #N" or "model_name"
403
- display_name = $1.strip
404
-
405
- # Remove role part: " (role_name)"
406
- internal_id = display_name.sub(/\s+\([^)]+\)\s*$/, '')
407
-
408
- # Remove space before instance number: "model #2" -> "model#2"
409
- internal_id = internal_id.sub(/\s+#/, '#')
410
-
411
- current_model = internal_id
412
- current_content = []
413
- elsif current_model
414
- current_content << line
415
- end
416
- end
417
-
418
- # Save last model's response
419
- if current_model
420
- responses[current_model] = current_content.join.strip
421
- end
69
+ # Check if we should start chat immediately without processing any prompts
70
+ def should_start_chat_immediately?
71
+ return false unless AIA.chat?
422
72
 
423
- responses
73
+ AIA.config.pipeline.empty? || AIA.config.pipeline.all? { |id| id.nil? || id.empty? }
424
74
  end
425
75
  end
426
76
  end
@@ -0,0 +1,61 @@
1
+ # lib/aia/skill_utils.rb
2
+
3
+ require 'yaml'
4
+
5
+ module AIA
6
+ module SkillUtils
7
+ extend self
8
+
9
+ def path_based_id?(id)
10
+ id.to_s.start_with?('/', './', '../', '~/')
11
+ end
12
+
13
+ def parse_front_matter(path)
14
+ return {} unless File.exist?(path)
15
+ content = File.read(path)
16
+ return {} unless content.start_with?('---')
17
+ end_marker = content.index("\n---", 3)
18
+ return {} unless end_marker
19
+ yaml_text = content[3...end_marker]
20
+ YAML.safe_load(yaml_text) || {}
21
+ rescue StandardError
22
+ {}
23
+ end
24
+
25
+ def find_skill_dir(skill_name, base_dir)
26
+ if path_based_id?(skill_name)
27
+ expanded = File.expand_path(skill_name)
28
+ return expanded if Dir.exist?(expanded)
29
+ return expanded if File.file?(expanded)
30
+ return nil
31
+ end
32
+
33
+ exact = File.join(base_dir, skill_name)
34
+ return safe_skill_path(exact, base_dir) if Dir.exist?(exact)
35
+
36
+ Dir.children(base_dir).sort.each do |entry|
37
+ next unless entry.start_with?(skill_name)
38
+ candidate = File.join(base_dir, entry)
39
+ return safe_skill_path(candidate, base_dir) if Dir.exist?(candidate)
40
+ end
41
+
42
+ nil
43
+ rescue Errno::ENOENT
44
+ nil
45
+ end
46
+
47
+ def skill_body(content)
48
+ return content unless content.start_with?('---')
49
+ end_marker = content.index("\n---", 3)
50
+ return content unless end_marker
51
+ content[(end_marker + 4)..].lstrip
52
+ end
53
+
54
+ def safe_skill_path(path, dir)
55
+ resolved = File.realpath(path)
56
+ resolved.start_with?(File.realpath(dir) + File::SEPARATOR) ? resolved : nil
57
+ rescue Errno::ENOENT
58
+ nil
59
+ end
60
+ end
61
+ end
@@ -97,6 +97,14 @@ module AIA
97
97
  puts "\n#{message}"
98
98
  end
99
99
 
100
+ def display_error(message)
101
+ warn "ERROR: #{message}"
102
+ end
103
+
104
+ def display_warning(message)
105
+ warn "WARNING: #{message}"
106
+ end
107
+
100
108
  def with_spinner(message = "Processing", operation_type = nil)
101
109
  spinner_message = operation_type ? "#{message} #{operation_type}..." : "#{message}..."
102
110
  spinner = TTY::Spinner.new("[:spinner] #{spinner_message}", format: :bouncing_ball)