forked from molecule-ai/molecule-core
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1) with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo. Brand: Starfire → Molecule AI. Slug: starfire / agent-molecule → molecule. Env vars: STARFIRE_* → MOLECULE_*. Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform. Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent. DB: agentmolecule → molecule. History truncated; see public repo for prior commits and contributor attribution. Verified green: go test -race ./... (platform), pytest (workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
5.9 KiB
Python
155 lines
5.9 KiB
Python
"""Plugin system for loading per-workspace and shared plugins.
|
|
|
|
Plugins provide skills, rules, and prompt fragments to agent workspaces.
|
|
Each plugin is a directory containing:
|
|
- plugin.yaml — manifest (name, version, description, skills, rules)
|
|
- rules/*.md — always-on guidelines injected into every prompt
|
|
- skills/ — skill directories with SKILL.md + tools/*.py
|
|
- *.md — prompt fragments (excluding README, CHANGELOG, etc.)
|
|
|
|
Loading priority:
|
|
1. Per-workspace: /configs/plugins/<name>/ (installed via API)
|
|
2. Shared fallback: /plugins/<name>/ (legacy bind mount)
|
|
Deduplication by name — per-workspace wins.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from dataclasses import dataclass, field
|
|
|
|
import yaml
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WORKSPACE_PLUGINS_DIR = "/configs/plugins"
|
|
SHARED_PLUGINS_DIR = os.environ.get("PLUGINS_DIR", "/plugins")
|
|
|
|
|
|
@dataclass
|
|
class PluginManifest:
|
|
name: str = ""
|
|
version: str = "0.0.0"
|
|
description: str = ""
|
|
author: str = ""
|
|
tags: list[str] = field(default_factory=list)
|
|
skills: list[str] = field(default_factory=list)
|
|
rules: list[str] = field(default_factory=list)
|
|
prompt_fragments: list[str] = field(default_factory=list)
|
|
adapters: dict = field(default_factory=dict)
|
|
runtimes: list[str] = field(default_factory=list) # declared supported runtimes
|
|
|
|
|
|
@dataclass
|
|
class Plugin:
|
|
name: str
|
|
path: str
|
|
manifest: PluginManifest = field(default_factory=PluginManifest)
|
|
rules: list[str] = field(default_factory=list) # rule content strings
|
|
prompt_fragments: list[str] = field(default_factory=list) # extra prompt content
|
|
skills_dir: str = "" # path to skills/ inside plugin
|
|
|
|
|
|
@dataclass
|
|
class LoadedPlugins:
|
|
rules: list[str] = field(default_factory=list)
|
|
prompt_fragments: list[str] = field(default_factory=list)
|
|
skill_dirs: list[str] = field(default_factory=list) # dirs to scan for extra skills
|
|
plugin_names: list[str] = field(default_factory=list)
|
|
plugins: list[Plugin] = field(default_factory=list)
|
|
|
|
|
|
def load_plugin_manifest(plugin_path: str) -> PluginManifest:
|
|
"""Parse plugin.yaml from a plugin directory. Returns empty manifest if not found."""
|
|
manifest_file = os.path.join(plugin_path, "plugin.yaml")
|
|
if not os.path.isfile(manifest_file):
|
|
return PluginManifest(name=os.path.basename(plugin_path))
|
|
try:
|
|
with open(manifest_file) as f:
|
|
raw = yaml.safe_load(f) or {}
|
|
return PluginManifest(
|
|
name=raw.get("name", os.path.basename(plugin_path)),
|
|
version=raw.get("version", "0.0.0"),
|
|
description=raw.get("description", ""),
|
|
author=raw.get("author", ""),
|
|
tags=raw.get("tags", []),
|
|
skills=raw.get("skills", []),
|
|
rules=raw.get("rules", []),
|
|
prompt_fragments=raw.get("prompt_fragments", []),
|
|
adapters=raw.get("adapters", {}),
|
|
runtimes=raw.get("runtimes", []),
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to parse plugin manifest %s: %s", manifest_file, e)
|
|
return PluginManifest(name=os.path.basename(plugin_path))
|
|
|
|
|
|
def _load_single_plugin(plugin_path: str) -> Plugin:
|
|
"""Load a single plugin from a directory."""
|
|
name = os.path.basename(plugin_path)
|
|
manifest = load_plugin_manifest(plugin_path)
|
|
plugin = Plugin(name=name, path=plugin_path, manifest=manifest)
|
|
|
|
# Load rules
|
|
rules_dir = os.path.join(plugin_path, "rules")
|
|
if os.path.isdir(rules_dir):
|
|
for rule_file in sorted(os.listdir(rules_dir)):
|
|
if rule_file.endswith(".md"):
|
|
content = Path(os.path.join(rules_dir, rule_file)).read_text().strip()
|
|
if content:
|
|
plugin.rules.append(content)
|
|
logger.info("Plugin %s: loaded rule %s", name, rule_file)
|
|
|
|
# Load prompt fragments (any .md in root of plugin)
|
|
skip = {"readme.md", "changelog.md", "license.md", "contributing.md", "plugin.yaml"}
|
|
for f in sorted(os.listdir(plugin_path)):
|
|
if f.endswith(".md") and f.lower() not in skip and os.path.isfile(os.path.join(plugin_path, f)):
|
|
content = Path(os.path.join(plugin_path, f)).read_text().strip()
|
|
if content:
|
|
plugin.prompt_fragments.append(content)
|
|
logger.info("Plugin %s: loaded prompt fragment %s", name, f)
|
|
|
|
# Register skills directory
|
|
skills_dir = os.path.join(plugin_path, "skills")
|
|
if os.path.isdir(skills_dir):
|
|
plugin.skills_dir = skills_dir
|
|
skill_count = len([d for d in os.listdir(skills_dir) if os.path.isdir(os.path.join(skills_dir, d))])
|
|
logger.info("Plugin %s: found %d skills", name, skill_count)
|
|
|
|
return plugin
|
|
|
|
|
|
def load_plugins(
|
|
workspace_plugins_dir: str | None = None,
|
|
shared_plugins_dir: str | None = None,
|
|
) -> LoadedPlugins:
|
|
"""Scan per-workspace plugins first, then shared plugins. Deduplicate by name."""
|
|
ws_dir = workspace_plugins_dir or WORKSPACE_PLUGINS_DIR
|
|
shared_dir = shared_plugins_dir or SHARED_PLUGINS_DIR
|
|
result = LoadedPlugins()
|
|
seen_names: set[str] = set()
|
|
|
|
# Scan both dirs: per-workspace first (higher priority)
|
|
for base_dir in [ws_dir, shared_dir]:
|
|
if not os.path.isdir(base_dir):
|
|
continue
|
|
for entry in sorted(os.listdir(base_dir)):
|
|
plugin_path = os.path.join(base_dir, entry)
|
|
if not os.path.isdir(plugin_path) or entry in seen_names:
|
|
continue
|
|
|
|
plugin = _load_single_plugin(plugin_path)
|
|
seen_names.add(entry)
|
|
|
|
result.rules.extend(plugin.rules)
|
|
result.prompt_fragments.extend(plugin.prompt_fragments)
|
|
if plugin.skills_dir:
|
|
result.skill_dirs.append(plugin.skills_dir)
|
|
result.plugin_names.append(entry)
|
|
result.plugins.append(plugin)
|
|
|
|
if result.plugin_names:
|
|
logger.info("Loaded %d plugins: %s", len(result.plugin_names), ", ".join(result.plugin_names))
|
|
|
|
return result
|