aidp 0.26.0 → 0.28.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +89 -0
  3. data/lib/aidp/cli/checkpoint_command.rb +198 -0
  4. data/lib/aidp/cli/config_command.rb +71 -0
  5. data/lib/aidp/cli/enhanced_input.rb +2 -0
  6. data/lib/aidp/cli/first_run_wizard.rb +8 -7
  7. data/lib/aidp/cli/harness_command.rb +102 -0
  8. data/lib/aidp/cli/jobs_command.rb +3 -3
  9. data/lib/aidp/cli/mcp_dashboard.rb +4 -3
  10. data/lib/aidp/cli/models_command.rb +661 -0
  11. data/lib/aidp/cli/providers_command.rb +223 -0
  12. data/lib/aidp/cli.rb +45 -464
  13. data/lib/aidp/config.rb +54 -0
  14. data/lib/aidp/daemon/runner.rb +2 -2
  15. data/lib/aidp/debug_mixin.rb +25 -10
  16. data/lib/aidp/execute/agent_signal_parser.rb +22 -0
  17. data/lib/aidp/execute/async_work_loop_runner.rb +2 -1
  18. data/lib/aidp/execute/checkpoint_display.rb +38 -37
  19. data/lib/aidp/execute/interactive_repl.rb +2 -1
  20. data/lib/aidp/execute/prompt_manager.rb +4 -4
  21. data/lib/aidp/execute/repl_macros.rb +2 -2
  22. data/lib/aidp/execute/steps.rb +94 -1
  23. data/lib/aidp/execute/work_loop_runner.rb +238 -19
  24. data/lib/aidp/execute/workflow_selector.rb +4 -27
  25. data/lib/aidp/firewall/provider_requirements_collector.rb +262 -0
  26. data/lib/aidp/harness/ai_decision_engine.rb +35 -2
  27. data/lib/aidp/harness/config_manager.rb +5 -10
  28. data/lib/aidp/harness/config_schema.rb +8 -0
  29. data/lib/aidp/harness/configuration.rb +40 -2
  30. data/lib/aidp/harness/enhanced_runner.rb +25 -19
  31. data/lib/aidp/harness/error_handler.rb +23 -73
  32. data/lib/aidp/harness/model_cache.rb +269 -0
  33. data/lib/aidp/harness/model_discovery_service.rb +259 -0
  34. data/lib/aidp/harness/model_registry.rb +201 -0
  35. data/lib/aidp/harness/provider_factory.rb +11 -2
  36. data/lib/aidp/harness/runner.rb +5 -0
  37. data/lib/aidp/harness/state_manager.rb +0 -7
  38. data/lib/aidp/harness/thinking_depth_manager.rb +202 -7
  39. data/lib/aidp/harness/ui/enhanced_tui.rb +8 -18
  40. data/lib/aidp/harness/ui/enhanced_workflow_selector.rb +0 -18
  41. data/lib/aidp/harness/ui/progress_display.rb +6 -2
  42. data/lib/aidp/harness/user_interface.rb +0 -58
  43. data/lib/aidp/init/runner.rb +7 -2
  44. data/lib/aidp/message_display.rb +0 -46
  45. data/lib/aidp/planning/analyzers/feedback_analyzer.rb +365 -0
  46. data/lib/aidp/planning/builders/agile_plan_builder.rb +387 -0
  47. data/lib/aidp/planning/builders/project_plan_builder.rb +193 -0
  48. data/lib/aidp/planning/generators/gantt_generator.rb +190 -0
  49. data/lib/aidp/planning/generators/iteration_plan_generator.rb +392 -0
  50. data/lib/aidp/planning/generators/legacy_research_planner.rb +473 -0
  51. data/lib/aidp/planning/generators/marketing_report_generator.rb +348 -0
  52. data/lib/aidp/planning/generators/mvp_scope_generator.rb +310 -0
  53. data/lib/aidp/planning/generators/user_test_plan_generator.rb +373 -0
  54. data/lib/aidp/planning/generators/wbs_generator.rb +259 -0
  55. data/lib/aidp/planning/mappers/persona_mapper.rb +163 -0
  56. data/lib/aidp/planning/parsers/document_parser.rb +141 -0
  57. data/lib/aidp/planning/parsers/feedback_data_parser.rb +252 -0
  58. data/lib/aidp/provider_manager.rb +8 -32
  59. data/lib/aidp/providers/adapter.rb +2 -4
  60. data/lib/aidp/providers/aider.rb +264 -0
  61. data/lib/aidp/providers/anthropic.rb +206 -121
  62. data/lib/aidp/providers/base.rb +123 -3
  63. data/lib/aidp/providers/capability_registry.rb +0 -1
  64. data/lib/aidp/providers/codex.rb +75 -70
  65. data/lib/aidp/providers/cursor.rb +87 -59
  66. data/lib/aidp/providers/gemini.rb +57 -60
  67. data/lib/aidp/providers/github_copilot.rb +19 -66
  68. data/lib/aidp/providers/kilocode.rb +35 -80
  69. data/lib/aidp/providers/opencode.rb +35 -80
  70. data/lib/aidp/setup/wizard.rb +555 -8
  71. data/lib/aidp/version.rb +1 -1
  72. data/lib/aidp/watch/build_processor.rb +211 -30
  73. data/lib/aidp/watch/change_request_processor.rb +128 -14
  74. data/lib/aidp/watch/ci_fix_processor.rb +103 -37
  75. data/lib/aidp/watch/ci_log_extractor.rb +258 -0
  76. data/lib/aidp/watch/github_state_extractor.rb +177 -0
  77. data/lib/aidp/watch/implementation_verifier.rb +284 -0
  78. data/lib/aidp/watch/plan_generator.rb +95 -52
  79. data/lib/aidp/watch/plan_processor.rb +7 -6
  80. data/lib/aidp/watch/repository_client.rb +245 -17
  81. data/lib/aidp/watch/review_processor.rb +100 -19
  82. data/lib/aidp/watch/reviewers/base_reviewer.rb +1 -1
  83. data/lib/aidp/watch/runner.rb +181 -29
  84. data/lib/aidp/watch/state_store.rb +22 -1
  85. data/lib/aidp/workflows/definitions.rb +147 -0
  86. data/lib/aidp/workflows/guided_agent.rb +3 -3
  87. data/lib/aidp/workstream_cleanup.rb +245 -0
  88. data/lib/aidp/worktree.rb +19 -0
  89. data/templates/aidp-development.yml.example +2 -2
  90. data/templates/aidp-production.yml.example +3 -3
  91. data/templates/aidp.yml.example +57 -0
  92. data/templates/implementation/generate_tdd_specs.md +213 -0
  93. data/templates/implementation/iterative_implementation.md +122 -0
  94. data/templates/planning/agile/analyze_feedback.md +183 -0
  95. data/templates/planning/agile/generate_iteration_plan.md +179 -0
  96. data/templates/planning/agile/generate_legacy_research_plan.md +171 -0
  97. data/templates/planning/agile/generate_marketing_report.md +162 -0
  98. data/templates/planning/agile/generate_mvp_scope.md +127 -0
  99. data/templates/planning/agile/generate_user_test_plan.md +143 -0
  100. data/templates/planning/agile/ingest_feedback.md +174 -0
  101. data/templates/planning/assemble_project_plan.md +113 -0
  102. data/templates/planning/assign_personas.md +108 -0
  103. data/templates/planning/create_tasks.md +52 -6
  104. data/templates/planning/generate_gantt.md +86 -0
  105. data/templates/planning/generate_wbs.md +85 -0
  106. data/templates/planning/initialize_planning_mode.md +70 -0
  107. data/templates/skills/README.md +2 -2
  108. data/templates/skills/marketing_strategist/SKILL.md +279 -0
  109. data/templates/skills/product_manager/SKILL.md +177 -0
  110. data/templates/skills/ruby_aidp_planning/SKILL.md +497 -0
  111. data/templates/skills/ruby_rspec_tdd/SKILL.md +514 -0
  112. data/templates/skills/ux_researcher/SKILL.md +222 -0
  113. metadata +47 -1
