aidp 0.25.0 → 0.27.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -6
  3. data/lib/aidp/analyze/error_handler.rb +11 -0
  4. data/lib/aidp/cli/checkpoint_command.rb +198 -0
  5. data/lib/aidp/cli/config_command.rb +71 -0
  6. data/lib/aidp/cli/enhanced_input.rb +2 -0
  7. data/lib/aidp/cli/first_run_wizard.rb +8 -7
  8. data/lib/aidp/cli/harness_command.rb +102 -0
  9. data/lib/aidp/cli/jobs_command.rb +3 -3
  10. data/lib/aidp/cli/mcp_dashboard.rb +4 -3
  11. data/lib/aidp/cli/models_command.rb +662 -0
  12. data/lib/aidp/cli/providers_command.rb +223 -0
  13. data/lib/aidp/cli.rb +35 -456
  14. data/lib/aidp/daemon/runner.rb +2 -2
  15. data/lib/aidp/debug_mixin.rb +2 -9
  16. data/lib/aidp/execute/async_work_loop_runner.rb +2 -1
  17. data/lib/aidp/execute/checkpoint_display.rb +38 -37
  18. data/lib/aidp/execute/interactive_repl.rb +2 -1
  19. data/lib/aidp/execute/prompt_manager.rb +4 -4
  20. data/lib/aidp/execute/work_loop_runner.rb +253 -56
  21. data/lib/aidp/execute/workflow_selector.rb +2 -2
  22. data/lib/aidp/harness/config_loader.rb +20 -11
  23. data/lib/aidp/harness/config_manager.rb +5 -5
  24. data/lib/aidp/harness/config_schema.rb +30 -8
  25. data/lib/aidp/harness/configuration.rb +105 -4
  26. data/lib/aidp/harness/enhanced_runner.rb +24 -15
  27. data/lib/aidp/harness/error_handler.rb +26 -5
  28. data/lib/aidp/harness/filter_strategy.rb +45 -0
  29. data/lib/aidp/harness/generic_filter_strategy.rb +63 -0
  30. data/lib/aidp/harness/model_cache.rb +269 -0
  31. data/lib/aidp/harness/model_discovery_service.rb +259 -0
  32. data/lib/aidp/harness/model_registry.rb +201 -0
  33. data/lib/aidp/harness/output_filter.rb +136 -0
  34. data/lib/aidp/harness/provider_manager.rb +18 -3
  35. data/lib/aidp/harness/rspec_filter_strategy.rb +82 -0
  36. data/lib/aidp/harness/runner.rb +5 -0
  37. data/lib/aidp/harness/test_runner.rb +165 -27
  38. data/lib/aidp/harness/thinking_depth_manager.rb +223 -7
  39. data/lib/aidp/harness/ui/enhanced_tui.rb +4 -1
  40. data/lib/aidp/logger.rb +35 -5
  41. data/lib/aidp/providers/adapter.rb +2 -4
  42. data/lib/aidp/providers/anthropic.rb +141 -128
  43. data/lib/aidp/providers/base.rb +98 -2
  44. data/lib/aidp/providers/capability_registry.rb +0 -1
  45. data/lib/aidp/providers/codex.rb +49 -67
  46. data/lib/aidp/providers/cursor.rb +71 -59
  47. data/lib/aidp/providers/gemini.rb +44 -60
  48. data/lib/aidp/providers/github_copilot.rb +2 -66
  49. data/lib/aidp/providers/kilocode.rb +24 -80
  50. data/lib/aidp/providers/opencode.rb +24 -80
  51. data/lib/aidp/safe_directory.rb +10 -3
  52. data/lib/aidp/setup/wizard.rb +345 -8
  53. data/lib/aidp/storage/csv_storage.rb +9 -3
  54. data/lib/aidp/storage/file_manager.rb +8 -2
  55. data/lib/aidp/storage/json_storage.rb +9 -3
  56. data/lib/aidp/version.rb +1 -1
  57. data/lib/aidp/watch/build_processor.rb +40 -1
  58. data/lib/aidp/watch/change_request_processor.rb +659 -0
  59. data/lib/aidp/watch/plan_generator.rb +93 -14
  60. data/lib/aidp/watch/plan_processor.rb +71 -8
  61. data/lib/aidp/watch/repository_client.rb +85 -20
  62. data/lib/aidp/watch/review_processor.rb +3 -3
  63. data/lib/aidp/watch/runner.rb +37 -0
  64. data/lib/aidp/watch/state_store.rb +46 -1
  65. data/lib/aidp/workflows/guided_agent.rb +3 -3
  66. data/lib/aidp/workstream_executor.rb +5 -2
  67. data/lib/aidp.rb +4 -0
  68. data/templates/aidp-development.yml.example +2 -2
  69. data/templates/aidp-production.yml.example +3 -3
  70. data/templates/aidp.yml.example +53 -0
  71. metadata +14 -1
