aia 0.8.5 → 0.9.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.
data/lib/aia/config.rb CHANGED
@@ -9,6 +9,9 @@ require 'yaml'
9
9
  require 'toml-rb'
10
10
  require 'erb'
11
11
  require 'optparse'
12
+ require 'json'
13
+ require 'tempfile'
14
+ require 'fileutils'
12
15
 
13
16
  module AIA
14
17
  class Config
@@ -26,10 +29,14 @@ module AIA
26
29
  role: '',
27
30
  system_prompt: '',
28
31
 
32
+ # MCP configuration
33
+ mcp_servers: [],
34
+ allowed_tools: nil, # nil means all tools are allowed; otherwise an Array of Strings which are the tool names
35
+
29
36
  # Flags
30
37
  markdown: true,
31
- shell: false,
32
- erb: false,
38
+ shell: true,
39
+ erb: true,
33
40
  chat: false,
34
41
  clear: false,
35
42
  terse: false,
@@ -60,6 +67,7 @@ module AIA
60
67
  speech_model: 'tts-1',
61
68
  transcription_model: 'whisper-1',
62
69
  voice: 'alloy',
70
+ adapter: 'ruby_llm', # 'ruby_llm' or ???
63
71
 
64
72
  # Embedding parameters
65
73
  embedding_model: 'text-embedding-ada-002',
@@ -192,6 +200,13 @@ module AIA
192
200
  PromptManager::Prompt.parameter_regex = Regexp.new(config.parameter_regex)
193
201
  end
194
202
 
203
+ debug_me{[ 'config.mcp_servers' ]}
204
+
205
+ unless config.mcp_servers.empty?
206
+ # create a single JSON file contain all of the MCP server definitions specified my the --mcp option
207
+ config.mcp_servers = combine_mcp_server_json_files config.mcp_servers
208
+ end
209
+
195
210
  config
196
211
  end
197
212
 
@@ -236,19 +251,23 @@ module AIA
236
251
  puts "Debug: Setting chat mode to true" if config.debug
237
252
  end
238
253
 
239
- opts.on("-m MODEL", "--model MODEL", "Name of the LLM model to use") do |model|
240
- config.model = model
254
+ opts.on("--adapter ADAPTER", "Interface that adapts AIA to the LLM") do |adapter|
255
+ adapter.downcase!
256
+ valid_adapters = %w[ ruby_llm ] # NOTE: Add additional adapters here when needed
257
+ if valid_adapters.include? adapter
258
+ config.adapter = adapter
259
+ else
260
+ STDERR.puts "ERROR: Invalid adapter #{adapter} must be one of these: #{valid_adapters.join(', ')}"
261
+ exit 1
262
+ end
241
263
  end
242
264
 
243
- opts.on("--shell", "Enables `aia` to access your terminal's shell environment from inside the prompt text, allowing for dynamic content insertion using system environment variables and shell commands. Includes safety features to confirm or block dangerous commands.") do
244
- config.shell = true
245
- end
246
265
 
247
- opts.on("--erb", "Turns the prompt text file into a fully functioning ERB template, allowing for embedded Ruby code processing within the prompt text. This enables dynamic content generation and complex logic within prompts.") do
248
- config.erb = true
266
+ opts.on("-m MODEL", "--model MODEL", "Name of the LLM model to use") do |model|
267
+ config.model = model
249
268
  end
250
269
 
251
- opts.on("--terse", "Add terse instruction to prompt") do
270
+ opts.on("--terse", "Adds a special instruction to the prompt asking the AI to keep responses short and to the point") do
252
271
  config.terse = true
253
272
  end
254
273
 
@@ -415,6 +434,44 @@ module AIA
415
434
  opts.on("--rq LIBS", "Ruby libraries to require for Ruby directive") do |libs|
416
435
  config.require_libs = libs.split(',')
417
436
  end
