molecule-core/workspace-template/plugins.py
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
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>
2026-04-13 11:55:37 -07:00

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