@@ -168,12 +168,47 @@ module Aidp
168
168
 
169
169
  # Get test commands
170
170
  def test_commands
171
- work_loop_config[:test_commands] || []
171
+ normalize_commands(work_loop_config[:test_commands] || [])
172
172
  end
173
173
 
174
174
  # Get lint commands
175
175
  def lint_commands
176
- work_loop_config[:lint_commands] || []
176
+ normalize_commands(work_loop_config[:lint_commands] || [])
177
+ end
178
+
179
+ # Get formatter commands
180
+ def formatter_commands
181
+ normalize_commands(work_loop_config[:formatter_commands] || [])
182
+ end
183
+
184
+ # Get build commands
185
+ def build_commands
186
+ normalize_commands(work_loop_config[:build_commands] || [])
187
+ end
188
+
189
+ # Get documentation commands
190
+ def documentation_commands
191
+ normalize_commands(work_loop_config[:documentation_commands] || [])
192
+ end
193
+
194
+ # Get test output mode
195
+ def test_output_mode
196
+ work_loop_config.dig(:test, :output_mode) || :full
197
+ end
198
+
199
+ # Get max output lines for tests
200
+ def test_max_output_lines
201
+ work_loop_config.dig(:test, :max_output_lines) || 500
202
+ end
203
+
204
+ # Get lint output mode
205
+ def lint_output_mode
206
+ work_loop_config.dig(:lint, :output_mode) || :full
207
+ end
208
+
209
+ # Get max output lines for linters
210
+ def lint_max_output_lines
211
+ work_loop_config.dig(:lint, :max_output_lines) || 300
177
212
  end
178
213
 
179
214
  # Get guards configuration
@@ -348,12 +383,12 @@ module Aidp
348
383
 
349
384
  # Get default thinking tier
350
385
  def default_tier
351
- thinking_config[:default_tier] || "standard"
386
+ thinking_config[:default_tier] || default_thinking_config[:default_tier]
352
387
  end
353
388
 
354
389
  # Get maximum thinking tier
355
390
  def max_tier
356
- thinking_config[:max_tier] || "standard"
391
+ thinking_config[:max_tier] || default_thinking_config[:max_tier]
357
392
  end
358
393
 
359
394
  # Check if provider switching for tier is allowed
@@ -397,6 +432,36 @@ module Aidp
397
432
  thinking_overrides[key] || thinking_overrides[key.to_sym]
398
433
  end
399
434
 
435
+ # Get tiers configuration from user's config
436
+ def thinking_tiers_config
437
+ thinking_config[:tiers] || {}
438
+ end
439
+
440
+ # Get models configured for a specific tier
441
+ # Returns array of {provider:, model:} hashes
442
+ def models_for_tier(tier)
443
+ tier_config = thinking_tiers_config[tier] || thinking_tiers_config[tier.to_sym]
444
+ return [] unless tier_config
445
+
446
+ models = tier_config[:models] || tier_config["models"]
447
+ return [] unless models
448
+
449
+ # Normalize to array of hashes with symbol keys
450
+ Array(models).map do |model_entry|
451
+ if model_entry.is_a?(Hash)
452
+ {
453
+ provider: (model_entry[:provider] || model_entry["provider"]).to_s,
454
+ model: (model_entry[:model] || model_entry["model"]).to_s
455
+ }
456
+ end
457
+ end.compact
458
+ end
459
+
460
+ # Get all configured tiers
461
+ def configured_tiers
462
+ thinking_tiers_config.keys.map(&:to_s)
463
+ end
464
+
400
465
  # Get fallback configuration