437
+
438
+ opts.on("--mcp FILE", "Add MCP server configuration from JSON file. Can be specified multiple times.") do |file|
439
+ # debug_me FIXME ruby-mcp-client is looking for a single JSON file that
440
+ # could contain multiple server definitions that looks like this:
441
+ # {
442
+ # "mcpServers": {
443
+ # "server one": { ... },
444
+ # "server two": { ... }, ....
445
+ # }
446
+ # }
447
+ # FIXME: need to rurn multiple JSON files into one.
448
+ if AIA.good_file?(file)
449
+ config.mcp_servers ||= []
450
+ config.mcp_servers << file
451
+ begin
452
+ server_config = JSON.parse(File.read(file))
453
+ config.mcp_servers_config ||= []
454
+ config.mcp_servers_config << server_config
455
+ rescue JSON::ParserError => e
456
+ STDERR.puts "Error parsing MCP server config file #{file}: #{e.message}"
457
+ exit 1
458
+ end
459
+ else
460
+ STDERR.puts "MCP server config file not found: #{file}"
461
+ exit 1
462
+ end
463
+ end
464
+
465
+ opts.on("--at", "--allowed_tools TOOLS_LIST", "Allow only these tools to be used") do |tools_list|
466
+ config.allowed_tools ||= []
467
+ if tools_list.empty?
468
+ STDERR.puts "No list of tool names provided for --allowed_tools option"
469
+ exit 1
470
+ else
471
+ config.allowed_tools += tools_list.split(',').map(&:strip)
472
+ config.allowed_tools.uniq!
473
+ end
474
+ end
418
475
  end
419
476
 
420
477
  args = ARGV.dup
@@ -500,5 +557,95 @@ module AIA
500
557
  File.write(file, content)
501
558
  puts "Config successfully dumped to #{file}"
502
559
  end
560
+
561
+
562
+ # Combine multiple MCP server JSON files into a single file
563
+ def self.combine_mcp_server_json_files(file_paths)
564
+ raise ArgumentError, "No JSON files provided" if file_paths.nil? || file_paths.empty?
565
+
566
+ # The output will have only one top-level key: "mcpServers"
567
+ mcp_servers = {} # This will store all collected server_name => server_config pairs
568
+
569
+ file_paths.each do |file_path|
570
+ file_content = JSON.parse(File.read(file_path))
571
+ # Clean basename, e.g., "filesystem.json" -> "filesystem", "foo.json.erb" -> "foo"
572
+ cleaned_basename = File.basename(file_path).sub(/\.json\.erb$/, '').sub(/\.json$/, '')
573
+
574
+ if file_content.is_a?(Hash)
575
+ if file_content.key?("mcpServers") && file_content["mcpServers"].is_a?(Hash)
576
+ # Case A: {"mcpServers": {"name1": {...}, "name2": {...}}}
577
+ file_content["mcpServers"].each do |server_name, server_data|
578
+ if mcp_servers.key?(server_name)
579
+ STDERR.puts "Warning: Duplicate MCP server name '#{server_name}' found. Overwriting with definition from #{file_path}."
580
+ end
581
+ mcp_servers[server_name] = server_data
582
+ end
583
+ # Check if the root hash itself is a single server definition
584
+ elsif is_single_server_definition?(file_content)
585
+ # Case B: {"type": "stdio", ...} or {"url": "...", ...}
586
+ # Use "name" property from JSON if present, otherwise use cleaned_basename
587
+ server_name = file_content["name"] || cleaned_basename
588
+ if mcp_servers.key?(server_name)
589
+ STDERR.puts "Warning: Duplicate MCP server name '#{server_name}' (from file #{file_path}). Overwriting."
590
+ end
591
+ mcp_servers[server_name] = file_content
592
+ else
593
+ # Case D: Fallback for {"custom_name1": {server_config1}, "custom_name2": {server_config2}}
594
+ # This assumes top-level keys are server names and values are server configs.
595
+ file_content.each do |server_name, server_data|
596
+ if server_data.is_a?(Hash) && is_single_server_definition?(server_data)
597
+ if mcp_servers.key?(server_name)
598
+ STDERR.puts "Warning: Duplicate MCP server name '#{server_name}' found in #{file_path}. Overwriting."
599
+ end
600
+ mcp_servers[server_name] = server_data
601
+ else
602
+ STDERR.puts "Warning: Unrecognized structure for key '#{server_name}' in #{file_path}. Value is not a valid server definition. Skipping."
603
+ end
604
+ end
605
+ end
606
+ elsif file_content.is_a?(Array)
607
+ # Case C: [ {server_config1}, {server_config2_with_name} ]
608
+ file_content.each_with_index do |server_data, index|
609
+ if server_data.is_a?(Hash) && is_single_server_definition?(server_data)
610
+ # Use "name" property from JSON if present, otherwise generate one
611
+ server_name = server_data["name"] || "#{cleaned_basename}_#{index}"
612
+ if mcp_servers.key?(server_name)
613
+ STDERR.puts "Warning: Duplicate MCP server name '#{server_name}' (from array in #{file_path}). Overwriting."
614
+ end
615
+ mcp_servers[server_name] = server_data
616
+ else
617
+ STDERR.puts "Warning: Unrecognized item in array in #{file_path} at index #{index}. Skipping."
618
+ end
619
+ end
620
+ else
621
+ STDERR.puts "Warning: Unrecognized JSON structure in #{file_path}. Skipping."
622
+ end
623
+ end
624
+
625
+ # Create the final output structure
626
+ output = {"mcpServers" => mcp_servers}
627
+ temp_file = Tempfile.new(['combined', '.json'])
628
+ temp_file.write(JSON.pretty_generate(output))
629
+ temp_file.close
630
+
631
+ temp_file.path
632
+ end
633
+
634
+ # Helper method to determine if a hash represents a valid MCP server definition
635
+ def self.is_single_server_definition?(config)
636
+ return false unless config.is_a?(Hash)
637
+ type = config['type']
638
+ if type
639
+ return true if type == 'stdio' && config.key?('command')
640
+ return true if type == 'sse' && config.key?('url')
641
+ # Potentially other explicit types if they exist in MCP
642
+ return false # Known type but missing required fields for it, or unknown type
643
+ else
644
+ # Infer type
645
+ return true if config.key?('command') || config.key?('args') || config.key?('env') # stdio
646
+ return true if config.key?('url') # sse
647
+ end
648
+ false
649
+ end
503
650
  end
