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.
- checksums.yaml +4 -4
- data/.envrc +5 -1
- data/.loki +231 -0
- data/.quality/flay_baseline.txt +1 -0
- data/.quality/flog_baseline.txt +29 -0
- data/.quality/reek_baseline.txt +80 -0
- data/.rubocop.yml +116 -0
- data/.version +1 -1
- data/CHANGELOG.md +259 -50
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +266 -238
- data/Rakefile +118 -5
- data/architecture_review.md +314 -0
- data/bin/aia +16 -0
- data/docs/AGENTS.md +40 -0
- data/docs/advanced-prompting.md +67 -3
- data/docs/cli-reference.md +312 -56
- data/docs/configuration.md +130 -19
- data/docs/contributing.md +56 -2
- data/docs/directives-reference.md +593 -78
- data/docs/faq.md +85 -3
- data/docs/guides/available-models.md +1 -1
- data/docs/guides/basic-usage.md +6 -6
- data/docs/guides/chat.md +40 -16
- data/docs/guides/crew.md +239 -0
- data/docs/guides/executable-prompts.md +1 -1
- data/docs/guides/index.md +1 -0
- data/docs/guides/models.md +15 -0
- data/docs/index.md +29 -2
- data/docs/installation.md +44 -17
- data/docs/mcp-integration.md +40 -0
- data/docs/prompt_management.md +85 -86
- data/docs/security.md +47 -0
- data/docs/special_projects_guide.md +386 -0
- data/docs/tools-and-mcp-examples.md +23 -0
- data/docs/workflows-and-pipelines.md +84 -7
- data/examples/.gitignore +1 -0
- data/examples/00_setup_aia.sh +27 -44
- data/examples/11_multi_model.sh +4 -14
- data/examples/12_token_usage.sh +3 -12
- data/examples/18_tools.sh +10 -2
- data/examples/22_chat_mode.sh +0 -10
- data/examples/23_verify.sh +139 -0
- data/examples/24_decompose.sh +139 -0
- data/examples/25_spawn.sh +139 -0
- data/examples/26_debate.sh +97 -0
- data/examples/27_mention_routing.sh +157 -0
- data/examples/28_model_switching.sh +106 -0
- data/examples/29_agent_harness.sh +177 -0
- data/examples/README.md +65 -0
- data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
- data/examples/aia_config.yml +1 -1
- data/examples/aia_config_orchestrator.yml +45 -0
- data/examples/common.sh +18 -6
- data/examples/context/tech_stack.md +2 -2
- data/examples/prompts_dir/roles/orchestrator.md +21 -0
- data/examples/requirements/sinatra_taskflow_app.md +139 -0
- data/examples/rules/01_classify_ruby.rb +16 -0
- data/examples/rules/02_prefer_claude_for_code.rb +19 -0
- data/examples/rules/03_gate_prompt_length.rb +19 -0
- data/examples/rules/04_tool_selection.rb +41 -0
- data/examples/rules/README.md +30 -0
- data/examples/run_all.sh +48 -15
- data/examples/tools/word_count_tool.rb +1 -1
- data/lib/AGENTS.md +57 -0
- data/lib/aia/chat_loop.rb +304 -164
- data/lib/aia/config/cli_parser.rb +174 -111
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +39 -46
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +108 -142
- data/lib/aia/config.rb +110 -145
- data/lib/aia/content_extractor.rb +153 -0
- data/lib/aia/cost_calculator.rb +38 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +166 -0
- data/lib/aia/delegate_handler.rb +112 -0
- data/lib/aia/directive.rb +33 -18
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +160 -20
- data/lib/aia/directives/context_directives.rb +38 -26
- data/lib/aia/directives/execution_directives.rb +136 -4
- data/lib/aia/directives/model_directives.rb +76 -34
- data/lib/aia/directives/trakflow_directives.rb +44 -0
- data/lib/aia/directives/utility_directives.rb +203 -6
- data/lib/aia/directives/web_and_file_directives.rb +96 -60
- data/lib/aia/errors.rb +15 -0
- data/lib/aia/fact_asserter.rb +27 -0
- data/lib/aia/fzf.rb +9 -31
- data/lib/aia/handler_context.rb +17 -0
- data/lib/aia/handler_protocol.rb +19 -0
- data/lib/aia/history_transfer.rb +55 -0
- data/lib/aia/input_collector.rb +3 -3
- data/lib/aia/layered_orchestrator.rb +448 -0
- data/lib/aia/logger.rb +24 -4
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +305 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_utility.rb +57 -0
- data/lib/aia/mention_router.rb +260 -0
- data/lib/aia/model_alias_registry.rb +97 -0
- data/lib/aia/model_switch_handler.rb +100 -0
- data/lib/aia/network_builder.rb +155 -0
- data/lib/aia/network_memory_manager.rb +55 -0
- data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
- data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
- data/lib/aia/pipeline_orchestrator.rb +262 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +208 -0
- data/lib/aia/prompt_decomposer.rb +157 -0
- data/lib/aia/prompt_handler.rb +19 -39
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +334 -0
- data/lib/aia/robot_namer.rb +116 -0
- data/lib/aia/session.rb +83 -17
- data/lib/aia/session_tracker.rb +209 -0
- data/lib/aia/similarity_scorer.rb +39 -0
- data/lib/aia/skill_utils.rb +105 -1
- data/lib/aia/spawn_handler.rb +129 -0
- data/lib/aia/spawn_spec_parser.rb +65 -0
- data/lib/aia/special_mode_handler.rb +302 -0
- data/lib/aia/startup_coordinator.rb +150 -0
- data/lib/aia/streaming_runner.rb +169 -0
- data/lib/aia/system_prompt_assembler.rb +88 -0
- data/lib/aia/task_coordinator.rb +202 -0
- data/lib/aia/task_decomposer.rb +57 -0
- data/lib/aia/task_executor.rb +51 -0
- data/lib/aia/tfidf_math.rb +27 -0
- data/lib/aia/tool_filter/tfidf.rb +116 -0
- data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
- data/lib/aia/tool_filter.rb +82 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +143 -0
- data/lib/aia/tool_loader.rb +210 -0
- data/lib/aia/tool_utility.rb +30 -0
- data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
- data/lib/aia/tools/recruit_robot_tool.rb +60 -0
- data/lib/aia/tools/reskill_robot_tool.rb +44 -0
- data/lib/aia/tools/task_board_tool.rb +114 -0
- data/lib/aia/trakflow_bridge.rb +173 -0
- data/lib/aia/turn_state.rb +94 -0
- data/lib/aia/ui_presenter.rb +166 -198
- data/lib/aia/utility.rb +134 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +8 -9
- data/lib/aia/verification_network.rb +58 -0
- data/lib/aia.rb +108 -63
- data/mkdocs.yml +1 -0
- metadata +179 -56
- data/justfile +0 -215
- data/lib/aia/adapter/chat_execution.rb +0 -242
- data/lib/aia/adapter/error_handler.rb +0 -68
- data/lib/aia/adapter/gem_activator.rb +0 -57
- data/lib/aia/adapter/mcp_connector.rb +0 -274
- data/lib/aia/adapter/modality_handlers.rb +0 -167
- data/lib/aia/adapter/model_registry.rb +0 -81
- data/lib/aia/adapter/multi_model_chat.rb +0 -218
- data/lib/aia/adapter/provider_configurator.rb +0 -59
- data/lib/aia/adapter/tool_filter.rb +0 -85
- data/lib/aia/adapter/tool_loader.rb +0 -90
- data/lib/aia/chat_processor_service.rb +0 -178
- data/lib/aia/prompt_pipeline.rb +0 -183
- data/lib/aia/ruby_llm_adapter.rb +0 -95
- data/lib/extensions/openstruct_merge.rb +0 -48
- data/lib/extensions/ruby_llm/.irbrc +0 -56
- data/lib/extensions/ruby_llm/modalities.rb +0 -36
- data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
- data/lib/refinements/string.rb +0 -16
- 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
|