401
466
  def fallback_config
402
467
  harness_config[:fallback] || default_fallback_config
@@ -704,6 +769,42 @@ module Aidp
704
769
 
705
770
  private
706
771
 
772
+ # Normalize command configuration to consistent format
773
+ # Supports both string format and object format with required flag
774
+ # Examples:
775
+ # "bundle exec rspec" -> {command: "bundle exec rspec", required: true}
776
+ # {command: "rubocop", required: false} -> {command: "rubocop", required: false}
777
+ def normalize_commands(commands)
778
+ return [] if commands.nil? || commands.empty?
779
+
780
+ commands.map do |cmd|
781
+ case cmd
782
+ when String
783
+ {command: cmd, required: true}
784
+ when Hash
785
+ # Handle both symbol and string keys
786
+ command_value = cmd[:command] || cmd["command"]
787
+ required_value = if cmd.key?(:required)
788
+ cmd[:required]
789
+ else
790
+ (cmd.key?("required") ? cmd["required"] : true)
791
+ end
792
+
793
+ unless command_value.is_a?(String) && !command_value.empty?
794
+ raise ConfigurationError, "Command must be a non-empty string, got: #{command_value.inspect}"
795
+ end
796
+
797
+ unless [true, false].include?(required_value)
798
+ raise ConfigurationError, "Required flag must be boolean, got: #{required_value.inspect}"
799
+ end
800
+
801
+ {command: command_value, required: required_value}
802
+ else
803
+ raise ConfigurationError, "Command must be a string or hash, got: #{cmd.class}"
804
+ end
805
+ end
806
+ end
807
+
707
808
  def validate_configuration!
708
809
  errors = Aidp::Config.validate_harness_config(@config, @project_dir)
709
810
 
@@ -6,6 +6,7 @@ require_relative "ui/job_monitor"
6
6
  require_relative "ui/workflow_controller"
7
7
  require_relative "ui/progress_display"
8
8
  require_relative "ui/status_widget"
9
+ require_relative "../errors"
9
10
 
10
11
  module Aidp
11
12
  module Harness
@@ -47,27 +48,31 @@ module Aidp
47
48
  @selected_steps = options[:selected_steps] || []
48
49
  @workflow_type = options[:workflow_type] || :default
49
50
 
50
- # Initialize enhanced TUI components
51
- @tui = UI::EnhancedTUI.new
52
- @workflow_selector = UI::EnhancedWorkflowSelector.new(@tui)
53
- @job_monitor = UI::JobMonitor.new
54
- @workflow_controller = UI::WorkflowController.new
55
- @progress_display = UI::ProgressDisplay.new
56
- @status_widget = UI::StatusWidget.new
51
+ # Initialize enhanced TUI components (with dependency injection)
52
+ @tui = options[:tui] || UI::EnhancedTUI.new
53
+ @workflow_selector = options[:workflow_selector] || UI::EnhancedWorkflowSelector.new(@tui)
54
+ @job_monitor = options[:job_monitor] || UI::JobMonitor.new
55
+ @workflow_controller = options[:workflow_controller] || UI::WorkflowController.new
56
+ @progress_display = options[:progress_display] || UI::ProgressDisplay.new
57
+ @status_widget = options[:status_widget] || UI::StatusWidget.new
57
58
 
58
- # Initialize other components
59
- @configuration = Configuration.new(project_dir)
60
- @state_manager = StateManager.new(project_dir, @mode)
61
- @provider_manager = ProviderManager.new(@configuration, prompt: @prompt)
59
+ # Initialize other components (with dependency injection)
60
+ @configuration = options[:configuration] || Configuration.new(project_dir)
61
+ @state_manager = options[:state_manager] || StateManager.new(project_dir, @mode)
62
+ @provider_manager = options[:provider_manager] || ProviderManager.new(@configuration, prompt: @prompt)
62
63
 
63
64
  # Use ZFC-enabled condition detector
64
65
  # ZfcConditionDetector will create its own ProviderFactory if needed
65
66
  # Falls back to legacy pattern matching when ZFC is disabled
