ollama_agent 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +14 -3
- data/lib/ollama_agent/agent/agent_config.rb +19 -2
- data/lib/ollama_agent/agent/client_wiring.rb +3 -8
- data/lib/ollama_agent/agent/session_wiring.rb +37 -3
- data/lib/ollama_agent/agent.rb +82 -6
- data/lib/ollama_agent/cli/repl.rb +159 -0
- data/lib/ollama_agent/cli/repl_shared.rb +229 -0
- data/lib/ollama_agent/cli/tui_repl.rb +149 -0
- data/lib/ollama_agent/cli.rb +129 -49
- data/lib/ollama_agent/core/action_envelope.rb +82 -0
- data/lib/ollama_agent/core/budget.rb +90 -0
- data/lib/ollama_agent/core/loop_detector.rb +67 -0
- data/lib/ollama_agent/core/schema_validator.rb +136 -0
- data/lib/ollama_agent/core/trace_logger.rb +138 -0
- data/lib/ollama_agent/external_agents/probe.rb +23 -3
- data/lib/ollama_agent/indexing/context_packer.rb +140 -0
- data/lib/ollama_agent/indexing/diff_summarizer.rb +125 -0
- data/lib/ollama_agent/indexing/file_indexer.rb +129 -0
- data/lib/ollama_agent/indexing/repo_scanner.rb +158 -0
- data/lib/ollama_agent/memory/long_term.rb +109 -0
- data/lib/ollama_agent/memory/manager.rb +121 -0
- data/lib/ollama_agent/memory/session_memory.rb +93 -0
- data/lib/ollama_agent/memory/short_term.rb +66 -0
- data/lib/ollama_agent/ollama_cloud_catalog.rb +66 -0
- data/lib/ollama_agent/ollama_connection.rb +30 -0
- data/lib/ollama_agent/plugins/loader.rb +95 -0
- data/lib/ollama_agent/plugins/registry.rb +103 -0
- data/lib/ollama_agent/providers/anthropic.rb +245 -0
- data/lib/ollama_agent/providers/base.rb +79 -0
- data/lib/ollama_agent/providers/ollama.rb +118 -0
- data/lib/ollama_agent/providers/openai.rb +215 -0
- data/lib/ollama_agent/providers/registry.rb +76 -0
- data/lib/ollama_agent/providers/router.rb +93 -0
- data/lib/ollama_agent/resilience/retry_middleware.rb +5 -0
- data/lib/ollama_agent/runner.rb +25 -4
- data/lib/ollama_agent/runtime/approval_gate.rb +74 -0
- data/lib/ollama_agent/runtime/permissions.rb +103 -0
- data/lib/ollama_agent/runtime/policies.rb +100 -0
- data/lib/ollama_agent/runtime/sandbox.rb +130 -0
- data/lib/ollama_agent/streaming/hooks.rb +3 -1
- data/lib/ollama_agent/tools/base.rb +108 -0
- data/lib/ollama_agent/tools/git_tools.rb +176 -0
- data/lib/ollama_agent/tools/http_tools.rb +202 -0
- data/lib/ollama_agent/tools/memory_tools.rb +116 -0
- data/lib/ollama_agent/tools/shell_tools.rb +208 -0
- data/lib/ollama_agent/tui.rb +183 -0
- data/lib/ollama_agent/tui_slash_reader.rb +147 -0
- data/lib/ollama_agent/tui_user_prompt.rb +45 -0
- data/lib/ollama_agent/version.rb +1 -1
- data/lib/ollama_agent.rb +46 -1
- metadata +142 -5
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module OllamaAgent
|
|
7
|
+
module Memory
|
|
8
|
+
# Session-scoped key-value store for goals, task progress, and intermediate state.
|
|
9
|
+
# Persisted to YAML under .ollama_agent/memory/<session_id>.yml.
|
|
10
|
+
# Outlives a single run but is scoped to a session.
|
|
11
|
+
class SessionMemory
|
|
12
|
+
attr_reader :session_id
|
|
13
|
+
|
|
14
|
+
def initialize(root:, session_id: nil)
|
|
15
|
+
@root = File.expand_path(root)
|
|
16
|
+
@session_id = session_id || "default"
|
|
17
|
+
@store = load_or_initialize
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param key [String, Symbol]
|
|
21
|
+
# @param value [Object] must be YAML-serialisable
|
|
22
|
+
def set(key, value)
|
|
23
|
+
@store[key.to_s] = value
|
|
24
|
+
persist!
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def get(key)
|
|
29
|
+
@store[key.to_s]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def delete(key)
|
|
33
|
+
@store.delete(key.to_s)
|
|
34
|
+
persist!
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def keys
|
|
38
|
+
@store.keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def all
|
|
42
|
+
@store.dup
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clear!
|
|
46
|
+
@store = {}
|
|
47
|
+
persist!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Active goals tracking
|
|
51
|
+
def set_goal(description)
|
|
52
|
+
goals = @store.fetch("_goals", [])
|
|
53
|
+
goals << { description: description, status: "active", added_at: Time.now.iso8601 }
|
|
54
|
+
set("_goals", goals)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def complete_goal(description)
|
|
58
|
+
goals = @store.fetch("_goals", [])
|
|
59
|
+
goals.each { |g| g[:status] = "done" if g[:description] == description }
|
|
60
|
+
set("_goals", goals)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def active_goals
|
|
64
|
+
@store.fetch("_goals", []).select { |g| g[:status] == "active" }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def memory_dir
|
|
70
|
+
File.join(@root, ".ollama_agent", "memory")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def memory_path
|
|
74
|
+
File.join(memory_dir, "#{@session_id}.yml")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def load_or_initialize
|
|
78
|
+
return {} unless File.exist?(memory_path)
|
|
79
|
+
|
|
80
|
+
YAML.safe_load_file(memory_path, permitted_classes: [Symbol, Time]) || {}
|
|
81
|
+
rescue StandardError
|
|
82
|
+
{}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def persist!
|
|
86
|
+
FileUtils.mkdir_p(memory_dir)
|
|
87
|
+
File.write(memory_path, YAML.dump(@store))
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Memory
|
|
5
|
+
# In-memory sliding window of recent tool calls and observations.
|
|
6
|
+
# Cleared at the end of each run. Never persisted.
|
|
7
|
+
class ShortTerm
|
|
8
|
+
DEFAULT_MAX_ENTRIES = 20
|
|
9
|
+
|
|
10
|
+
Entry = Data.define(:type, :content, :ts)
|
|
11
|
+
|
|
12
|
+
attr_reader :entries
|
|
13
|
+
|
|
14
|
+
def initialize(max: DEFAULT_MAX_ENTRIES)
|
|
15
|
+
@max = max
|
|
16
|
+
@entries = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Record a new entry.
|
|
20
|
+
# @param type [Symbol] :tool_call, :tool_result, :observation, :reasoning
|
|
21
|
+
# @param content [Object] anything serialisable
|
|
22
|
+
def record(type, content)
|
|
23
|
+
@entries << Entry.new(type: type.to_sym, content: content, ts: Time.now.to_f)
|
|
24
|
+
@entries.shift if @entries.size > @max
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Return the N most recent entries.
|
|
29
|
+
def recent(n = 5)
|
|
30
|
+
@entries.last(n)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Return all entries of a given type.
|
|
34
|
+
def by_type(type)
|
|
35
|
+
@entries.select { |e| e.type == type.to_sym }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Last N tool calls (name + args)
|
|
39
|
+
def recent_tool_calls(n = 5)
|
|
40
|
+
by_type(:tool_call).last(n)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# True if the same tool+args was called recently (loop hint).
|
|
44
|
+
def recently_called?(tool_name, args, window: 6)
|
|
45
|
+
recent(window).any? do |e|
|
|
46
|
+
e.type == :tool_call &&
|
|
47
|
+
e.content[:tool] == tool_name.to_s &&
|
|
48
|
+
e.content[:args].to_s == args.to_s
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def size
|
|
53
|
+
@entries.size
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def clear!
|
|
57
|
+
@entries.clear
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_a
|
|
62
|
+
@entries.map(&:to_h)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module OllamaAgent
|
|
8
|
+
# Fetches the public Ollama cloud model catalog (+/api/tags+ on ollama.com).
|
|
9
|
+
#
|
|
10
|
+
# See https://docs.ollama.com/cloud — list models with +curl https://ollama.com/api/tags+.
|
|
11
|
+
# Optional +OLLAMA_API_KEY+ is sent as +Authorization: Bearer+ when set.
|
|
12
|
+
module OllamaCloudCatalog
|
|
13
|
+
DEFAULT_TAGS_URL = "https://ollama.com/api/tags"
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param base_url [String, nil] override catalog host (default +ollama.com+ or +OLLAMA_AGENT_CLOUD_CATALOG_URL+)
|
|
18
|
+
# @param api_key [String, nil] Bearer token (default +ENV["OLLAMA_API_KEY"]+)
|
|
19
|
+
# @return [Array<String>] sorted unique model names, or empty on failure
|
|
20
|
+
def list_model_names(base_url: nil, api_key: nil, open_timeout: 5, read_timeout: 20)
|
|
21
|
+
uri = catalog_uri(base_url)
|
|
22
|
+
return [] unless uri
|
|
23
|
+
|
|
24
|
+
key = api_key_string(api_key)
|
|
25
|
+
res = http_get(uri, api_key: key, open_timeout: open_timeout, read_timeout: read_timeout)
|
|
26
|
+
return [] unless res.is_a?(Net::HTTPSuccess)
|
|
27
|
+
|
|
28
|
+
names_from_tags_json(res.body)
|
|
29
|
+
rescue StandardError
|
|
30
|
+
[]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param body [String] raw JSON from +/api/tags+
|
|
34
|
+
# @return [Array<String>]
|
|
35
|
+
def names_from_tags_json(body)
|
|
36
|
+
parsed = JSON.parse(body.to_s)
|
|
37
|
+
models = parsed["models"] || []
|
|
38
|
+
models.filter_map { |m| m["name"] }.uniq.sort
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def catalog_uri(base_url)
|
|
42
|
+
raw = base_url || ENV.fetch("OLLAMA_AGENT_CLOUD_CATALOG_URL", nil)
|
|
43
|
+
raw = DEFAULT_TAGS_URL if raw.nil? || raw.to_s.strip.empty?
|
|
44
|
+
URI(raw.to_s.strip)
|
|
45
|
+
rescue URI::InvalidURIError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def api_key_string(api_key)
|
|
50
|
+
k = api_key || ENV.fetch("OLLAMA_API_KEY", nil)
|
|
51
|
+
k.to_s.strip
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def http_get(uri, api_key:, open_timeout:, read_timeout:)
|
|
55
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
56
|
+
http.use_ssl = uri.scheme == "https"
|
|
57
|
+
http.open_timeout = open_timeout
|
|
58
|
+
http.read_timeout = read_timeout
|
|
59
|
+
|
|
60
|
+
req = Net::HTTP::Get.new(uri)
|
|
61
|
+
req["Authorization"] = "Bearer #{api_key}" unless api_key.empty?
|
|
62
|
+
|
|
63
|
+
http.request(req)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -15,5 +15,35 @@ module OllamaAgent
|
|
|
15
15
|
model = ModelEnv.resolved_model_from_env
|
|
16
16
|
config.model = model if model
|
|
17
17
|
end
|
|
18
|
+
|
|
19
|
+
# Builds an +Ollama::Client+ wrapped in {Resilience::RetryMiddleware}.
|
|
20
|
+
#
|
|
21
|
+
# @param timeout [Numeric] read/open timeout seconds
|
|
22
|
+
# @param max_attempts [Integer] retry attempts for the middleware
|
|
23
|
+
# @param base_url [String, nil] optional explicit API base URL (e.g. from +OLLAMA_HOST+); +nil+ keeps config default
|
|
24
|
+
# @param hooks [Streaming::Hooks, nil] optional hooks for retry events
|
|
25
|
+
# @param base_delay [Float, nil] backoff base; default from {Resilience::RetryMiddleware}
|
|
26
|
+
# @return [Resilience::RetryMiddleware]
|
|
27
|
+
def self.retry_wrapped_client(timeout:, max_attempts:, base_url: nil, hooks: nil, base_delay: nil)
|
|
28
|
+
require "ollama_client"
|
|
29
|
+
require_relative "resilience/retry_middleware"
|
|
30
|
+
|
|
31
|
+
inner = Ollama::Client.new(config: config_for_client(base_url: base_url, timeout: timeout))
|
|
32
|
+
Resilience::RetryMiddleware.new(
|
|
33
|
+
client: inner,
|
|
34
|
+
max_attempts: max_attempts,
|
|
35
|
+
hooks: hooks,
|
|
36
|
+
base_delay: base_delay || Resilience::RetryMiddleware::DEFAULT_BASE_DELAY
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.config_for_client(base_url:, timeout:)
|
|
41
|
+
config = Ollama::Config.new
|
|
42
|
+
config.base_url = base_url if base_url && !base_url.to_s.strip.empty?
|
|
43
|
+
config.timeout = timeout
|
|
44
|
+
apply_env_to_config(config)
|
|
45
|
+
config
|
|
46
|
+
end
|
|
47
|
+
private_class_method :config_for_client
|
|
18
48
|
end
|
|
19
49
|
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
|
|
5
|
+
module OllamaAgent
|
|
6
|
+
module Plugins
|
|
7
|
+
# Loads plugins from:
|
|
8
|
+
# 1. OLLAMA_AGENT_PLUGINS env var (comma-separated require paths)
|
|
9
|
+
# 2. ~/.config/ollama_agent/plugins.rb (user global plugins)
|
|
10
|
+
# 3. <project_root>/.ollama_agent/plugins.rb (project-local plugins)
|
|
11
|
+
# 4. Gem-installed plugins with name matching "ollama_agent_*"
|
|
12
|
+
#
|
|
13
|
+
# Each loaded file is expected to call:
|
|
14
|
+
# OllamaAgent::Plugins::Registry.register(:my_plugin) { |r| ... }
|
|
15
|
+
class Loader
|
|
16
|
+
GLOBAL_PLUGINS_PATH = File.join(Dir.home, ".config", "ollama_agent", "plugins.rb")
|
|
17
|
+
GEM_PLUGIN_PREFIX = "ollama_agent_"
|
|
18
|
+
|
|
19
|
+
def initialize(root: Dir.pwd, registry: nil)
|
|
20
|
+
@root = File.expand_path(root)
|
|
21
|
+
@registry = registry || Registry.instance
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Load all applicable plugins.
|
|
25
|
+
# @param skip_gems [Boolean] skip gem-installed plugins (useful in tests)
|
|
26
|
+
# @return [Array<String>] paths/names of successfully loaded plugins
|
|
27
|
+
def load_all(skip_gems: false)
|
|
28
|
+
loaded = []
|
|
29
|
+
|
|
30
|
+
loaded.concat(load_env_plugins)
|
|
31
|
+
loaded.concat(load_file_plugin(GLOBAL_PLUGINS_PATH))
|
|
32
|
+
loaded.concat(load_file_plugin(project_plugins_path))
|
|
33
|
+
loaded.concat(load_gem_plugins) unless skip_gems
|
|
34
|
+
|
|
35
|
+
loaded
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Load a single plugin from a require path or file.
|
|
39
|
+
def load_plugin(path_or_require)
|
|
40
|
+
if File.exist?(path_or_require)
|
|
41
|
+
require File.expand_path(path_or_require)
|
|
42
|
+
else
|
|
43
|
+
require path_or_require
|
|
44
|
+
end
|
|
45
|
+
[path_or_require]
|
|
46
|
+
rescue LoadError => e
|
|
47
|
+
warn "ollama_agent: plugin load failed (#{path_or_require}): #{e.message}" if debug?
|
|
48
|
+
[]
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
warn "ollama_agent: plugin error (#{path_or_require}): #{e.message}"
|
|
51
|
+
[]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def load_env_plugins
|
|
57
|
+
env_val = ENV.fetch("OLLAMA_AGENT_PLUGINS", "").strip
|
|
58
|
+
return [] if env_val.empty?
|
|
59
|
+
|
|
60
|
+
env_val.split(",").flat_map { |p| load_plugin(p.strip) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def load_file_plugin(path)
|
|
64
|
+
return [] unless File.exist?(path)
|
|
65
|
+
|
|
66
|
+
load_plugin(path)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def load_gem_plugins
|
|
70
|
+
return [] unless defined?(Gem)
|
|
71
|
+
|
|
72
|
+
loaded = []
|
|
73
|
+
Gem::Specification.each do |spec|
|
|
74
|
+
next unless spec.name.start_with?(GEM_PLUGIN_PREFIX)
|
|
75
|
+
|
|
76
|
+
plugin_file = File.join(spec.gem_dir, "lib", "#{spec.name}.rb")
|
|
77
|
+
next unless File.exist?(plugin_file)
|
|
78
|
+
|
|
79
|
+
loaded.concat(load_plugin(plugin_file))
|
|
80
|
+
end
|
|
81
|
+
loaded
|
|
82
|
+
rescue StandardError
|
|
83
|
+
[]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def project_plugins_path
|
|
87
|
+
File.join(@root, ".ollama_agent", "plugins.rb")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def debug?
|
|
91
|
+
ENV["OLLAMA_AGENT_DEBUG"] == "1"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OllamaAgent
|
|
4
|
+
module Plugins
|
|
5
|
+
# Central plugin registry.
|
|
6
|
+
#
|
|
7
|
+
# A plugin is any object (module, class, or struct) that can register
|
|
8
|
+
# extensions at one or more extension points.
|
|
9
|
+
#
|
|
10
|
+
# Extension points:
|
|
11
|
+
# :tools — Array<Tools::Base subclass instances>
|
|
12
|
+
# :prompts — Array<Hash { name:, content: }>
|
|
13
|
+
# :policies — Array<Proc(tool, args, ctx) → nil | String>
|
|
14
|
+
# :providers — Array<Providers::Base instances>
|
|
15
|
+
# :postprocessors — Array<Proc(response) → response>
|
|
16
|
+
# :memory_adapters — Array<Memory::Base-like objects>
|
|
17
|
+
# :command_handlers — Array<Hash { slash_command:, handler: Proc }>
|
|
18
|
+
#
|
|
19
|
+
# @example Register a plugin inline
|
|
20
|
+
# OllamaAgent::Plugins::Registry.register(:my_plugin) do |r|
|
|
21
|
+
# r.extend(:tools, MyTool.new)
|
|
22
|
+
# r.extend(:prompts, name: "code_review", content: File.read("prompts/review.md"))
|
|
23
|
+
# end
|
|
24
|
+
class Registry
|
|
25
|
+
EXTENSION_POINTS = %i[
|
|
26
|
+
tools prompts policies providers
|
|
27
|
+
postprocessors memory_adapters command_handlers
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def instance
|
|
32
|
+
@instance ||= new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Delegate class methods to the singleton.
|
|
36
|
+
def register(name, plugin = nil, &block) = instance.register(name, plugin, &block)
|
|
37
|
+
def extensions_for(point) = instance.extensions_for(point)
|
|
38
|
+
def all_tools = instance.extensions_for(:tools)
|
|
39
|
+
def all_prompts = instance.extensions_for(:prompts)
|
|
40
|
+
def all_policies = instance.extensions_for(:policies)
|
|
41
|
+
def all_providers = instance.extensions_for(:providers)
|
|
42
|
+
def all_postprocessors = instance.extensions_for(:postprocessors)
|
|
43
|
+
def all_command_handlers = instance.extensions_for(:command_handlers)
|
|
44
|
+
def plugin_names = instance.plugin_names
|
|
45
|
+
def reset! = instance.reset!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize
|
|
49
|
+
reset!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Register a plugin by name.
|
|
53
|
+
# @param name [Symbol, String] unique plugin identifier
|
|
54
|
+
# @param plugin [Object, nil] plugin object (must respond to #register(registry))
|
|
55
|
+
# @param block [Proc] alternative: inline registration block
|
|
56
|
+
def register(name, plugin = nil, &block)
|
|
57
|
+
name = name.to_sym
|
|
58
|
+
raise ArgumentError, "Plugin #{name} is already registered" if @plugins.key?(name)
|
|
59
|
+
|
|
60
|
+
@plugins[name] = plugin
|
|
61
|
+
|
|
62
|
+
if block
|
|
63
|
+
block.call(self)
|
|
64
|
+
elsif plugin.respond_to?(:register)
|
|
65
|
+
plugin.register(self)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Add an extension at a specific extension point.
|
|
70
|
+
# @param point [Symbol] one of EXTENSION_POINTS
|
|
71
|
+
# @param handler [Object] see per-point documentation above
|
|
72
|
+
def extend(point, handler)
|
|
73
|
+
point = point.to_sym
|
|
74
|
+
unless EXTENSION_POINTS.include?(point)
|
|
75
|
+
raise ArgumentError,
|
|
76
|
+
"Unknown extension point: #{point}. Valid: #{EXTENSION_POINTS.join(", ")}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@extensions[point] << handler
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Shorthand helpers
|
|
83
|
+
def add_tool(tool) = extend(:tools, tool)
|
|
84
|
+
def add_prompt(hash) = extend(:prompts, hash)
|
|
85
|
+
def add_policy(&block) = extend(:policies, block)
|
|
86
|
+
def add_provider(provider) = extend(:providers, provider)
|
|
87
|
+
def add_command(slash_command:, &handler) = extend(:command_handlers, { slash_command: slash_command, handler: handler })
|
|
88
|
+
|
|
89
|
+
def extensions_for(point)
|
|
90
|
+
@extensions[point.to_sym].dup
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def plugin_names
|
|
94
|
+
@plugins.keys
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def reset!
|
|
98
|
+
@plugins = {}
|
|
99
|
+
@extensions = Hash.new { |h, k| h[k] = [] }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|