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.
- checksums.yaml +4 -4
- data/lib/aidp/cli/models_command.rb +75 -117
- data/lib/aidp/cli/tools_command.rb +333 -0
- data/lib/aidp/cli.rb +8 -1
- data/lib/aidp/config.rb +9 -0
- data/lib/aidp/execute/work_loop_runner.rb +6 -3
- data/lib/aidp/harness/capability_registry.rb +4 -4
- data/lib/aidp/harness/provider_manager.rb +5 -3
- data/lib/aidp/harness/ruby_llm_registry.rb +239 -0
- data/lib/aidp/metadata/cache.rb +201 -0
- data/lib/aidp/metadata/compiler.rb +229 -0
- data/lib/aidp/metadata/parser.rb +204 -0
- data/lib/aidp/metadata/query.rb +237 -0
- data/lib/aidp/metadata/scanner.rb +191 -0
- data/lib/aidp/metadata/tool_metadata.rb +245 -0
- data/lib/aidp/metadata/validator.rb +187 -0
- data/lib/aidp/providers/aider.rb +1 -1
- data/lib/aidp/providers/anthropic.rb +6 -4
- data/lib/aidp/providers/base.rb +105 -35
- data/lib/aidp/providers/codex.rb +1 -1
- data/lib/aidp/providers/cursor.rb +1 -1
- data/lib/aidp/providers/gemini.rb +1 -1
- data/lib/aidp/providers/github_copilot.rb +1 -1
- data/lib/aidp/providers/kilocode.rb +1 -1
- data/lib/aidp/providers/opencode.rb +1 -1
- data/lib/aidp/setup/wizard.rb +35 -107
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp.rb +11 -0
- data/templates/implementation/implement_features.md +4 -1
- metadata +24 -2
- data/lib/aidp/harness/model_discovery_service.rb +0 -259
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
|