504
651
  end
@@ -49,6 +49,7 @@ module AIA
49
49
  envar_flag: AIA.config.shell
50
50
  )
51
51
 
52
+ # Parameters should be extracted during initialization or to_s
52
53
  return prompt if prompt
53
54
  else
54
55
  puts "Warning: Invalid prompt ID or file not found: #{prompt_id}"
@@ -140,8 +141,11 @@ module AIA
140
141
  end
141
142
 
142
143
 
144
+ # FIXME: original implementation used a search_proc to look into the content of the prompt
145
+ # files. The use of the select statement does not work.
143
146
  def search_prompt_id_with_fzf(initial_query)
144
- prompt_files = Dir.glob(File.join(@prompts_dir, "*.txt")).map { |file| File.basename(file, ".txt") }
147
+ prompt_files = Dir.glob(File.join(@prompts_dir, "*.txt"))
148
+ .map { |file| File.basename(file, ".txt") }
145
149
  fzf = AIA::Fzf.new(
146
150
  list: prompt_files,
147
151
  directory: @prompts_dir,
@@ -153,7 +157,8 @@ module AIA
153
157
  end
154
158
 
155
159
  def search_role_id_with_fzf(initial_query)
156
- role_files = Dir.glob(File.join(@roles_dir, "*.txt")).map { |file| File.basename(file, ".txt") }
160
+ role_files = Dir.glob(File.join(@roles_dir, "*.txt"))
161
+ .map { |file| File.basename(file, ".txt") }
157
162
  fzf = AIA::Fzf.new(
158
163
  list: role_files,
159
164
  directory: @prompts_dir,
@@ -0,0 +1,225 @@
1
+ # lib/aia/ruby_llm_adapter.rb
2
+
3
+ require 'ruby_llm'
4
+ require 'mcp_client'
5
+
6
+ module AIA
7
+ class RubyLLMAdapter
8
+ def initialize
9
+
10
+ debug_me('=== RubyLLMAdapter ===')
11
+
12
+ @model = AIA.config.model
13
+ model_info = extract_model_parts(@model)
14
+
15
+ # Configure RubyLLM with available API keys
16
+ RubyLLM.configure do |config|
17
+ config.openai_api_key = ENV.fetch('OPENAI_API_KEY', nil)
18
+ config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY', nil)
19
+ config.gemini_api_key = ENV.fetch('GEMINI_API_KEY', nil)
20
+ config.deepseek_api_key = ENV.fetch('DEEPSEEK_API_KEY', nil)
21
+
22
+ # Bedrock configuration
23
+ config.bedrock_api_key = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
24
+ config.bedrock_secret_key = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
25
+ config.bedrock_region = ENV.fetch('AWS_REGION', nil)
26
+ config.bedrock_session_token = ENV.fetch('AWS_SESSION_TOKEN', nil)
27
+ end
28
+
29
+ debug_me{[ :model_info ]}
30
+
31
+ mcp_client, mcp_tools = generate_mcp_tools(model_info[:provider])
32
+
33
+ debug_me{[ :mcp_tools ]}
34
+
35
+ if mcp_tools && !mcp_tools.empty?
36
+ RubyLLM::Chat.with_mcp(client: mcp_client, call_tool_method: :call_tool, tools: mcp_tools)
37
+ end
38
+
39
+ @chat = RubyLLM.chat(model: model_info[:model])
40
+ end
41
+
42
+ def chat(prompt)
43
+ if @model.downcase.include?('dall-e') || @model.downcase.include?('image-generation')
44
+ text_to_image(prompt)
45
+ elsif @model.downcase.include?('vision') || @model.downcase.include?('image')
46
+ image_to_text(prompt)
47
+ elsif @model.downcase.include?('tts') || @model.downcase.include?('speech')
48
+ text_to_audio(prompt)
49
+ elsif @model.downcase.include?('whisper') || @model.downcase.include?('transcription')
50
+ audio_to_text(prompt)
51
+ else
52
+ text_to_text(prompt)
53
+ end
54
+ end
55
+
56
+ def transcribe(audio_file)
57
+ @chat.ask("Transcribe this audio", with: { audio: audio_file })
58
+ end
59
+
60
+ def speak(text)
61
+ output_file = "#{Time.now.to_i}.mp3"
62
+
63
+ # Note: RubyLLM doesn't have a direct text-to-speech feature
64
+ # This is a placeholder for a custom implementation or external service
65
+ begin
66
+ # Try using a TTS API if available
67
+ # For now, we'll use a mock implementation
68
+ File.write(output_file, "Mock TTS audio content")
69
+ system("#{AIA.config.speak_command} #{output_file}") if File.exist?(output_file) && system("which #{AIA.config.speak_command} > /dev/null 2>&1")
70
+ "Audio generated and saved to: #{output_file}"
71
+ rescue => e
72
+ "Error generating audio: #{e.message}"
73
+ end
74
+ end
75
+
76
+ def method_missing(method, *args, &block)
77
+ debug_me(tag: '== missing ==', levels: 25){[ :method, :args ]}
78
+ if @chat.respond_to?(method)
79
+ @chat.public_send(method, *args, &block)
80
+ else
81
+ super
82
+ end
83
+ end
84
+
85
+ def respond_to_missing?(method, include_private = false)
86
+ @chat.respond_to?(method) || super
87
+ end
88
+
89
+ private
90
+
91
+ # Generate an array of MCP tools, filtered and formatted for the correct provider.
92
+ # @param config [OpenStruct] the config object containing mcp_servers, allowed_tools, and model
93
+ # @return [Array<Hash>, nil] the filtered and formatted MCP tools or nil if no tools
94
+ def generate_mcp_tools(provider)
95
+ return [nil, nil] unless AIA.config.mcp_servers && !AIA.config.mcp_servers.empty?
96
+
97
+ debug_me('=== generate_mcp_tools ===')
98
+
99
+ # AIA.config.mcp_servers is now a path to the combined JSON file
100
+ mcp_client = MCPClient.create_client(server_definition_file: AIA.config.mcp_servers)
101
+ debug_me
102
+ all_tools = mcp_client.list_tools(cache: false).map(&:name)
103
+ debug_me
104
+ allowed = AIA.config.allowed_tools
105
+ debug_me
106
+ filtered_tools = allowed.nil? ? all_tools : all_tools & allowed
107
+ debug_me{[ :filtered_tools ]}
108
+
109
+ debug_me{[ :provider ]}
110
+
111
+ mcp_tools = if :anthropic == provider.to_sym
112
+ debug_me
113
+ mcp_client.to_anthropic_tools(tool_names: filtered_tools)
114
+ else
115
+ debug_me
116
+ mcp_client.to_openai_tools(tool_names: filtered_tools)
117
+ end
118
+ [mcp_client, mcp_tools]
119
+ rescue => e
120
+ STDERR.puts "ERROR: Failed to generate MCP tools: #{e.message}"
121
+ nil
122
+ end
123
+
124
+ def extract_model_parts(model_string)
125
+ parts = model_string.split('/')
126
+ parts.map!(&:strip)
127
+
128
+ if parts.length > 1
129
+ provider = parts[0]
130
+ model = parts[1]
131
+ else
132
+ provider = nil # RubyLLM will figure it out from the model name
133
+ model = parts[0]
134
+ end
135
+
136
+ { provider: provider, model: model }
137
+ end
138
+
139
+ def extract_text_prompt(prompt)
140
+ if prompt.is_a?(String)
141
+ prompt
142
+ elsif prompt.is_a?(Hash) && prompt[:text]
143
+ prompt[:text]
144
+ elsif prompt.is_a?(Hash) && prompt[:content]
145
+ prompt[:content]
146
+ else
147
+ prompt.to_s
148
+ end
149
+ end
150
+
151
+ def text_to_text(prompt)
152
+ text_prompt = extract_text_prompt(prompt)
153
+ @chat.ask(text_prompt)
154
+ end
155
+
156
+ def text_to_image(prompt)
157
+ text_prompt = extract_text_prompt(prompt)
158
+ output_file = "#{Time.now.to_i}.png"
159
+
160
+ begin
161
+ RubyLLM.paint(text_prompt, output_path: output_file,
162
+ size: AIA.config.image_size,
163
+ quality: AIA.config.image_quality,
164
+ style: AIA.config.image_style)
165
+ "Image generated and saved to: #{output_file}"
166
+ rescue => e
167
+ "Error generating image: #{e.message}"
168
+ end
169
+ end
170
+
171
+ def image_to_text(prompt)
172
+ image_path = extract_image_path(prompt)
173
+ text_prompt = extract_text_prompt(prompt)
174
+
175
+ if image_path && File.exist?(image_path)
176
+ begin
177
+ @chat.ask(text_prompt, with: { image: image_path })
178
+ rescue => e
179
+ "Error analyzing image: #{e.message}"
180
+ end
181
+ else
182
+ text_to_text(prompt)
183
+ end
184
+ end
185
+
186
+ def text_to_audio(prompt)
187
+ text_prompt = extract_text_prompt(prompt)
188
+ output_file = "#{Time.now.to_i}.mp3"
189
+
190
+ begin
191
+ # Note: RubyLLM doesn't have a direct TTS feature
192
+ # This is a placeholder for a custom implementation
193
+ File.write(output_file, text_prompt)
194
+ system("#{AIA.config.speak_command} #{output_file}") if File.exist?(output_file) && system("which #{AIA.config.speak_command} > /dev/null 2>&1")
195
+ "Audio generated and saved to: #{output_file}"
196
+ rescue => e
197
+ "Error generating audio: #{e.message}"
198
+ end
199
+ end
200
+
201
+ def audio_to_text(prompt)
202
+ if prompt.is_a?(String) && File.exist?(prompt) &&
203
+ prompt.downcase.end_with?('.mp3', '.wav', '.m4a', '.flac')
204
+ begin
205
+ @chat.ask("Transcribe this audio", with: { audio: prompt })
206
+ rescue => e
207
+ "Error transcribing audio: #{e.message}"
208
+ end
209
+ else
210
+ # Fall back to regular chat if no valid audio file is found
211
+ text_to_text(prompt)
212
+ end
213
+ end
214
+
215
+ def extract_image_path(prompt)
216
+ if prompt.is_a?(String)
217
+ prompt.scan(/\b[\w\/\.\-]+\.(jpg|jpeg|png|gif|webp)\b/i).first&.first
218
+ elsif prompt.is_a?(Hash)
219
+ prompt[:image] || prompt[:image_path]
220
+ else
221
+ nil
222
+ end
223
+ end
224
+ end
225
+ end