@@ -157,6 +157,36 @@ module Aidp
157
157
  },
158
158
  default_tier: "mini",
159
159
  cache_ttl: nil
160
+ },
161
+
162
+ implementation_verification: {
163
+ prompt_template: "{{prompt}}", # Custom prompt provided by caller
164
+ schema: {
165
+ type: "object",
166
+ properties: {
167
+ fully_implemented: {
168
+ type: "boolean",
169
+ description: "True if the implementation fully addresses all issue requirements"
170
+ },
171
+ reasoning: {
172
+ type: "string",
173
+ description: "Detailed explanation of the verification decision"
174
+ },
175
+ missing_requirements: {
176
+ type: "array",
177
+ items: {type: "string"},
178
+ description: "List of specific requirements from the issue that are not yet implemented"
179
+ },
180
+ additional_work_needed: {
181
+ type: "array",
182
+ items: {type: "string"},
183
+ description: "List of specific tasks needed to complete the implementation"
184
+ }
185
+ },
186
+ required: ["fully_implemented", "reasoning", "missing_requirements", "additional_work_needed"]
187
+ },
188
+ default_tier: "mini",
189
+ cache_ttl: nil
160
190
  }
161
191
  }.freeze
162
192
 
@@ -204,9 +234,12 @@ module Aidp
204
234
  # Select tier