66
- require_relative "zfc_condition_detector"
67
- @condition_detector = ZfcConditionDetector.new(@configuration)
67
+ if options[:condition_detector]
68
+ @condition_detector = options[:condition_detector]
69
+ else
70
+ require_relative "zfc_condition_detector"
71
+ @condition_detector = ZfcConditionDetector.new(@configuration)
72
+ end
68
73
 
69
- @error_handler = ErrorHandler.new(@provider_manager, @configuration)
70
- @completion_checker = CompletionChecker.new(@project_dir, @workflow_type)
74
+ @error_handler = options[:error_handler] || ErrorHandler.new(@provider_manager, @configuration)
75
+ @completion_checker = options[:completion_checker] || CompletionChecker.new(@project_dir, @workflow_type)
71
76
  end
72
77
 
73
78
  # Get current provider (delegate to provider manager)
@@ -158,6 +163,10 @@ module Aidp
158
163
  handle_completion_criteria_not_met(completion_status)
159
164
  end
160
165
  end
166
+ rescue Aidp::Errors::ConfigurationError
167
+ # Configuration errors should crash immediately (crash-early principle)
168
+ # Re-raise without catching
169
+ raise
161
170
  rescue => e
162
171
  @state = STATES[:error]
163
172
  # Single error message - don't duplicate
@@ -4,6 +4,7 @@ require "net/http"
4
4
  require_relative "../debug_mixin"
5
5
  require_relative "../concurrency"
6
6
  require_relative "../providers/error_taxonomy"
7
+ require_relative "../errors"
7
8
 
8
9
  module Aidp
9
10
  module Harness
@@ -107,6 +108,10 @@ module Aidp
107
108
  begin
108
109
  attempt += 1
109
110
  return yield
111
+ rescue Aidp::Errors::ConfigurationError
112
+ # Configuration errors should crash immediately (crash-early principle)
113
+ # Re-raise without catching
114
+ raise
110
115
  rescue => error
111
116
  current_provider = current_provider_safely
112
117
 
@@ -483,7 +488,7 @@ module Aidp
483
488
  }
484
489
  end
485
490
 
486
- def attempt_provider_switch(error_info, _recovery_plan)
491
+ def attempt_provider_switch(error_info, recovery_plan)
487
492
  new_provider = @provider_manager.switch_provider_for_error(
488
493
  error_info[:error_type],
489
494
  error_info[:context]
@@ -497,6 +502,18 @@ module Aidp
497
502
  reason: "Error recovery: #{error_info[:error_type]}"
498
503
  }
499
504
  else
505
+ # If this is an auth error and we have no fallback providers, crash
506
+ if recovery_plan[:crash_if_no_fallback]
507
+ error_msg = "All providers have failed authentication.\n\n" \
508
+ "Last provider: #{error_info[:provider]}\n" \
509
+ "Error: #{error_info[:error]&.message || error_info[:error]}\n\n" \
510
+ "Please check your API credentials for all configured providers.\n" \
511
+ "Run 'aidp config --interactive' to update credentials."
512
+
513
+ raise Aidp::Errors::ConfigurationError, error_msg
514
+ end
515
+
516
+ # For non-auth errors, return failure result
500
517
  {
501
518
  success: false,
502
519
  action: :provider_switch_failed,
@@ -680,12 +697,13 @@ module Aidp
680
697
  priority: :high
681
698
  }
682
699
  when :auth_expired
683
- # Attempt a provider switch so workflows can continue with alternate providers
684
- # while the user resolves credentials for the failing provider
700
+ # Try to switch to another provider. If no providers available, this will
701
+ # be detected in attempt_recovery and we'll crash (crash-early principle)
685
702
  {
686
703
  action: :switch_provider,
687
704
  reason: "Authentication expired – switching provider to continue",
688
- priority: :critical
705
+ priority: :critical,
706
+ crash_if_no_fallback: true
689
707
  }
690
708
  when :quota_exceeded
691
709
  {
@@ -725,10 +743,13 @@ module Aidp
725
743
  priority: :medium
726
744
  }
727
745
  when :authentication, :permission_denied
746
+ # Try to switch to another provider. If no providers available, this will
747
+ # be detected in attempt_recovery and we'll crash (crash-early principle)
728
748
  {
729
749
  action: :switch_provider,
730
750
  reason: "Authentication/permission issue – switching provider to continue",
731
- priority: :critical
751
+ priority: :critical,
752
+ crash_if_no_fallback: true
732
753
  }
