aidp 0.28.0 → 0.29.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/aidp/cli.rb CHANGED
@@ -256,7 +256,7 @@ module Aidp
256
256
  # Determine if the invocation is a subcommand style call
257
257
  def subcommand?(args)
258
258
  return false if args.nil? || args.empty?
259
- %w[status jobs kb harness providers checkpoint mcp issue config init watch ws work skill settings models].include?(args.first)
259
+ %w[status jobs kb harness providers checkpoint mcp issue config init watch ws work skill settings models tools].include?(args.first)
260
260
  end
261
261
 
262
262
  def run_subcommand(args)
@@ -279,6 +279,7 @@ module Aidp
279
279
  when "skill" then run_skill_command(args)
280
280
  when "settings" then run_settings_command(args)
281
281
  when "models" then run_models_command(args)
282
+ when "tools" then run_tools_command(args)
282
283
  else
283
284
  display_message("Unknown command: #{cmd}", type: :info)
284
285
  return 1
@@ -625,6 +626,12 @@ module Aidp
625
626
  models_cmd.run(args)
626
627
  end
627
628
 
629
+ def run_tools_command(args)
630
+ require_relative "cli/tools_command"
631
+ tools_cmd = Aidp::CLI::ToolsCommand.new(project_dir: Dir.pwd, prompt: create_prompt)
632
+ tools_cmd.run(args)
633
+ end
634
+
628
635
  def run_issue_command(args)
629
636
  require_relative "cli/issue_importer"
630
637
 
data/lib/aidp/config.rb CHANGED
@@ -285,6 +285,15 @@ module Aidp
285
285
  symbolize_keys(agile_section)
286
286
  end
287
287
 
288
+ # Get tool metadata configuration
289
+ def self.tool_metadata_config(project_dir = Dir.pwd)
290
+ config = load_harness_config(project_dir)
291
+ tool_metadata_section = config[:tool_metadata] || config["tool_metadata"] || {}
292
+
293
+ # Convert string keys to symbols for consistency
294
+ symbolize_keys(tool_metadata_section)
295
+ end
296
+
288
297
  # Check if configuration file exists
289
298
  def self.config_exists?(project_dir = Dir.pwd)
290
299
  ConfigPaths.config_exists?(project_dir)
@@ -308,7 +308,8 @@ module Aidp
308
308
  iteration: @iteration_count,
309
309
  project_dir: @project_dir,
310
310
  mode: :decide_whats_next,
311
- model: model_name
311
+ model: model_name,
312
+ tier: @thinking_depth_manager.current_tier
312
313
  }
313
314
  )
314
315
 
@@ -340,7 +341,8 @@ module Aidp
340
341
  iteration: @iteration_count,
341
342
  project_dir: @project_dir,
342
343
  mode: :diagnose_failures,
343
- model: model_name
344
+ model: model_name,
345
+ tier: @thinking_depth_manager.current_tier
344
346
  }
345
347
  )
346
348
 
@@ -761,7 +763,8 @@ module Aidp
761
763
  step_name: @step_name,
762
764
  iteration: @iteration_count,
763
765
  project_dir: @project_dir,
764
- model: model_name
766
+ model: model_name,
767
+ tier: @thinking_depth_manager.current_tier
765
768
  }
766
769
  )
767
770
  end
@@ -87,7 +87,7 @@ module Aidp
87
87
  providers_to_search.each do |provider_name|
88
88
  matching_models = []
89
89
  models_for_provider(provider_name).each do |model_name, model_data|
90
- matching_models << model_name if model_data["tier"] == tier
90
+ matching_models << model_name if model_data["tier"] == tier.to_s
91
91
  end
92
92
  results[provider_name] = matching_models unless matching_models.empty?
93
93
  end
@@ -114,12 +114,12 @@ module Aidp
114
114
 
115
115
  # Check if a tier is valid
116
116
  def valid_tier?(tier)
117
- VALID_TIERS.include?(tier)
117
+ VALID_TIERS.include?(tier.to_s)
118
118
  end
119
119
 
120
120
  # Get tier priority (0 = lowest, 4 = highest)
121
121
  def tier_priority(tier)
122
- TIER_PRIORITY[tier]
122
+ TIER_PRIORITY[tier.to_s]
123
123
  end
124
124
 
125
125
  # Compare two tiers (returns -1, 0, 1 like <=>)
@@ -154,7 +154,7 @@ module Aidp
154
154
  models = models_for_provider(provider_name)
155
155
 
156
156
  # Find all models matching tier