205
235
  selected_tier = tier || template[:default_tier]
206
236
 
207
- # Get model for tier
237
+ # Get model for tier, using harness default provider
208
238
  thinking_manager = ThinkingDepthManager.new(config)
209
- provider_name, model_name, _model_data = thinking_manager.select_model_for_tier(selected_tier)
239
+ provider_name, model_name, _model_data = thinking_manager.select_model_for_tier(
240
+ selected_tier,
241
+ provider: config.default_provider
242
+ )
210
243
 
211
244
  Aidp.log_debug("ai_decision_engine", "Making AI decision", {
212
245
  decision_type: decision_type,
@@ -103,11 +103,6 @@ module Aidp
103
103
  }
104
104
  end
105
105
 
106
- # Get max retries (alias for backward compatibility with ErrorHandler)
107
- def max_retries(options = {})
108
- retry_config(options)[:max_attempts]
109
- end
110
-
111
106
  # Get circuit breaker configuration
112
107
  def circuit_breaker_config(options = {})
113
108
  harness_config = harness_config(options)
@@ -359,19 +354,19 @@ module Aidp
359
354
  def load_config_with_options(options)
360
355
  # Apply different loading strategies based on options
361
356
  if options[:mode]
362
- @loader.get_mode_config(options[:mode], options[:force_reload])
357
+ @loader.mode_config(options[:mode], options[:force_reload])
363
358
  elsif options[:environment]
364
- @loader.get_environment_config(options[:environment], options[:force_reload])
359
+ @loader.environment_config(options[:environment], options[:force_reload])
365
360
  elsif options[:step]
366
361
  @loader.get_step_config(options[:step], options[:force_reload])
367
362
  elsif options[:features]
368
- @loader.get_config_with_features(options[:features], options[:force_reload])
363
+ @loader.config_with_features(options[:features], options[:force_reload])
369
364
  elsif options[:user]
370
365
  @loader.get_user_config(options[:user], options[:force_reload])
371
366
  elsif options[:time_based]
372
- @loader.get_time_based_config(options[:force_reload])
367
+ @loader.time_based_config(options[:force_reload])
373
368
  elsif options[:overrides]
374
- @loader.get_config_with_overrides(options[:overrides])
369
+ @loader.config_with_overrides(options[:overrides])
375
370
  else
376
371
  @loader.load_config(options[:force_reload])
377
372
  end
@@ -378,6 +378,7 @@ module Aidp
378
378
  formatter_commands: [],
379
379
  build_commands: [],
380
380
  documentation_commands: [],
381
+ task_completion_required: true,
381
382
  units: {},
382
383
  guards: {enabled: false},
383
384
  version_control: {tool: "git", behavior: "nothing", conventional_commits: false},
@@ -432,6 +433,13 @@ module Aidp
432
433
  # Items can be strings or {command: string, required: boolean}
433
434
  # Validation handled in Configuration class for flexibility
434
435
  },
