aia 1.1.1 → 2.0.0.0.pre.alpha

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 (169) hide show
  1. checksums.yaml +4 -4
  2. data/.envrc +5 -1
  3. data/.loki +231 -0
  4. data/.quality/flay_baseline.txt +1 -0
  5. data/.quality/flog_baseline.txt +29 -0
  6. data/.quality/reek_baseline.txt +80 -0
  7. data/.rubocop.yml +116 -0
  8. data/.version +1 -1
  9. data/CHANGELOG.md +259 -50
  10. data/IMPLEMENTATION_PLAN.md +506 -0
  11. data/README.md +266 -238
  12. data/Rakefile +118 -5
  13. data/architecture_review.md +314 -0
  14. data/bin/aia +16 -0
  15. data/docs/AGENTS.md +40 -0
  16. data/docs/advanced-prompting.md +67 -3
  17. data/docs/cli-reference.md +312 -56
  18. data/docs/configuration.md +130 -19
  19. data/docs/contributing.md +56 -2
  20. data/docs/directives-reference.md +593 -78
  21. data/docs/faq.md +85 -3
  22. data/docs/guides/available-models.md +1 -1
  23. data/docs/guides/basic-usage.md +6 -6
  24. data/docs/guides/chat.md +40 -16
  25. data/docs/guides/crew.md +239 -0
  26. data/docs/guides/executable-prompts.md +1 -1
  27. data/docs/guides/index.md +1 -0
  28. data/docs/guides/models.md +15 -0
  29. data/docs/index.md +29 -2
  30. data/docs/installation.md +44 -17
  31. data/docs/mcp-integration.md +40 -0
  32. data/docs/prompt_management.md +85 -86
  33. data/docs/security.md +47 -0
  34. data/docs/special_projects_guide.md +386 -0
  35. data/docs/tools-and-mcp-examples.md +23 -0
  36. data/docs/workflows-and-pipelines.md +84 -7
  37. data/examples/.gitignore +1 -0
  38. data/examples/00_setup_aia.sh +27 -44
  39. data/examples/11_multi_model.sh +4 -14
  40. data/examples/12_token_usage.sh +3 -12
  41. data/examples/18_tools.sh +10 -2
  42. data/examples/22_chat_mode.sh +0 -10
  43. data/examples/23_verify.sh +139 -0
  44. data/examples/24_decompose.sh +139 -0
  45. data/examples/25_spawn.sh +139 -0
  46. data/examples/26_debate.sh +97 -0
  47. data/examples/27_mention_routing.sh +157 -0
  48. data/examples/28_model_switching.sh +106 -0
  49. data/examples/29_agent_harness.sh +177 -0
  50. data/examples/README.md +65 -0
  51. data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
  52. data/examples/aia_config.yml +1 -1
  53. data/examples/aia_config_orchestrator.yml +45 -0
  54. data/examples/common.sh +18 -6
  55. data/examples/context/tech_stack.md +2 -2
  56. data/examples/prompts_dir/roles/orchestrator.md +21 -0
  57. data/examples/requirements/sinatra_taskflow_app.md +139 -0
  58. data/examples/rules/01_classify_ruby.rb +16 -0
  59. data/examples/rules/02_prefer_claude_for_code.rb +19 -0
  60. data/examples/rules/03_gate_prompt_length.rb +19 -0
  61. data/examples/rules/04_tool_selection.rb +41 -0
  62. data/examples/rules/README.md +30 -0
  63. data/examples/run_all.sh +48 -15
  64. data/examples/tools/word_count_tool.rb +1 -1
  65. data/lib/AGENTS.md +57 -0
  66. data/lib/aia/chat_loop.rb +304 -164
  67. data/lib/aia/config/cli_parser.rb +174 -111
  68. data/lib/aia/config/defaults.yml +62 -33
  69. data/lib/aia/config/mcp_parser.rb +39 -46
  70. data/lib/aia/config/model_spec.rb +34 -2
  71. data/lib/aia/config/validator.rb +108 -142
  72. data/lib/aia/config.rb +110 -145
  73. data/lib/aia/content_extractor.rb +153 -0
  74. data/lib/aia/cost_calculator.rb +38 -0
  75. data/lib/aia/crew.rb +164 -0
  76. data/lib/aia/debate_handler.rb +166 -0
  77. data/lib/aia/delegate_handler.rb +112 -0
  78. data/lib/aia/directive.rb +33 -18
  79. data/lib/aia/directive_processor.rb +16 -7
  80. data/lib/aia/directives/configuration_directives.rb +160 -20
  81. data/lib/aia/directives/context_directives.rb +38 -26
  82. data/lib/aia/directives/execution_directives.rb +136 -4
  83. data/lib/aia/directives/model_directives.rb +76 -34
  84. data/lib/aia/directives/trakflow_directives.rb +44 -0
  85. data/lib/aia/directives/utility_directives.rb +203 -6
  86. data/lib/aia/directives/web_and_file_directives.rb +96 -60
  87. data/lib/aia/errors.rb +15 -0
  88. data/lib/aia/fact_asserter.rb +27 -0
  89. data/lib/aia/fzf.rb +9 -31
  90. data/lib/aia/handler_context.rb +17 -0
  91. data/lib/aia/handler_protocol.rb +19 -0
  92. data/lib/aia/history_transfer.rb +55 -0
  93. data/lib/aia/input_collector.rb +3 -3
  94. data/lib/aia/layered_orchestrator.rb +448 -0
  95. data/lib/aia/logger.rb +24 -4
  96. data/lib/aia/mcp_config_normalizer.rb +35 -0
  97. data/lib/aia/mcp_connection_manager.rb +305 -0
  98. data/lib/aia/mcp_discovery.rb +44 -0
  99. data/lib/aia/mcp_grouper.rb +33 -0
  100. data/lib/aia/mcp_utility.rb +57 -0
  101. data/lib/aia/mention_router.rb +260 -0
  102. data/lib/aia/model_alias_registry.rb +97 -0
  103. data/lib/aia/model_switch_handler.rb +100 -0
  104. data/lib/aia/network_builder.rb +155 -0
  105. data/lib/aia/network_memory_manager.rb +55 -0
  106. data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
  107. data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
  108. data/lib/aia/pipeline_orchestrator.rb +262 -0
  109. data/lib/aia/plugin_loader.rb +170 -0
  110. data/lib/aia/plugin_monitor.rb +208 -0
  111. data/lib/aia/prompt_decomposer.rb +157 -0
  112. data/lib/aia/prompt_handler.rb +19 -39
  113. data/lib/aia/robot_builder.rb +51 -0
  114. data/lib/aia/robot_factory.rb +334 -0
  115. data/lib/aia/robot_namer.rb +116 -0
  116. data/lib/aia/session.rb +83 -17
  117. data/lib/aia/session_tracker.rb +209 -0
  118. data/lib/aia/similarity_scorer.rb +39 -0
  119. data/lib/aia/skill_utils.rb +105 -1
  120. data/lib/aia/spawn_handler.rb +129 -0
  121. data/lib/aia/spawn_spec_parser.rb +65 -0
  122. data/lib/aia/special_mode_handler.rb +302 -0
  123. data/lib/aia/startup_coordinator.rb +150 -0
  124. data/lib/aia/streaming_runner.rb +169 -0
  125. data/lib/aia/system_prompt_assembler.rb +88 -0
  126. data/lib/aia/task_coordinator.rb +202 -0
  127. data/lib/aia/task_decomposer.rb +57 -0
  128. data/lib/aia/task_executor.rb +51 -0
  129. data/lib/aia/tfidf_math.rb +27 -0
  130. data/lib/aia/tool_filter/tfidf.rb +116 -0
  131. data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
  132. data/lib/aia/tool_filter.rb +82 -0
  133. data/lib/aia/tool_filter_registry.rb +30 -0
  134. data/lib/aia/tool_filter_strategy.rb +143 -0
  135. data/lib/aia/tool_loader.rb +210 -0
  136. data/lib/aia/tool_utility.rb +30 -0
  137. data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
  138. data/lib/aia/tools/recruit_robot_tool.rb +60 -0
  139. data/lib/aia/tools/reskill_robot_tool.rb +44 -0
  140. data/lib/aia/tools/task_board_tool.rb +114 -0
  141. data/lib/aia/trakflow_bridge.rb +173 -0
  142. data/lib/aia/turn_state.rb +94 -0
  143. data/lib/aia/ui_presenter.rb +166 -198
  144. data/lib/aia/utility.rb +134 -87
  145. data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
  146. data/lib/aia/verification_network.rb +58 -0
  147. data/lib/aia.rb +108 -63
  148. data/mkdocs.yml +1 -0
  149. metadata +179 -56
  150. data/justfile +0 -215
  151. data/lib/aia/adapter/chat_execution.rb +0 -242
  152. data/lib/aia/adapter/error_handler.rb +0 -68
  153. data/lib/aia/adapter/gem_activator.rb +0 -57
  154. data/lib/aia/adapter/mcp_connector.rb +0 -274
  155. data/lib/aia/adapter/modality_handlers.rb +0 -167
  156. data/lib/aia/adapter/model_registry.rb +0 -81
  157. data/lib/aia/adapter/multi_model_chat.rb +0 -218
  158. data/lib/aia/adapter/provider_configurator.rb +0 -59
  159. data/lib/aia/adapter/tool_filter.rb +0 -85
  160. data/lib/aia/adapter/tool_loader.rb +0 -90
  161. data/lib/aia/chat_processor_service.rb +0 -178
  162. data/lib/aia/prompt_pipeline.rb +0 -183
  163. data/lib/aia/ruby_llm_adapter.rb +0 -95
  164. data/lib/extensions/openstruct_merge.rb +0 -48
  165. data/lib/extensions/ruby_llm/.irbrc +0 -56
  166. data/lib/extensions/ruby_llm/modalities.rb +0 -36
  167. data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
  168. data/lib/refinements/string.rb +0 -16
  169. data/main.just +0 -76
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/aia/pipeline_orchestrator.rb
4
+ #
5
+ # Handles per-prompt pipeline processing: building prompt text,
6
+ # executing against the robot, tracking results, and displaying output.
7
+ # Extracted from Session to separate startup concerns from per-turn concerns.
8
+
9
+ require "json"
10
+ require_relative 'skill_utils'
11
+
12
+ module AIA
13
+ class PipelineOrchestrator
14
+ include ContentExtractor
15
+ include SkillUtils
16
+
17
+ def initialize(robot:, prompt_handler:, input_collector:, ui_presenter:, session_tracker:)
18
+ @robot = robot
19
+ @prompt_handler = prompt_handler
20
+ @input_collector = input_collector
21
+ @ui = ui_presenter
22
+ @tracker = session_tracker
23
+ end
24
+
25
+ # Process all prompts in the pipeline.
26
+ #
27
+ # @param config [AIA::Config]
28
+ def process(config)
29
+ bridge = TrakFlowBridge.new
30
+ tracking = bridge.available? && config.flags.track_pipeline
31
+
32
+ bridge.create_plan_from_pipeline(config.pipeline) if tracking
33
+
34
+ # Use shift-based loop so that prompt front matter which sets
35
+ # config.pipeline (via `next:` or `pipeline:`) is picked up on
36
+ # the next iteration rather than being silently ignored.
37
+ until config.pipeline.empty?
38
+ prompt_id = config.pipeline.shift
39
+ next if prompt_id.nil? || prompt_id.empty?
40
+
41
+ begin
42
+ bridge.update_step_status(prompt_id, :started) if tracking
43
+
44
+ prompt_text = build_prompt_text(prompt_id, config)
45
+ next if prompt_text.nil? || prompt_text.strip.empty?
46
+
47
+ result, content = compute_result(prompt_text, config)
48
+
49
+ bridge.update_step_status(prompt_id, :completed) if tracking
50
+
51
+ @tracker.record_turn(
52
+ model: config.models.first.name,
53
+ input: prompt_text,
54
+ result: result
55
+ )
56
+
57
+ @ui.display_ai_response(content)
58
+ output_to_file(content, config)
59
+ display_metrics(result, config)
60
+ @ui.display_separator
61
+ rescue StandardError => e
62
+ bridge.update_step_status(prompt_id, :failed, reason: e.message) if tracking
63
+ raise
64
+ end
65
+ end
66
+ end
67
+
68
+ # Allow robot to be updated (after model switch)
69
+ attr_writer :robot
70
+
71
+ private
72
+
73
+ # Returns [result, displayable_content] for one prompt — image when in paint
74
+ # mode, otherwise the LLM text response.
75
+ def compute_result(prompt_text, config)
76
+ if AIA.paint?
77
+ image = generate_image(prompt_text, config)
78
+ [image, image]
79
+ else
80
+ result = execute_prompt(prompt_text, config)
81
+ [result, extract_content(result)]
82
+ end
83
+ end
84
+
85
+ def generate_image(prompt_text, config)
86
+ img_cfg = config.image
87
+ params = {}
88
+ params[:quality] = img_cfg.quality if img_cfg.quality && !img_cfg.quality.to_s.strip.empty?
89
+ params[:style] = img_cfg.style if img_cfg.style && !img_cfg.style.to_s.strip.empty?
90
+
91
+ image = @ui.with_spinner("Generating image") do
92
+ RubyLLM.paint(
93
+ prompt_text,
94
+ model: img_cfg.model || 'dall-e-3',
95
+ size: img_cfg.size || '1024x1024',
96
+ params: params
97
+ )
98
+ end
99
+
100
+ out_file = config.output.file
101
+ if out_file
102
+ ext = '.png'
103
+ path = out_file.chomp(File.extname(out_file)) + ext
104
+ image.save(path)
105
+ "Image saved to: #{path}"
106
+ else
107
+ image.url || "(image returned no URL)"
108
+ end
109
+ end
110
+
111
+ # Execute a prompt, optionally using concurrent MCP
112
+ def execute_prompt(prompt_text, config)
113
+ concurrent_network = maybe_use_concurrent_mcp(prompt_text, config)
114
+
115
+ if concurrent_network
116
+ @ui.with_spinner("Processing (concurrent)") do
117
+ concurrent_network.run(prompt_text)
118
+ end
119
+ else
120
+ @ui.with_spinner("Processing") do
121
+ @robot.run(prompt_text, mcp: :inherit, tools: :inherit)
122
+ end
123
+ end
124
+ end
125
+
126
+ # Check if concurrent MCP mode should be used
127
+ def maybe_use_concurrent_mcp(prompt_text, config)
128
+ return nil unless (config.mcp_servers || []).size > 1
129
+
130
+ concurrency = config.respond_to?(:concurrency) ? config.concurrency : nil
131
+ return nil unless concurrency&.auto || AIA.turn_state.force_concurrent_mcp
132
+
133
+ if AIA.turn_state.force_concurrent_mcp
134
+ AIA.turn_state.force_concurrent_mcp = false
135
+ end
136
+
137
+ discovery = MCPDiscovery.new
138
+ relevant_servers = discovery.discover(config)
139
+ return nil if relevant_servers.size <= 1
140
+
141
+ grouper = MCPGrouper.new
142
+ groups = grouper.group(relevant_servers)
143
+
144
+ threshold = concurrency.respond_to?(:threshold) ? (concurrency.threshold || 2) : 2
145
+ return nil if groups.size < threshold
146
+
147
+ RobotFactory.build_concurrent_mcp_network(config, groups)
148
+ rescue StandardError => e
149
+ $stderr.puts "Warning: Concurrent MCP setup failed: #{e.message}"
150
+ nil
151
+ end
152
+
153
+ # Build prompt text from a prompt_id
154
+ def build_prompt_text(prompt_id, config)
155
+ parsed = @prompt_handler.fetch_prompt(prompt_id)
156
+ return nil unless parsed
157
+
158
+ if parsed.respond_to?(:parameters) && parsed.parameters && !parsed.parameters.empty?
159
+ values = @input_collector.collect(parsed.parameters)
160
+ values.each { |k, v| parsed.parameters[k] = v }
161
+ end
162
+
163
+ prompt_text = parsed.to_s
164
+
165
+ role = config.prompts.role
166
+ if role && !role.empty?
167
+ role_parsed = @prompt_handler.fetch_role(role)
168
+ if role_parsed
169
+ prompt_text = "#{role_parsed}\n\n#{prompt_text}"
170
+ end
171
+ end
172
+
173
+ # In pipeline mode, append --skill content to each prompt text.
174
+ # In chat mode, skills are injected into the system prompt instead (system_prompt_assembler).
175
+ unless config.flags&.chat == true
176
+ skill_content = load_skills_content(
177
+ Array(config.prompts&.skills),
178
+ skills_base_dir(config)
179
+ )
180
+ prompt_text = "#{prompt_text}\n\n#{skill_content}" if skill_content
181
+ end
182
+
183
+ if config.stdin_content && !config.stdin_content.strip.empty?
184
+ prompt_text = "#{prompt_text}\n\n#{config.stdin_content}"
185
+ config.stdin_content = nil
186
+ end
187
+
188
+ add_context_files(prompt_text, config)
189
+ end
190
+
191
+ # Append context file contents to prompt
192
+ def add_context_files(prompt_text, config)
193
+ context_files = config.context_files
194
+ return prompt_text if context_files.nil? || context_files.empty?
195
+
196
+ context = context_files.map do |file|
197
+ File.read(file) rescue "Error reading file: #{file}"
198
+ end.join("\n\n")
199
+
200
+ return prompt_text if context.strip.empty?
201
+
202
+ "#{prompt_text}\n\n#{context}"
203
+ end
204
+
205
+ # Display token metrics if enabled.
206
+ # Uses result.raw (RubyLLM::Message) for token data, matching ChatLoop's approach.
207
+ def display_metrics(result, config)
208
+ return unless config.flags.tokens
209
+
210
+ if defined?(SimpleFlow::Result) && result.is_a?(SimpleFlow::Result)
211
+ display_network_metrics(result)
212
+ return
213
+ end
214
+
215
+ raw = result.respond_to?(:raw) ? result.raw : nil
216
+ return unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
217
+
218
+ model_id = (raw.respond_to?(:model_id) && raw.model_id) ||
219
+ (raw.respond_to?(:model) && raw.model) ||
220
+ config.models.first.name
221
+ @ui.display_token_metrics(
222
+ model_id: model_id,
223
+ input_tokens: raw.input_tokens,
224
+ output_tokens: raw.output_tokens
225
+ )
226
+ end
227
+
228
+ def display_network_metrics(flow_result)
229
+ metrics_list = []
230
+ flow_result.context.each do |task_name, robot_result|
231
+ next if task_name == :run_params
232
+ next unless robot_result.respond_to?(:raw)
233
+
234
+ raw = robot_result.raw
235
+ next unless raw && raw.respond_to?(:input_tokens) && raw.input_tokens
236
+
237
+ model_id = (raw.respond_to?(:model_id) && raw.model_id) ||
238
+ (raw.respond_to?(:model) && raw.model) ||
239
+ task_name.to_s
240
+ display_name = robot_result.respond_to?(:robot_name) ? robot_result.robot_name : task_name.to_s
241
+ metrics_list << {
242
+ model_id: model_id,
243
+ display_name: display_name,
244
+ input_tokens: raw.input_tokens || 0,
245
+ output_tokens: raw.output_tokens || 0,
246
+ elapsed: robot_result.respond_to?(:duration) ? robot_result.duration : nil
247
+ }
248
+ end
249
+ @ui.display_multi_model_metrics(metrics_list) unless metrics_list.empty?
250
+ end
251
+
252
+ # Write content to the output file if configured
253
+ def output_to_file(content, config)
254
+ out_file = config.output.file
255
+ return unless out_file
256
+
257
+ File.open(out_file, "a") do |f|
258
+ f.puts "AI: #{content}"
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AIA
4
+ # Loads, reloads, and unloads top-level Ruby plugin files from the configured
5
+ # plugins directory. A plugin is any `*.rb` file directly under
6
+ # `config.paths.plugins_dir`; it may define top-level (Kernel) methods and/or
7
+ # top-level constants. The loader tracks exactly what each file defines so a
8
+ # changed plugin can be cleanly reloaded and a deleted plugin fully removed
9
+ # from the running process.
10
+ class PluginLoader
11
+ class << self
12
+ # Per-process registry of loaded plugins, keyed by basename.
13
+ # Each value: { path:, signature:, defined: { methods: [Symbol], constants: [Symbol] } }
14
+ def registry
15
+ @registry ||= {}
16
+ end
17
+
18
+ def mutex
19
+ @mutex ||= Mutex.new
20
+ end
21
+
22
+ # Load every `*.rb` file at the top level of the plugins directory,
23
+ # starting from a clean slate (any previously-tracked definitions are
24
+ # removed first). Returns the sorted basenames of the loaded plugins.
25
+ #
26
+ # @param config [#paths, #loaded_plugins=]
27
+ # @return [Array<String>]
28
+ def load!(config)
29
+ @config = config
30
+ reset!(config)
31
+
32
+ dir = plugins_dir(config)
33
+ return [] unless dir
34
+
35
+ Dir.glob(File.join(dir, '*.rb')).each { |file| load_file(file, config) }
36
+ loaded_names
37
+ end
38
+
39
+ # Load — or, if already loaded, reload — a single plugin file. On reload,
40
+ # the file's previous definitions are removed before re-executing so that
41
+ # renamed/deleted methods don't linger. Returns the basename, or nil on
42
+ # failure.
43
+ #
44
+ # @param path [String] absolute path to a `*.rb` plugin file
45
+ # @param config [Object, nil] config to keep `loaded_plugins` in sync with
46
+ # @return [String, nil]
47
+ def load_file(path, config = @config)
48
+ mutex.synchronize do
49
+ name = File.basename(path, '.rb')
50
+ remove_definitions(registry[name]) if registry.key?(name)
51
+
52
+ before = current_definitions
53
+ load(path)
54
+ defined = diff_definitions(before, current_definitions)
55
+
56
+ registry[name] = { path: path, signature: signature(path), defined: defined }
57
+ sync_config(config)
58
+ name
59
+ end
60
+ rescue ScriptError, StandardError => e
61
+ warn_failure(path, e)
62
+ nil
63
+ end
64
+
65
+ # Unload a plugin by basename: remove the methods and constants it defined
66
+ # and forget it. Returns the basename if it was loaded, else nil.
67
+ #
68
+ # @param name [String] plugin basename (no extension)
69
+ # @param config [Object, nil]
70
+ # @return [String, nil]
71
+ def unload_file(name, config = @config)
72
+ mutex.synchronize do
73
+ entry = registry.delete(name)
74
+ remove_definitions(entry)
75
+ sync_config(config)
76
+ entry && name
77
+ end
78
+ end
79
+
80
+ # @return [Array<String>] sorted basenames currently loaded
81
+ def loaded_names
82
+ registry.keys.sort
83
+ end
84
+
85
+ # Remove all tracked plugin definitions and clear the registry. Used by
86
+ # load! for a clean reload and by tests for isolation.
87
+ #
88
+ # @param config [Object, nil]
89
+ # @return [void]
90
+ def reset!(config = @config)
91
+ mutex.synchronize do
92
+ registry.each_value { |entry| remove_definitions(entry) }
93
+ registry.clear
94
+ sync_config(config)
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def plugins_dir(config)
101
+ dir = config.paths&.plugins_dir
102
+ return nil if dir.nil? || dir.to_s.strip.empty?
103
+ return nil unless Dir.exist?(dir)
104
+
105
+ dir
106
+ end
107
+
108
+ # Snapshot of the top-level methods and constants currently defined on
109
+ # Object. Top-level `def` creates private instance methods on Object;
110
+ # top-level constants/classes/modules become constants on Object.
111
+ #
112
+ # @return [Hash{Symbol=>Array<Symbol>}]
113
+ def current_definitions
114
+ {
115
+ methods: Object.private_instance_methods(false) + Object.instance_methods(false),
116
+ constants: Object.constants(false)
117
+ }
118
+ end
119
+
120
+ # What appeared between two snapshots.
121
+ def diff_definitions(before, after)
122
+ {
123
+ methods: after[:methods] - before[:methods],
124
+ constants: after[:constants] - before[:constants]
125
+ }
126
+ end
127
+
128
+ # Remove the methods and constants a plugin defined, if still present and
129
+ # owned directly by Object.
130
+ def remove_definitions(entry)
131
+ return unless entry
132
+
133
+ defined = entry[:defined] || {}
134
+ Array(defined[:methods]).each { |m| remove_object_method(m) }
135
+ Array(defined[:constants]).each { |c| remove_object_constant(c) }
136
+ end
137
+
138
+ def remove_object_method(name)
139
+ return unless Object.private_method_defined?(name, false) ||
140
+ Object.method_defined?(name, false)
141
+
142
+ Object.send(:remove_method, name)
143
+ rescue NameError
144
+ # Already gone — nothing to do.
145
+ end
146
+
147
+ def remove_object_constant(name)
148
+ Object.send(:remove_const, name) if Object.const_defined?(name, false)
149
+ rescue NameError
150
+ # Already gone — nothing to do.
151
+ end
152
+
153
+ # A cheap change-signature for a file: [mtime, size]. Nil if unreadable.
154
+ def signature(path)
155
+ stat = File.stat(path)
156
+ [stat.mtime, stat.size]
157
+ rescue SystemCallError
158
+ nil
159
+ end
160
+
161
+ def sync_config(config)
162
+ config.loaded_plugins = loaded_names if config.respond_to?(:loaded_plugins=)
163
+ end
164
+
165
+ def warn_failure(path, error)
166
+ $stderr.puts "Warning: Failed to load plugin '#{path}': #{error.message}"
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'plugin_loader'
4
+
5
+ module AIA
6
+ # Watches the plugins directory and keeps the running process in sync with it:
7
+ #
8
+ # - a new `*.rb` file -> loaded
9
+ # - an existing file changed -> reloaded (old definitions removed first)
10
+ # - a file removed -> its definitions removed from the process
11
+ #
12
+ # Detection prefers the `listen` gem (native OS file events; instant, no idle
13
+ # CPU). If `listen` cannot be loaded, it falls back to a background thread that
14
+ # polls file signatures ([mtime, size]). Either way the same #react logic drives
15
+ # {PluginLoader}, so behavior is identical — only latency differs.
16
+ class PluginMonitor
17
+ DEFAULT_INTERVAL = 1.0 # seconds (polling fallback only)
18
+
19
+ # @param config [#paths] provides paths.plugins_dir
20
+ # @param loader [#load_file, #unload_file] plugin loader (default PluginLoader)
21
+ # @param interval [Float] seconds between scans (polling fallback only)
22
+ # @param output [IO] where status messages go (default $stderr)
23
+ def initialize(config, loader: PluginLoader, interval: DEFAULT_INTERVAL, output: $stderr)
24
+ @config = config
25
+ @loader = loader
26
+ @interval = interval
27
+ @output = output
28
+ @dir = config.paths&.plugins_dir
29
+ @thread = nil
30
+ @listener = nil
31
+ @running = false
32
+ @backend = nil
33
+ @snapshot = {}
34
+ end
35
+
36
+ # Start watching. Prefers listen; falls back to polling. No-op if the
37
+ # directory is not watchable or the monitor is already running.
38
+ #
39
+ # @return [self]
40
+ def start
41
+ return self unless watchable?
42
+ return self if @running
43
+
44
+ @snapshot = current_snapshot
45
+ @running = true
46
+ @backend = start_listen ? :listen : start_polling
47
+ self
48
+ end
49
+
50
+ # Stop watching, whichever backend is active.
51
+ #
52
+ # @return [self]
53
+ def stop
54
+ @running = false
55
+ stop_listen
56
+ stop_polling
57
+ @backend = nil
58
+ self
59
+ end
60
+
61
+ # @return [Boolean]
62
+ def running?
63
+ @running
64
+ end
65
+
66
+ # @return [Symbol, nil] :listen or :polling once started
67
+ attr_reader :backend
68
+
69
+ # Perform one scan-and-react cycle (polling backend). Compares the current
70
+ # directory state to the previous snapshot, applies each change, then stores
71
+ # the new snapshot. Public so it can be driven directly in tests.
72
+ #
73
+ # @return [Array<Array(Symbol, String)>] the changes applied this tick
74
+ def tick
75
+ now = current_snapshot
76
+ changes = detect_changes(@snapshot, now)
77
+ changes.each { |event, path| react(event, path) }
78
+ @snapshot = now
79
+ changes
80
+ end
81
+
82
+ # Compare two `path => signature` snapshots and classify the differences.
83
+ #
84
+ # @return [Array<Array(Symbol, String)>] e.g. [[:added, "/p/foo.rb"], ...]
85
+ def detect_changes(old_snapshot, new_snapshot)
86
+ added = (new_snapshot.keys - old_snapshot.keys).map { |path| [:added, path] }
87
+ removed = (old_snapshot.keys - new_snapshot.keys).map { |path| [:removed, path] }
88
+ modified = (old_snapshot.keys & new_snapshot.keys)
89
+ .reject { |path| new_snapshot[path] == old_snapshot[path] }
90
+ .map { |path| [:modified, path] }
91
+ added + removed + modified
92
+ end
93
+
94
+ private
95
+
96
+ def watchable?
97
+ !@dir.nil? && !@dir.to_s.strip.empty? && Dir.exist?(@dir)
98
+ end
99
+
100
+ # --- listen backend ---
101
+
102
+ # Try to start the listen-based watcher. Returns true on success, false if
103
+ # the listen gem is unavailable (caller then uses the polling fallback).
104
+ def start_listen
105
+ require 'listen'
106
+ @listener = Listen.to(@dir, only: /\.rb$/) do |modified, added, removed|
107
+ handle_listen_events(modified, added, removed)
108
+ end
109
+ @listener.start
110
+ true
111
+ rescue LoadError
112
+ false
113
+ end
114
+
115
+ # Map a listen callback into per-file reactions, ignoring anything that is
116
+ # not a top-level file of the plugins directory (listen watches recursively).
117
+ def handle_listen_events(modified, added, removed)
118
+ Array(added).each { |path| react(:added, path) if top_level?(path) }
119
+ Array(modified).each { |path| react(:modified, path) if top_level?(path) }
120
+ Array(removed).each { |path| react(:removed, path) if top_level?(path) }
121
+ end
122
+
123
+ # listen reports resolved (symlink-free) paths, and on macOS the plugins
124
+ # dir is often reached via a symlink (e.g. /tmp -> /private/tmp). Compare
125
+ # canonicalized directories so the top-level filter matches correctly.
126
+ def top_level?(path)
127
+ canonical_dir(File.dirname(path)) == watched_dir
128
+ end
129
+
130
+ def watched_dir
131
+ @watched_dir ||= canonical_dir(@dir)
132
+ end
133
+
134
+ def canonical_dir(dir)
135
+ File.realpath(dir)
136
+ rescue StandardError
137
+ File.expand_path(dir)
138
+ end
139
+
140
+ def stop_listen
141
+ @listener&.stop
142
+ rescue StandardError
143
+ # best-effort
144
+ ensure
145
+ @listener = nil
146
+ end
147
+
148
+ # --- polling backend ---
149
+
150
+ def start_polling
151
+ @thread = Thread.new { watch_loop }
152
+ @thread.name = 'aia-plugin-monitor' if @thread.respond_to?(:name=)
153
+ :polling
154
+ end
155
+
156
+ def stop_polling
157
+ begin
158
+ @thread&.wakeup
159
+ rescue ThreadError
160
+ # thread already finished
161
+ end
162
+ @thread&.join(@interval + 0.5)
163
+ @thread = nil
164
+ end
165
+
166
+ def watch_loop
167
+ while @running
168
+ begin
169
+ tick
170
+ rescue StandardError => e
171
+ @output.puts "Plugin monitor error: #{e.message}"
172
+ end
173
+ sleep @interval
174
+ end
175
+ end
176
+
177
+ # --- shared ---
178
+
179
+ # @return [Hash{String=>Array}] path => [mtime, size]
180
+ def current_snapshot
181
+ Dir.glob(File.join(@dir, '*.rb')).to_h { |path| [path, file_signature(path)] }
182
+ end
183
+
184
+ def file_signature(path)
185
+ stat = File.stat(path)
186
+ [stat.mtime, stat.size]
187
+ rescue SystemCallError
188
+ nil
189
+ end
190
+
191
+ # Apply a single change by driving the loader, and report it.
192
+ def react(event, path)
193
+ name = File.basename(path, '.rb')
194
+ case event
195
+ when :added
196
+ announce('loaded', name) if @loader.load_file(path, @config)
197
+ when :modified
198
+ announce('reloaded', name) if @loader.load_file(path, @config)
199
+ when :removed
200
+ announce('unloaded', name) if @loader.unload_file(name, @config)
201
+ end
202
+ end
203
+
204
+ def announce(action, name)
205
+ @output.puts "Plugin #{action}: #{name}"
206
+ end
207
+ end
208
+ end