733
754
  when :rate_limit
734
755
  {
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Harness
5
+ # Base class for framework-specific filtering strategies
6
+ class FilterStrategy
7
+ # @param output [String] Raw output
8
+ # @param filter [OutputFilter] Filter instance for config access
9
+ # @return [String] Filtered output
10
+ def filter(output, filter_instance)
11
+ raise NotImplementedError, "Subclasses must implement #filter"
12
+ end
13
+
14
+ protected
15
+
16
+ # Extract lines around a match (for context)
17
+ def extract_with_context(lines, index, context_lines)
18
+ start_idx = [0, index - context_lines].max
19
+ end_idx = [lines.length - 1, index + context_lines].min
20
+
21
+ lines[start_idx..end_idx]
22
+ end
23
+
24
+ # Find failure markers in output
25
+ def find_failure_markers(output)
26
+ lines = output.lines
27
+ markers = []
28
+
29
+ lines.each_with_index do |line, index|
30
+ # Check for failure patterns using safe string methods
31
+ if line.match?(/FAILED/i) ||
32
+ line.match?(/ERROR/i) ||
33
+ line.match?(/FAIL:/i) ||
34
+ line.match?(/failures?:/i) ||
35
+ line.match?(/^\s*\d{1,4}\)\s/) || # Numbered failures (limit digits to prevent ReDoS)
36
+ line.include?(") ") # Additional simple check for numbered patterns
37
+ markers << index
38
+ end
39
+ end
40
+
41
+ markers
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "filter_strategy"
4
+
5
+ module Aidp
6
+ module Harness
7
+ # Generic filtering for unknown frameworks
8
+ class GenericFilterStrategy < FilterStrategy
9
+ def filter(output, filter_instance)
10
+ case filter_instance.mode
11
+ when :failures_only
12
+ extract_failure_lines(output, filter_instance)
13
+ when :minimal
14
+ extract_summary(output, filter_instance)
15
+ else
16
+ output
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def extract_failure_lines(output, filter_instance)
23
+ lines = output.lines
24
+ failure_indices = find_failure_markers(output)
25
+
26
+ return output if failure_indices.empty?
27
+
28
+ # Extract failures with context
29
+ relevant_lines = Set.new
30
+ failure_indices.each do |index|
31
+ if filter_instance.include_context
32
+ extract_with_context(lines, index, filter_instance.context_lines)
33
+ start_idx = [0, index - filter_instance.context_lines].max
34
+ end_idx = [lines.length - 1, index + filter_instance.context_lines].min
35
+ (start_idx..end_idx).each { |idx| relevant_lines.add(idx) }
36
+ else
37
+ relevant_lines.add(index)
38
+ end
39
+ end
40
+
41
+ selected = relevant_lines.to_a.sort.map { |idx| lines[idx] }
42
+ selected.join
43
+ end
44
+
45
+ def extract_summary(output, filter_instance)
46
+ lines = output.lines
47
+
48
+ # Take first line, last line, and any lines with numbers/statistics
49
+ parts = []
50
+ parts << lines.first if lines.first
51
+
52
+ summary_lines = lines.select do |line|
53
+ line.match?(/\d+/) || line.match?(/summary|total|passed|failed/i)
54
+ end
55
+
56
+ parts.concat(summary_lines.uniq)
57
+ parts << lines.last if lines.last && !parts.include?(lines.last)
58
+
59
+ parts.join("\n")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Aidp
7
+ module Harness
8
+ # Manages caching of discovered models with TTL support
9
+ #
10
+ # Cache is stored in ~/.aidp/cache/models.json
11
+ # Each provider's models are cached separately with timestamps
12
+ #
13
+ # Usage:
14
+ # cache = ModelCache.new
15
+ # cache.cache_models("anthropic", models_array)
16
+ # cached = cache.get_cached_models("anthropic")
17
+ # cache.invalidate("anthropic")
18
+ class ModelCache
19
+ class CacheError < StandardError; end
20
+
21
+ DEFAULT_TTL = 86400 # 24 hours in seconds
22
+
23
+ attr_reader :cache_file
24
+
25
+ def initialize(cache_file: nil, cache_dir: nil)
26
+ @cache_file = determine_cache_file(cache_file, cache_dir)
27
+ @cache_enabled = ensure_cache_directory
28
+
29
+ if @cache_enabled
30
+ Aidp.log_debug("model_cache", "initialized", cache_file: @cache_file)
31
+ else
32
+ Aidp.log_warn("model_cache", "cache disabled due to permission issues")
33
+ end
34
+ end
35
+
36
+ # Get cached models for a provider if not expired
37
+ #
38
+ # @param provider [String] Provider name
39
+ # @return [Array<Hash>, nil] Cached models or nil if expired/not found
40
+ def get_cached_models(provider)
41
+ cache_data = load_cache
42
+ provider_cache = cache_data[provider]
43
+
44
+ return nil unless provider_cache
45
+
46
+ cached_at = begin
47
+ Time.parse(provider_cache["cached_at"])
48
+ rescue
49
+ nil
50
+ end
51
+ return nil unless cached_at
52
+
53
+ ttl = provider_cache["ttl"] || DEFAULT_TTL
54
+ expires_at = cached_at + ttl
55
+
56
+ if Time.now > expires_at
57
+ Aidp.log_debug("model_cache", "cache expired",
58
+ provider: provider, cached_at: cached_at, expires_at: expires_at)
59
+ return nil
60
+ end
61
+
62
+ models = provider_cache["models"]
63
+ # Convert string keys to symbols for consistency with fresh discovery
64
+ models = models.map { |m| m.transform_keys(&:to_sym) } if models
65
+ Aidp.log_debug("model_cache", "cache hit",
66
+ provider: provider, count: models&.size || 0)
67
+ models
68
+ rescue => e
69
+ Aidp.log_error("model_cache", "failed to read cache",
70
+ provider: provider, error: e.message)
71
+ nil
72
+ end
73
+
74
+ # Cache models for a provider with TTL
75
+ #
76
+ # @param provider [String] Provider name
77
+ # @param models [Array<Hash>] Models to cache
78
+ # @param ttl [Integer] Time to live in seconds (default: 24 hours)
79
+ def cache_models(provider, models, ttl: DEFAULT_TTL)
80
+ unless @cache_enabled
81
+ Aidp.log_debug("model_cache", "caching disabled, skipping",
82
+ provider: provider)
83
+ return false
84
+ end
85
+
86
+ cache_data = load_cache
87
+
88
+ cache_data[provider] = {
89
+ "cached_at" => Time.now.iso8601,
90
+ "ttl" => ttl,
91
+ "models" => models
92
+ }
93
+
94
+ if save_cache(cache_data)
95
+ Aidp.log_info("model_cache", "cached models",
96
+ provider: provider, count: models.size, ttl: ttl)
97
+ true
98
+ else
99
+ Aidp.log_warn("model_cache", "failed to cache models",
100
+ provider: provider)
101
+ false
102
+ end
103
+ rescue => e
104
+ Aidp.log_error("model_cache", "error caching models",
105
+ provider: provider, error: e.message)
106
+ false
107
+ end
108
+
109
+ # Invalidate cache for a specific provider
110
+ #
111
+ # @param provider [String] Provider name
112
+ def invalidate(provider)
113
+ return false unless @cache_enabled
114
+
115
+ cache_data = load_cache
116
+ cache_data.delete(provider)
117
+ save_cache(cache_data)
118
+ Aidp.log_info("model_cache", "invalidated cache", provider: provider)
119
+ true
120
+ rescue => e
121
+ Aidp.log_error("model_cache", "failed to invalidate cache",
122
+ provider: provider, error: e.message)
123
+ false
124
+ end
125
+
126
+ # Invalidate all cached models
127
+ def invalidate_all
128
+ return false unless @cache_enabled
129
+
130
+ save_cache({})
131
+ Aidp.log_info("model_cache", "invalidated all caches")
132
+ true
133
+ rescue => e
134
+ Aidp.log_error("model_cache", "failed to invalidate all",
135
+ error: e.message)
136
+ false
137
+ end
138
+
139
+ # Get list of providers with cached models
140
+ #
141
+ # @return [Array<String>] Provider names with valid caches
142
+ def cached_providers
143
+ cache_data = load_cache
144
+ providers = []
145
+
146
+ cache_data.each do |provider, data|
147
+ cached_at = begin
148
+ Time.parse(data["cached_at"])
149
+ rescue
150
+ nil
151
+ end
152
+ next unless cached_at
153
+
154
+ ttl = data["ttl"] || DEFAULT_TTL
155
+ expires_at = cached_at + ttl
156
+
157
+ providers << provider if Time.now <= expires_at
158
+ end
159
+
160
+ providers
161
+ rescue => e
162
+ Aidp.log_error("model_cache", "failed to get cached providers",
163
+ error: e.message)
164
+ []
165
+ end
166
+
167
+ # Get cache statistics
168
+ #
169
+ # @return [Hash] Statistics about the cache
170
+ def stats
171
+ cache_data = load_cache
172
+ file_size = begin
173
+ File.size(@cache_file)
174
+ rescue
175
+ 0
176
+ end
177
+
178
+ {
179
+ total_providers: cache_data.size,
180
+ cached_providers: cached_providers,
181
+ cache_file_size: file_size
182
+ }
183
+ rescue => e
184
+ Aidp.log_error("model_cache", "failed to get stats",
185
+ error: e.message)
186
+ {total_providers: 0, cached_providers: [], cache_file_size: 0}
187
+ end
188
+
189
+ private
190
+
191
+ def determine_cache_file(cache_file, cache_dir)
192
+ return cache_file if cache_file
193
+
194
+ if cache_dir
195
+ File.join(cache_dir, "models.json")
196
+ else
197
+ default_cache_file
198
+ end
199
+ end
200
+
201
+ def default_cache_file
202
+ File.join(Dir.home, ".aidp", "cache", "models.json")
203
+ rescue => e
204
+ # Fallback to temp directory if home directory is not accessible
205
+ Aidp.log_debug("model_cache", "home directory not accessible, using temp",
206
+ error: e.message)
207
+ File.join(Dir.tmpdir, "aidp_cache", "models.json")
208
+ end
209
+
210
+ def ensure_cache_directory
211
+ cache_dir = File.dirname(@cache_file)
212
+ return true if File.directory?(cache_dir)
213
+
214
+ FileUtils.mkdir_p(cache_dir)
215
+ true
216
+ rescue Errno::EACCES, Errno::EPERM => e
217
+ Aidp.log_warn("model_cache", "permission denied creating cache directory",
218
+ cache_dir: cache_dir, error: e.message)
219
+
220
+ # Try fallback to temp directory
221
+ @cache_file = File.join(Dir.tmpdir, "aidp_cache", "models.json")
222
+ fallback_dir = File.dirname(@cache_file)
223
+
224
+ begin
225
+ FileUtils.mkdir_p(fallback_dir) unless File.directory?(fallback_dir)
226
+ Aidp.log_info("model_cache", "using fallback cache directory",
227
+ cache_file: @cache_file)
228
+ true
229
+ rescue => fallback_error
230
+ Aidp.log_error("model_cache", "failed to create fallback cache directory",
231
+ error: fallback_error.message)
232
+ false
233
+ end
234
+ rescue => e
235
+ Aidp.log_error("model_cache", "failed to create cache directory",
236
+ cache_dir: cache_dir, error: e.message)
237
+ false
238
+ end
239
+
240
+ def load_cache
241
+ return {} unless File.exist?(@cache_file)
242
+
243
+ content = File.read(@cache_file)
244
+ JSON.parse(content)
245
+ rescue JSON::ParserError => e
246
+ Aidp.log_warn("model_cache", "corrupted cache file, resetting",
247
+ error: e.message)
248
+ # Reset corrupted cache
249
+ {}
250
+ rescue => e
251
+ Aidp.log_error("model_cache", "failed to load cache",
252
+ error: e.message)
253
+ {}
254
+ end
255
+
256
+ def save_cache(data)
257
+ return false unless @cache_enabled
258
+
259
+ ensure_cache_directory
260
+ File.write(@cache_file, JSON.pretty_generate(data))
261
+ true
262
+ rescue => e
263
+ Aidp.log_error("model_cache", "failed to save cache",
264
+ error: e.message, cache_file: @cache_file)
265
+ false
266
+ end
267
+ end
268
+ end
269
+ end