157
- tier_models = models.select { |_name, data| data["tier"] == tier }
157
+ tier_models = models.select { |_name, data| data["tier"] == tier.to_s }
158
158
  return nil if tier_models.empty?
159
159
 
160
160
  # Prefer newer models (higher in the list)
@@ -1394,8 +1394,9 @@ module Aidp
1394
1394
 
1395
1395
  # Execute a prompt with a specific provider
1396
1396
  def execute_with_provider(provider_type, prompt, options = {})
1397
- # Extract model from options if provided
1397
+ # Extract model and tier from options if provided
1398
1398
  model_name = options.delete(:model)
1399
+ tier = options[:tier] # Keep tier in options for provider
1399
1400
 
1400
1401
  # Create provider factory instance
1401
1402
  provider_factory = ProviderFactory.new
@@ -1414,10 +1415,11 @@ module Aidp
1414
1415
  Aidp.logger.debug("provider_manager", "Executing with provider",
1415
1416
  provider: provider_type,
1416
1417
  model: model_name,
1418
+ tier: tier,
1417
1419
  prompt_length: prompt.length)
1418
1420
 
1419
- # Execute the prompt with the provider
1420
- result = provider.send_message(prompt: prompt, session: nil)
1421
+ # Execute the prompt with the provider (pass options including tier)
1422
+ result = provider.send_message(prompt: prompt, session: nil, options: options)
1421
1423
 
1422
1424
  # Return structured result
