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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/CHANGELOG.md +89 -0
- data/COMMITS.md +192 -11
- data/README.md +327 -110
- data/docs/cli-reference.md +93 -10
- data/docs/configuration.md +29 -36
- data/docs/contributing.md +2 -2
- data/docs/directives-reference.md +49 -27
- data/docs/examples/index.md +2 -2
- data/docs/examples/mcp/index.md +93 -97
- data/docs/examples/prompts/automation/index.md +3 -2
- data/docs/examples/tools/index.md +17 -27
- data/docs/faq.md +9 -12
- data/docs/guides/basic-usage.md +4 -4
- data/docs/guides/chat.md +39 -34
- data/docs/guides/tools.md +4 -4
- data/docs/index.md +36 -62
- data/docs/installation.md +1 -1
- data/docs/mcp-integration.md +75 -139
- data/docs/prompt_management.md +88 -1
- data/docs/security.md +79 -81
- data/docs/tools-and-mcp-examples.md +8 -6
- data/docs/workflows-and-pipelines.md +2 -6
- data/examples/.gitignore +1 -0
- data/examples/README.md +41 -0
- data/examples/run_all.sh +261 -0
- data/lib/aia/adapter/chat_execution.rb +9 -7
- data/lib/aia/adapter/mcp_connector.rb +0 -29
- data/lib/aia/adapter/modality_handlers.rb +23 -15
- data/lib/aia/adapter/tool_filter.rb +21 -0
- data/lib/aia/adapter/tool_loader.rb +1 -9
- data/lib/aia/chat_loop.rb +244 -0
- data/lib/aia/chat_processor_service.rb +6 -3
- data/lib/aia/config/cli_parser.rb +56 -18
- data/lib/aia/config/defaults.yml +17 -2
- data/lib/aia/config/validator.rb +52 -11
- data/lib/aia/config.rb +29 -3
- data/lib/aia/directive.rb +29 -0
- data/lib/aia/directives/configuration_directives.rb +2 -1
- data/lib/aia/directives/execution_directives.rb +1 -1
- data/lib/aia/directives/model_directives.rb +28 -27
- data/lib/aia/directives/web_and_file_directives.rb +78 -40
- data/lib/aia/errors.rb +20 -1
- data/lib/aia/fzf.rb +8 -7
- data/lib/aia/input_collector.rb +24 -0
- data/lib/aia/prompt_handler.rb +36 -8
- data/lib/aia/prompt_pipeline.rb +183 -0
- data/lib/aia/session.rb +22 -372
- data/lib/aia/skill_utils.rb +61 -0
- data/lib/aia/ui_presenter.rb +8 -0
- data/lib/aia.rb +4 -0
- 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
|
-
|
|
36
|
+
@chat_loop.start
|
|
50
37
|
return
|
|
51
38
|
end
|
|
52
39
|
|
|
53
40
|
# Process all prompts in the pipeline
|
|
54
|
-
|
|
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
|
-
|
|
47
|
+
@chat_loop.start(skip_context_files: true)
|
|
61
48
|
end
|
|
62
49
|
end
|
|
63
50
|
|
|
64
51
|
private
|
|
65
52
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
#
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
data/lib/aia/ui_presenter.rb
CHANGED
|
@@ -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)
|