436
+ task_completion_required: {
437
+ type: :boolean,
438
+ required: false,
439
+ default: true
440
+ # When true, all tasks for the current session must be completed or
441
+ # explicitly abandoned (with user confirmation) before work loop can finish
442
+ },
435
443
  units: {
436
444
  type: :hash,
437
445
  required: false,
@@ -166,6 +166,11 @@ module Aidp
166
166
  work_loop_config[:max_iterations]
167
167
  end
168
168
 
169
+ # Check if task completion is required for work loop completion
170
+ def task_completion_required?
171
+ work_loop_config.fetch(:task_completion_required, true)
172
+ end
173
+
169
174
  # Get test commands
170
175
  def test_commands
171
176
  normalize_commands(work_loop_config[:test_commands] || [])
@@ -383,12 +388,12 @@ module Aidp
383
388
 
384
389
  # Get default thinking tier
385
390
  def default_tier
386
- thinking_config[:default_tier] || "standard"
391
+ thinking_config[:default_tier] || default_thinking_config[:default_tier]
387
392
  end
388
393
 
389
394
  # Get maximum thinking tier
390
395
  def max_tier
391
- thinking_config[:max_tier] || "standard"
396
+ thinking_config[:max_tier] || default_thinking_config[:max_tier]
392
397
  end
393
398
 
394
399
  # Check if provider switching for tier is allowed
@@ -432,6 +437,39 @@ module Aidp
432
437
  thinking_overrides[key] || thinking_overrides[key.to_sym]
433
438
  end
434
439
 
440
+ # Get thinking tiers configuration for a specific provider
441
+ # @param provider_name [String] The provider name
442
+ # @return [Hash] The thinking tiers configuration for the provider
443
+ def provider_thinking_tiers(provider_name)
444
+ provider_cfg = provider_config(provider_name)
445
+ provider_cfg[:thinking_tiers] || provider_cfg["thinking_tiers"] || {}
446
+ end
447
+
448
+ # Get models configured for a specific tier and provider
449
+ # @param tier [String, Symbol] The tier name (mini, standard, thinking, pro, max)
450
+ # @param provider_name [String] The provider name (required)
451
+ # @return [Array<String>] Array of model names for the tier
452
+ def models_for_tier(tier, provider_name)
453
+ return [] unless provider_name
454
+
455
+ tier_config = provider_thinking_tiers(provider_name)[tier] ||
456
+ provider_thinking_tiers(provider_name)[tier.to_sym]
457
+ return [] unless tier_config
458
+
459
+ models = tier_config[:models] || tier_config["models"]
460
+ return [] unless models
461
+
462
+ # Return simple array of model name strings
463
+ Array(models).map(&:to_s).compact
464
+ end
465
+
466
+ # Get all configured tiers for a provider
467
+ # @param provider_name [String] The provider name
468
+ # @return [Array<String>] Array of tier names
469
+ def configured_tiers(provider_name)
470
+ provider_thinking_tiers(provider_name).keys.map(&:to_s)
471
+ end
472
+
435
473
  # Get fallback configuration
436
474
  def fallback_config
437
475
  harness_config[:fallback] || default_fallback_config
@@ -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)
@@ -101,9 +106,6 @@ module Aidp
101
106
  @tui.show_message("🚀 Starting #{@mode.to_s.capitalize} Mode", :info)
102
107
 
103
108
  begin
104
- # Start TUI display loop
105
- @tui.start_display_loop
106
-
107
109
  # Load existing state if resuming
108
110
  # Temporarily disabled to test
109
111
  # load_state if @state_manager.has_state?
@@ -158,6 +160,10 @@ module Aidp
158
160
  handle_completion_criteria_not_met(completion_status)
159
161
  end
160
162
  end
163
+ rescue Aidp::Errors::ConfigurationError
164
+ # Configuration errors should crash immediately (crash-early principle)
165
+ # Re-raise without catching
166
+ raise
161
167
  rescue => e
162
168
  @state = STATES[:error]
163
169
  # Single error message - don't duplicate
@@ -165,7 +171,7 @@ module Aidp
165
171
  ensure
166
172
  # Save state before exiting
167
173
  save_state
168
- @tui.stop_display_loop
174
+ @tui.restore_screen
169
175
  cleanup
170
176
  end
171
177
 
@@ -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
@@ -80,7 +81,7 @@ module Aidp
80
81
  error_type: error_info[:error_type],
81
82
  reason: "Retry not applicable or exhausted"
82
83
  })
83
- if [:authentication, :permission_denied].include?(error_info[:error_type].to_sym)
84
+ if error_info[:error_type].to_sym == :auth_expired
84
85
  # Mark provider unhealthy to avoid immediate re-selection
85
86
  begin