1423
1425
  {
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Aidp
6
+ module Harness
7
+ # RubyLLMRegistry wraps the ruby_llm gem's model registry
8
+ # to provide AIDP-specific functionality while leveraging
9
+ # ruby_llm's comprehensive and actively maintained model database
10
+ class RubyLLMRegistry
11
+ class RegistryError < StandardError; end
12
+ class ModelNotFound < RegistryError; end
13
+
14
+ # Map AIDP provider names to RubyLLM provider names
15
+ # Some AIDP providers use different names than the upstream APIs
16
+ PROVIDER_NAME_MAPPING = {
17
+ "codex" => "openai", # Codex is AIDP's OpenAI adapter
18
+ "anthropic" => "anthropic",
19
+ "gemini" => "gemini", # Gemini provider name matches
20
+ "aider" => nil, # Aider aggregates multiple providers
21
+ "cursor" => nil, # Cursor has its own models
22
+ "openai" => "openai",
23
+ "google" => "gemini", # Google's API uses gemini provider name
24
+ "azure" => "bedrock", # Azure OpenAI uses bedrock in registry
25
+ "bedrock" => "bedrock",
26
+ "openrouter" => "openrouter"
27
+ }.freeze
28
+
29
+ # Tier classification based on model characteristics
30
+ # These are heuristics since ruby_llm doesn't classify tiers
31
+ TIER_CLASSIFICATION = {
32
+ # Mini tier: fast, cost-effective models
33
+ mini: ->(model) {
34
+ return true if model.id.to_s.match?(/haiku|mini|flash|small/i)
35
+
36
+ # Check pricing if available
37
+ if model.pricing
38
+ pricing_hash = model.pricing.to_h
39
+ input_cost = pricing_hash.dig(:text_tokens, :standard, :input_per_million)
40
+ return true if input_cost && input_cost < 1.0
41
+ end
42
+ false
43
+ },
44
+
45
+ # Advanced tier: high-capability, expensive models
46
+ advanced: ->(model) {
47
+ return true if model.id.to_s.match?(/opus|turbo|pro|preview|o1/i)
48
+
49
+ # Check pricing if available
50
+ if model.pricing
51
+ pricing_hash = model.pricing.to_h
52
+ input_cost = pricing_hash.dig(:text_tokens, :standard, :input_per_million)
53
+ return true if input_cost && input_cost > 10.0
54
+ end
55
+ false
56
+ },
57
+
58
+ # Standard tier: everything else (default)
59
+ standard: ->(model) { true }
60
+ }.freeze
61
+
62
+ def initialize
63
+ @models = RubyLLM::Models.instance.instance_variable_get(:@models)
64
+ @index_by_id = @models.to_h { |m| [m.id, m] }
65
+
66
+ # Build family index for mapping versioned names to families
67
+ @family_index = build_family_index
68
+
69
+ Aidp.log_info("ruby_llm_registry", "initialized", models: @models.size)
70
+ end
71
+
72
+ # Resolve a model name (family or versioned) to the canonical API model
73
+ #
74
+ # @param model_name [String] Model name (e.g., "claude-3-5-haiku" or "claude-3-5-haiku-20241022")
75
+ # @param provider [String, nil] Optional AIDP provider filter
76
+ # @return [String, nil] Canonical model ID for API calls, or nil if not found
77
+ def resolve_model(model_name, provider: nil)
78
+ # Map AIDP provider to registry provider if filtering
79
+ registry_provider = provider ? PROVIDER_NAME_MAPPING[provider] : nil
80
+
81
+ # Try exact match first
82
+ model = @index_by_id[model_name]
83
+ return model.id if model && (registry_provider.nil? || model.provider.to_s == registry_provider)
84
+
85
+ # Try family mapping
86
+ family_models = @family_index[model_name]
87
+ if family_models
88
+ # Filter by provider if specified
89
+ family_models = family_models.select { |m| m.provider.to_s == registry_provider } if registry_provider
90
+
91
+ # Return the latest version (first non-"latest" model, or the latest one)
92
+ model = family_models.reject { |m| m.id.to_s.include?("-latest") }.first || family_models.first
93
+ return model.id if model
94
+ end
95
+
96
+ # Try fuzzy matching for common patterns
97
+ fuzzy_match = find_fuzzy_match(model_name, registry_provider)
98
+ return fuzzy_match.id if fuzzy_match
99
+
100
+ Aidp.log_warn("ruby_llm_registry", "model not found", model: model_name, provider: provider)
101
+ nil
102
+ end
103
+
104
+ # Get model information
105
+ #
106
+ # @param model_id [String] The model ID
107
+ # @return [Hash, nil] Model information hash or nil if not found
108
+ def get_model_info(model_id)
109
+ model = @index_by_id[model_id]
110
+ return nil unless model
111
+
112
+ {
113
+ id: model.id,
114
+ name: model.name || model.display_name,
115
+ provider: model.provider.to_s,
116
+ tier: classify_tier(model),
117
+ context_window: model.context_window,
118
+ capabilities: extract_capabilities(model),
119
+ pricing: model.pricing
120
+ }
121
+ end
122
+
123
+ # Get all models for a specific tier
124
+ #
125
+ # @param tier [String, Symbol] The tier name (mini, standard, advanced)
126
+ # @param provider [String, nil] Optional AIDP provider filter
127
+ # @return [Array<String>] List of model IDs for the tier
128
+ def models_for_tier(tier, provider: nil)
129
+ tier_sym = tier.to_sym
130
+ classifier = TIER_CLASSIFICATION[tier_sym]
131
+
132
+ unless classifier
133
+ Aidp.log_warn("ruby_llm_registry", "invalid tier", tier: tier)
134
+ return []
135
+ end
136
+
137
+ # Map AIDP provider to registry provider if filtering
138
+ registry_provider = provider ? PROVIDER_NAME_MAPPING[provider] : nil
139
+ return [] if provider && registry_provider.nil?
140
+
141
+ models = @models.select do |model|
142
+ (registry_provider.nil? || model.provider.to_s == registry_provider) &&
143
+ classifier.call(model)
144
+ end
145
+
146
+ # For mini and standard tiers, exclude if advanced classification matches
147
+ if tier_sym == :mini
148
+ models.reject! { |m| TIER_CLASSIFICATION[:advanced].call(m) }
149
+ elsif tier_sym == :standard
150
+ models.reject! do |m|
151
+ TIER_CLASSIFICATION[:mini].call(m) || TIER_CLASSIFICATION[:advanced].call(m)
152
+ end
153
+ end
154
+
155
+ model_ids = models.map(&:id).uniq
156
+ Aidp.log_debug("ruby_llm_registry", "found models for tier",
157
+ tier: tier, provider: provider, count: model_ids.size)
158
+ model_ids
159
+ end
160
+
161
+ # Get all models for a provider
162
+ #
163
+ # @param provider [String] The AIDP provider name
164
+ # @return [Array<String>] List of model IDs
165
+ def models_for_provider(provider)
166
+ # Map AIDP provider name to RubyLLM provider name
167
+ registry_provider = PROVIDER_NAME_MAPPING[provider]
168
+
169
+ # Return empty if provider doesn't map to a registry provider
170
+ return [] if registry_provider.nil?
171
+
172
+ @models.select { |m| m.provider.to_s == registry_provider }.map(&:id)
173
+ end
174
+
175
+ # Classify a model's tier
176
+ #
177
+ # @param model [RubyLLM::Model::Info] The model info object
178
+ # @return [String] The tier name (mini, standard, advanced)
179
+ def classify_tier(model)
180
+ return "advanced" if TIER_CLASSIFICATION[:advanced].call(model)
181
+ return "mini" if TIER_CLASSIFICATION[:mini].call(model)
182
+ "standard"
183
+ end
184
+
185
+ # Refresh the model registry from ruby_llm
186
+ def refresh!
187
+ RubyLLM::Models.refresh!
188
+ @models = RubyLLM::Models.instance.instance_variable_get(:@models)
189
+ @index_by_id = @models.to_h { |m| [m.id, m] }
190
+ @family_index = build_family_index
191
+ Aidp.log_info("ruby_llm_registry", "refreshed", models: @models.size)
192
+ end
193
+
194
+ private
195
+
196
+ # Build an index mapping family names to model objects
197
+ # Family name is model ID with version suffix removed
198
+ def build_family_index
199
+ index = Hash.new { |h, k| h[k] = [] }
200
+
201
+ @models.each do |model|
202
+ # Remove date suffix (e.g., "claude-3-5-haiku-20241022" -> "claude-3-5-haiku")
203
+ family = model.id.to_s.sub(/-\d{8}$/, "").sub(/-latest$/, "")
204
+ index[family] << model unless family == model.id.to_s
205
+ end
206
+
207
+ index
208
+ end
209
+
210
+ # Find a model by fuzzy matching
211
+ def find_fuzzy_match(model_name, provider)
212
+ # Normalize the search term
213
+ normalized = model_name.downcase.gsub(/[^a-z0-9]/, "")
214
+
215
+ candidates = @models.select do |m|
216
+ next false if provider && m.provider.to_s != provider
217
+
218
+ # Check if model ID contains the search term
219
+ m.id.to_s.downcase.gsub(/[^a-z0-9]/, "").include?(normalized) ||
220
+ m.name.to_s.downcase.gsub(/[^a-z0-9]/, "").include?(normalized)
221
+ end
222
+
223
+ # Prefer shorter matches (more specific)
224
+ candidates.min_by { |m| m.id.to_s.length }
225
+ end
226
+
227
+ # Extract capabilities from model info
228
+ def extract_capabilities(model)
229
+ caps = []
230
+ caps << "chat" if model.capabilities.include?(:chat)
231
+ caps << "code" if model.capabilities.include?(:code) || model.id.to_s.include?("code")
232
+ caps << "vision" if model.capabilities.include?(:vision)
233
+ caps << "tool_use" if model.capabilities.include?(:function_calling) || model.capabilities.include?(:tools)
234
+ caps << "streaming" if model.capabilities.include?(:streaming)
235
+ caps
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "../errors"
6
+ require_relative "scanner"
7
+ require_relative "compiler"
8
+
9
+ module Aidp
10
+ module Metadata
11
+ # Manages cached tool directory with automatic invalidation
12
+ #
13
+ # Loads compiled tool directory from cache, checks for file changes,
14
+ # and regenerates cache when needed.
15
+ #
16
+ # @example Loading from cache
17
+ # cache = Cache.new(
18
+ # cache_path: ".aidp/cache/tool_directory.json",
19
+ # directories: [".aidp/skills", ".aidp/templates"]
20
+ # )
21
+ # directory = cache.load
22
+ class Cache
23
+ # Default cache TTL (24 hours)
24
+ DEFAULT_TTL = 86400
25
+
26
+ # Initialize cache
27
+ #
28
+ # @param cache_path [String] Path to cache file
29
+ # @param directories [Array<String>] Directories to monitor
30
+ # @param ttl [Integer] Cache TTL in seconds (default: 24 hours)
31
+ # @param strict [Boolean] Whether to fail on validation errors
32
+ def initialize(cache_path:, directories: [], ttl: DEFAULT_TTL, strict: false)
33
+ @cache_path = cache_path
34
+ @directories = Array(directories)
35
+ @ttl = ttl
36
+ @strict = strict
37
+ @file_hashes_path = "#{cache_path}.hashes"
38
+ end
39
+
40
+ # Load tool directory from cache or regenerate
41
+ #
42
+ # @return [Hash] Tool directory structure
43
+ def load
44
+ Aidp.log_debug("metadata", "Loading cache", path: @cache_path)
45
+
46
+ if cache_valid?
47
+ Aidp.log_debug("metadata", "Using cached directory")
48
+ load_from_cache
49
+ else
50
+ Aidp.log_info("metadata", "Cache invalid, regenerating")
51
+ regenerate
52
+ end
53
+ end
54
+
55
+ # Regenerate cache from source files
56
+ #
57
+ # @return [Hash] Tool directory structure
58
+ def regenerate
59
+ Aidp.log_info("metadata", "Regenerating tool directory", directories: @directories)
60
+
61
+ # Compile directory
62
+ compiler = Compiler.new(directories: @directories, strict: @strict)
63
+ directory = compiler.compile(output_path: @cache_path)
64
+
65
+ # Save file hashes for change detection
66
+ save_file_hashes
67
+
68
+ directory
69
+ end
70
+
71
+ # Force reload cache
72
+ #
73
+ # @return [Hash] Tool directory structure
74
+ def reload
75
+ Aidp.log_info("metadata", "Force reloading cache")
76
+ regenerate
77
+ end
78
+
79
+ # Check if cache is valid
80
+ #
81
+ # @return [Boolean] True if cache exists and is not stale
82
+ def cache_valid?
83
+ return false unless File.exist?(@cache_path)
84
+ return false if cache_expired?
85
+ return false if files_changed?
86
+
87
+ true
88
+ end
89
+
90
+ # Check if cache has expired based on TTL
91
+ #
92
+ # @return [Boolean] True if cache is expired
93
+ def cache_expired?
94
+ return true unless File.exist?(@cache_path)
95
+
96
+ cache_age = Time.now - File.mtime(@cache_path)
97
+ expired = cache_age > @ttl
98
+
99
+ if expired
100
+ Aidp.log_debug(
101
+ "metadata",
102
+ "Cache expired",
103
+ age_seconds: cache_age.to_i,
104
+ ttl: @ttl
105
+ )
106
+ end
107
+
108
+ expired
109
+ end
110
+
111
+ # Check if source files have changed
112
+ #
113
+ # @return [Boolean] True if any source files have changed
114
+ def files_changed?
115
+ previous_hashes = load_file_hashes
116
+ current_hashes = compute_current_hashes
117
+
118
+ changed = previous_hashes != current_hashes
119
+
120
+ if changed
121
+ Aidp.log_debug(
122
+ "metadata",
123
+ "Source files changed",
124
+ previous_count: previous_hashes.size,
125
+ current_count: current_hashes.size
126
+ )
127
+ end
128
+
129
+ changed
130
+ end
131
+
132
+ # Load directory from cache file
133
+ #
134
+ # @return [Hash] Cached directory structure
135
+ # @raise [Aidp::Errors::ConfigurationError] if cache is invalid
136
+ def load_from_cache
137
+ content = File.read(@cache_path, encoding: "UTF-8")
138
+ directory = JSON.parse(content)
139
+
140
+ Aidp.log_debug(
141
+ "metadata",
142
+ "Loaded from cache",
143
+ tools: directory["statistics"]["total_tools"],
144
+ compiled_at: directory["compiled_at"]
145
+ )
146
+
147
+ directory
148
+ rescue JSON::ParserError => e
149
+ Aidp.log_error("metadata", "Invalid cache JSON", error: e.message)
150
+ raise Aidp::Errors::ConfigurationError, "Invalid tool directory cache: #{e.message}"
151
+ end
152
+
153
+ # Compute current file hashes for all source files
154
+ #
155
+ # @return [Hash<String, String>] Map of file_path => file_hash
156
+ def compute_current_hashes
157
+ hashes = {}
158
+
159
+ @directories.each do |dir|
160
+ next unless Dir.exist?(dir)
161
+
162
+ scanner = Scanner.new([dir])
163
+ md_files = scanner.find_markdown_files(dir)
164
+
165
+ md_files.each do |file_path|
166
+ content = File.read(file_path, encoding: "UTF-8")
167
+ hashes[file_path] = Parser.compute_file_hash(content)
168
+ end
169
+ end
170
+
171
+ hashes
172
+ end
173
+
174
+ # Load saved file hashes
175
+ #
176
+ # @return [Hash<String, String>] Saved file hashes
177
+ def load_file_hashes
178
+ return {} unless File.exist?(@file_hashes_path)
179
+
180
+ content = File.read(@file_hashes_path, encoding: "UTF-8")
181
+ JSON.parse(content)
182
+ rescue JSON::ParserError
183
+ Aidp.log_warn("metadata", "Invalid file hashes cache, regenerating")
184
+ {}
185
+ end
186
+
187
+ # Save current file hashes
188
+ def save_file_hashes
189
+ hashes = compute_current_hashes
190
+
191
+ # Ensure directory exists
192
+ dir = File.dirname(@file_hashes_path)
193
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
194
+
195
+ File.write(@file_hashes_path, JSON.pretty_generate(hashes))
196
+
197
+ Aidp.log_debug("metadata", "Saved file hashes", count: hashes.size, path: @file_hashes_path)
198
+ end
199
+ end
200
+ end
201
+ end