Files
Mark-LIV/core/plugin_loader.py
T
2026-08-20 17:58:29 +03:00

189 lines
7.7 KiB
Python

"""
Plugin discovery, validation, collision detection, and dispatch.
Discovery runs once (JarvisLive.__init__ calls discover_plugins()); the resulting
PluginRegistry is cached for the process lifetime. Enable/disable state is re-read
from config on every call to get_tool_declarations() / run() / list_for_ui(), so
toggling a plugin does not require restarting the app or re-importing anything.
"""
from __future__ import annotations
import importlib.util
import inspect
import re
import sys
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
from memory.config_manager import get_plugin_enabled
_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
_DEFAULT_PARAMS = {"type": "OBJECT", "properties": {}}
@dataclass
class PluginRecord:
name: str
description: str = ""
parameters: dict = field(default_factory=lambda: dict(_DEFAULT_PARAMS))
run: Optional[Callable] = None
file: str = ""
valid: bool = False
error: str = ""
class PluginRegistry:
def __init__(self, plugins: dict[str, PluginRecord], logger: Callable[[str], None]):
self._plugins = plugins # name -> PluginRecord, VALID entries only
self._all_records: list[PluginRecord] = [] # valid + invalid, for UI listing
self._logger = logger
# -- called by main.py at LiveConnectConfig build time --
def get_tool_declarations(self) -> list[dict]:
decls = []
for name, rec in self._plugins.items():
if get_plugin_enabled(name):
decls.append({
"name": rec.name,
"description": rec.description,
"parameters": rec.parameters,
})
return decls
def has(self, name: str) -> bool:
return name in self._plugins
# -- called by main.py from _execute_tool's else branch --
def run(self, name: str, parameters: dict, player=None, session_memory=None) -> str:
rec = self._plugins.get(name)
if rec is None or not rec.valid:
return f"Plugin '{name}' is not available."
if not get_plugin_enabled(name):
return f"The '{name}' plugin is currently disabled."
try:
return _call_run(rec.run, parameters, player, session_memory) or "Done."
except Exception as e:
self._logger(f"Plugin '{name}' crashed during run(): {e}")
traceback.print_exc()
return f"Sir, the '{name}' plugin failed: {e}"
# -- called by ui.py's Plugin Manager overlay --
def list_for_ui(self) -> list[dict]:
out = []
for rec in self._all_records:
out.append({
"name": rec.name,
"description": rec.description,
"file": rec.file,
"valid": rec.valid,
"error": rec.error,
"enabled": get_plugin_enabled(rec.name) if rec.valid else False,
})
return out
def _call_run(run_fn, parameters, player, session_memory):
"""Invoke run() passing only the kwargs it actually declares (or all of them
if it has **kwargs), so a minimal `def run(parameters):` plugin still works."""
sig = inspect.signature(run_fn)
has_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
kwargs = {}
if has_var_kw or "player" in sig.parameters:
kwargs["player"] = player
if has_var_kw or "session_memory" in sig.parameters:
kwargs["session_memory"] = session_memory
return run_fn(parameters, **kwargs)
def _validate(module, filename: str) -> PluginRecord:
"""Returns a PluginRecord; .valid=False + .error set on any problem. Never raises."""
plugin_meta = getattr(module, "PLUGIN", None)
if not isinstance(plugin_meta, dict):
return PluginRecord(name=Path(filename).stem, file=filename,
error="Missing PLUGIN dict constant.")
name = plugin_meta.get("name")
if not isinstance(name, str) or not _NAME_RE.match(name):
return PluginRecord(name=str(name or Path(filename).stem), file=filename,
error="PLUGIN['name'] missing or not a valid identifier "
"(letters/digits/underscore, must start with letter/underscore).")
description = plugin_meta.get("description")
if not isinstance(description, str) or not description.strip():
return PluginRecord(name=name, file=filename,
error="PLUGIN['description'] missing or empty.")
parameters = plugin_meta.get("parameters", _DEFAULT_PARAMS)
if not isinstance(parameters, dict) or parameters.get("type") != "OBJECT":
return PluginRecord(name=name, file=filename,
error="PLUGIN['parameters'] must be a dict with \"type\": \"OBJECT\".")
run_fn = getattr(module, "run", None)
if not callable(run_fn):
return PluginRecord(name=name, file=filename,
error="Missing callable run(parameters, ...) function.")
return PluginRecord(name=name, description=description.strip(), parameters=parameters,
run=run_fn, file=filename, valid=True, error="")
def discover_plugins(plugins_dir: Path, core_tool_names: set[str],
logger: Callable[[str], None] = print) -> PluginRegistry:
"""
Scans plugins_dir for *.py files (skips files starting with '_', e.g. __init__.py,
_template.py, and any shared-helper modules an author prefixes with '_').
Import errors, validation errors, and name collisions are logged and the offending
file is skipped — they NEVER raise out of this function and never abort the scan
of remaining files.
"""
plugins_dir.mkdir(parents=True, exist_ok=True)
valid: dict[str, PluginRecord] = {}
all_records: list[PluginRecord] = []
files = sorted(plugins_dir.glob("*.py"), key=lambda p: p.name) # deterministic order
for path in files:
if path.name.startswith("_"):
continue
try:
module_name = f"plugins.{path.stem}"
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise ImportError("could not build import spec")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except Exception:
sys.modules.pop(module_name, None)
raise
rec = _validate(module, path.name)
if rec.valid and rec.name in core_tool_names:
rec = PluginRecord(name=rec.name, file=path.name,
error=f"Name '{rec.name}' collides with a core tool — rejected.")
elif rec.valid and rec.name in valid:
other = valid[rec.name].file
rec = PluginRecord(name=rec.name, file=path.name,
error=f"Name '{rec.name}' already used by plugin '{other}' — rejected.")
except Exception as e:
rec = PluginRecord(name=path.stem, file=path.name,
error=f"Failed to load: {e}")
traceback.print_exc()
all_records.append(rec)
if rec.valid:
valid[rec.name] = rec
logger(f"Plugin loaded: {rec.name} ({path.name})")
else:
logger(f"Plugin rejected: {path.name} — {rec.error}")
registry = PluginRegistry(valid, logger)
registry._all_records = all_records
logger(f"Plugin discovery complete: {len(valid)} active, "
f"{len(all_records) - len(valid)} rejected, {len(all_records)} total.")
return registry