86
87
  if @provider_manager.respond_to?(:mark_provider_auth_failure)
@@ -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
 
@@ -394,43 +399,6 @@ module Aidp
394
399
  jitter: false
395
400
  },
396
401
 
397
- # Legacy aliases for backward compatibility
398
- network_error: {
399
- name: "network_error",
400
- enabled: true,
401
- max_retries: 3,
402
- backoff_strategy: :exponential,
403
- base_delay: 1.0,
404
- max_delay: 30.0,
405
- jitter: true
406
- },
407
- server_error: {
408
- name: "server_error",
409
- enabled: true,
410
- max_retries: 2,
411
- backoff_strategy: :linear,
412
- base_delay: 2.0,
413
- max_delay: 10.0,
414
- jitter: true
415
- },
416
- timeout: {
417
- name: "timeout",
418
- enabled: true,
419
- max_retries: 2,
420
- backoff_strategy: :exponential,
421
- base_delay: 1.0,
422
- max_delay: 15.0,
423
- jitter: true
424
- },
425
- rate_limit: {
426
- name: "rate_limit",
427
- enabled: false,
428
- max_retries: 0,
429
- backoff_strategy: :none,
430
- base_delay: 0.0,
431
- max_delay: 0.0,
432
- jitter: false
433
- },
434
402
  authentication: {
435
403
  name: "authentication",
436
404
  enabled: false,
@@ -483,7 +451,7 @@ module Aidp
483
451
  }
484
452
  end
485
453
 
486
- def attempt_provider_switch(error_info, _recovery_plan)
454
+ def attempt_provider_switch(error_info, recovery_plan)
487
455
  new_provider = @provider_manager.switch_provider_for_error(
488
456
  error_info[:error_type],
489
457
  error_info[:context]
@@ -497,6 +465,18 @@ module Aidp
497
465
  reason: "Error recovery: #{error_info[:error_type]}"
498
466
  }
499
467
  else
468
+ # If this is an auth error and we have no fallback providers, crash
469
+ if recovery_plan[:crash_if_no_fallback]
470
+ error_msg = "All providers have failed authentication.\n\n" \
471
+ "Last provider: #{error_info[:provider]}\n" \
472
+ "Error: #{error_info[:error]&.message || error_info[:error]}\n\n" \
473
+ "Please check your API credentials for all configured providers.\n" \
474
+ "Run 'aidp config --interactive' to update credentials."
475
+
476
+ raise Aidp::Errors::ConfigurationError, error_msg
477
+ end
478
+
479
+ # For non-auth errors, return failure result
500
480
  {
501
481
  success: false,
502
482
  action: :provider_switch_failed,
@@ -680,12 +660,13 @@ module Aidp
680
660
  priority: :high
681
661
  }
682
662
  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
663
+ # Try to switch to another provider. If no providers available, this will
664
+ # be detected in attempt_recovery and we'll crash (crash-early principle)
685
665
  {
686
666
  action: :switch_provider,
687
667
  reason: "Authentication expired – switching provider to continue",
688
- priority: :critical
668
+ priority: :critical,
669
+ crash_if_no_fallback: true
689
670
  }
690
671
  when :quota_exceeded
691
672
  {
@@ -705,37 +686,6 @@ module Aidp
705
686
  reason: "Permanent error, requires manual intervention",
706
687
  priority: :critical
707
688
  }
708
- # Legacy error type mappings for backward compatibility
709
- when :timeout
710
- {
711
- action: :switch_model,
712
- reason: "Timeout error, trying faster model",
713
- priority: :medium
714
- }
715
- when :network_error
716
- {
717
- action: :switch_provider,
718
- reason: "Network error, switching provider",
719
- priority: :medium
720
- }
721
- when :server_error
722
- {
723
- action: :switch_provider,
724
- reason: "Server error, switching provider",
725
- priority: :medium
726
- }
727
- when :authentication, :permission_denied
728
- {
729
- action: :switch_provider,
730
- reason: "Authentication/permission issue – switching provider to continue",
731
- priority: :critical
732
- }
733
- when :rate_limit
734
- {
735
- action: :switch_provider,
736
- reason: "Rate limit reached, switching provider",
737
- priority: :high
738
- }
739
689
  else
740
690
  {
741
691
  action: :switch_provider,
@@ -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