chore: merge origin/main into mintlify/af82bda4 and resolve conflicts
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Build and validate self-contained Mem0 agent plugins."""
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .validate import validate_bundle
|
||||
except ImportError:
|
||||
from validate import validate_bundle
|
||||
|
||||
CORE_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = CORE_ROOT.parents[1]
|
||||
INTEGRATIONS_ROOT = REPOSITORY_ROOT / "integrations"
|
||||
SHARED_SKILLS = CORE_ROOT / "skills"
|
||||
PORTABLE_PLUGIN = "mem0-agent-plugin"
|
||||
NATIVE_PLUGINS = {
|
||||
"claude-code": INTEGRATIONS_ROOT / "claude-code-plugin",
|
||||
"cursor": INTEGRATIONS_ROOT / "cursor-plugin",
|
||||
"codex": INTEGRATIONS_ROOT / "codex-plugin",
|
||||
"kimi": INTEGRATIONS_ROOT / "kimi-plugin",
|
||||
"antigravity": INTEGRATIONS_ROOT / "antigravity-plugin",
|
||||
}
|
||||
PROTECTED_OUTPUTS = {
|
||||
REPOSITORY_ROOT,
|
||||
INTEGRATIONS_ROOT,
|
||||
CORE_ROOT,
|
||||
INTEGRATIONS_ROOT / PORTABLE_PLUGIN,
|
||||
*NATIVE_PLUGINS.values(),
|
||||
}
|
||||
TEMPLATE_TOKEN = re.compile(r"{{([A-Z_]+)}}")
|
||||
TEMPLATE_TOKENS = {
|
||||
"PLUGIN_ROOT",
|
||||
"PLUGIN_DATA",
|
||||
"PLUGIN_DATA_ARG",
|
||||
"COMMAND_PREFIX",
|
||||
"HARNESS_ID",
|
||||
"HARNESS_NAME",
|
||||
}
|
||||
|
||||
|
||||
def render_template(source: str, values: Mapping[str, str]) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
token = match.group(1)
|
||||
if token not in TEMPLATE_TOKENS or token not in values:
|
||||
raise ValueError(f"unknown or unresolved template token: {token}")
|
||||
return values[token]
|
||||
|
||||
rendered = TEMPLATE_TOKEN.sub(replace, source)
|
||||
if "{{" in rendered or "}}" in rendered:
|
||||
raise ValueError("unresolved template token")
|
||||
return rendered
|
||||
|
||||
|
||||
def replace_output(staged: Path, output: Path) -> Path:
|
||||
"""Replace one explicit build output without touching its siblings."""
|
||||
if not staged.is_dir():
|
||||
raise ValueError(f"staged directory does not exist: {staged}")
|
||||
|
||||
resolved_output = output.resolve()
|
||||
if resolved_output in {path.resolve() for path in PROTECTED_OUTPUTS}:
|
||||
raise ValueError(f"refusing protected output path: {output}")
|
||||
if output.exists() and not output.is_dir():
|
||||
raise ValueError(f"output path is not a directory: {output}")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = Path(tempfile.mkdtemp(prefix=f".{output.name}-", dir=output.parent))
|
||||
try:
|
||||
shutil.copytree(staged, temporary, dirs_exist_ok=True)
|
||||
if output.exists():
|
||||
shutil.rmtree(output)
|
||||
temporary.replace(output)
|
||||
finally:
|
||||
if temporary.exists():
|
||||
shutil.rmtree(temporary)
|
||||
return output
|
||||
|
||||
|
||||
def _bundle_python(
|
||||
staged: Path,
|
||||
host: str,
|
||||
plugin_root: str,
|
||||
*,
|
||||
plugin_data: str = "",
|
||||
portable: bool = False,
|
||||
) -> None:
|
||||
core = staged / "core"
|
||||
core.mkdir()
|
||||
for source in sorted((CORE_ROOT / "python").glob("*.py")):
|
||||
if portable and source.name in {"flush_worker.py", "hook_runner.py"}:
|
||||
continue
|
||||
shutil.copy2(source, core / source.name)
|
||||
|
||||
values = {
|
||||
"PLUGIN_ROOT": plugin_root,
|
||||
"PLUGIN_DATA": "${PLUGIN_DATA}",
|
||||
"PLUGIN_DATA_ARG": f'--plugin-data-dir "{plugin_data}"' if plugin_data else "",
|
||||
"COMMAND_PREFIX": "mem0",
|
||||
"HARNESS_ID": host,
|
||||
"HARNESS_NAME": host.replace("-", " ").title(),
|
||||
}
|
||||
for source in sorted(SHARED_SKILLS.glob("*/SKILL.md.tmpl")):
|
||||
target = staged / "skills" / source.parent.name / "SKILL.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
rendered = render_template(source.read_text(encoding="utf-8"), values)
|
||||
if portable:
|
||||
rendered = "\n".join(
|
||||
line
|
||||
for line in rendered.splitlines()
|
||||
if not line.startswith(("argument-hint:", "disable-model-invocation:"))
|
||||
) + "\n"
|
||||
target.write_text(rendered, encoding="utf-8")
|
||||
|
||||
|
||||
def _build_portable(staged: Path) -> None:
|
||||
source = INTEGRATIONS_ROOT / PORTABLE_PLUGIN
|
||||
_copy_declared_files(staged, source, {"plugin.json": "plugin.json", "mcp.json": "mcp.json"})
|
||||
_bundle_python(staged, "coding-agent", "${PLUGIN_ROOT}", portable=True)
|
||||
|
||||
|
||||
def _copy_declared_files(staged: Path, source_root: Path, files: object) -> None:
|
||||
if not isinstance(files, dict):
|
||||
raise ValueError("native files must be an object")
|
||||
source_root = source_root.resolve()
|
||||
staged_root = staged.resolve()
|
||||
for source_name, target_name in files.items():
|
||||
if not isinstance(source_name, str) or not isinstance(target_name, str):
|
||||
raise ValueError("native file paths must be strings")
|
||||
source = (source_root / source_name).resolve()
|
||||
target = (staged / target_name).resolve()
|
||||
if not source.is_relative_to(source_root) or not target.is_relative_to(staged_root):
|
||||
raise ValueError("native file paths must stay inside their roots")
|
||||
if not source.is_file():
|
||||
raise ValueError(f"native source file does not exist: {source_name}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
|
||||
def _build_native(host: str, source_root: Path, staged: Path, descriptor: dict) -> None:
|
||||
native = descriptor.get("native")
|
||||
if not isinstance(native, dict) or not isinstance(native.get("pluginRoot"), str):
|
||||
raise ValueError(f"native build is not declared for {host}")
|
||||
_bundle_python(
|
||||
staged,
|
||||
host,
|
||||
native["pluginRoot"],
|
||||
plugin_data=str(native.get("pluginData") or ""),
|
||||
)
|
||||
_copy_declared_files(staged, source_root, native.get("files", {}))
|
||||
|
||||
|
||||
def build(host: str, kind: str, output: Path) -> Path:
|
||||
if kind not in {"portable", "native"}:
|
||||
raise ValueError(f"unknown bundle kind: {kind}")
|
||||
if kind == "portable":
|
||||
if host != PORTABLE_PLUGIN:
|
||||
raise ValueError(f"the portable bundle is {PORTABLE_PLUGIN}")
|
||||
source_root = INTEGRATIONS_ROOT / PORTABLE_PLUGIN
|
||||
descriptor = None
|
||||
else:
|
||||
source_root = NATIVE_PLUGINS.get(host)
|
||||
if source_root is None:
|
||||
raise ValueError(f"unknown host: {host}")
|
||||
descriptor_path = source_root / "plugin-build.json"
|
||||
descriptor = json.loads(descriptor_path.read_text(encoding="utf-8"))
|
||||
with tempfile.TemporaryDirectory(prefix=f"mem0-{host}-{kind}-") as temporary:
|
||||
staged = Path(temporary) / "bundle"
|
||||
staged.mkdir()
|
||||
if kind == "portable":
|
||||
_build_portable(staged)
|
||||
else:
|
||||
assert descriptor is not None
|
||||
_build_native(host, source_root, staged, descriptor)
|
||||
errors = validate_bundle(staged, kind)
|
||||
if errors:
|
||||
raise ValueError("invalid bundle:\n" + "\n".join(errors))
|
||||
return replace_output(staged, output)
|
||||
|
||||
|
||||
def installable_root(host: str, kind: str) -> Path:
|
||||
if kind == "portable" and host == PORTABLE_PLUGIN:
|
||||
return INTEGRATIONS_ROOT / PORTABLE_PLUGIN
|
||||
if kind == "native" and host in NATIVE_PLUGINS:
|
||||
return NATIVE_PLUGINS[host]
|
||||
raise ValueError(f"unknown {kind} plugin: {host}")
|
||||
|
||||
|
||||
def bundle_drift(host: str, kind: str) -> list[str]:
|
||||
target = installable_root(host, kind)
|
||||
with tempfile.TemporaryDirectory(prefix=f"mem0-check-{host}-") as temporary:
|
||||
generated = build(host, kind, Path(temporary) / "bundle")
|
||||
errors: list[str] = []
|
||||
for directory in ("core", "skills"):
|
||||
expected = {
|
||||
path.relative_to(generated)
|
||||
for path in (generated / directory).rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
actual = {
|
||||
path.relative_to(target)
|
||||
for path in (target / directory).rglob("*")
|
||||
if path.is_file() and "__pycache__" not in path.parts and path.suffix != ".pyc"
|
||||
}
|
||||
errors.extend(f"missing generated file: {path}" for path in sorted(expected - actual))
|
||||
errors.extend(f"stale generated file: {path}" for path in sorted(actual - expected))
|
||||
for source in sorted(path for path in generated.rglob("*") if path.is_file()):
|
||||
relative = source.relative_to(generated)
|
||||
installed = target / relative
|
||||
if not installed.is_file() or source.read_bytes() != installed.read_bytes():
|
||||
errors.append(f"generated file differs: {relative}")
|
||||
return errors
|
||||
|
||||
|
||||
def sync_generated(host: str, kind: str) -> Path:
|
||||
target = installable_root(host, kind)
|
||||
with tempfile.TemporaryDirectory(prefix=f"mem0-sync-{host}-") as temporary:
|
||||
generated = build(host, kind, Path(temporary) / "bundle")
|
||||
for directory in ("core", "skills"):
|
||||
replace_output(generated / directory, target / directory)
|
||||
return target
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build a self-contained Mem0 agent plugin")
|
||||
parser.add_argument("host")
|
||||
parser.add_argument("--kind", choices=("portable", "native"), required=True)
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument("--output", type=Path)
|
||||
action.add_argument("--check", action="store_true")
|
||||
action.add_argument("--sync", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.check:
|
||||
errors = bundle_drift(args.host, args.kind)
|
||||
if errors:
|
||||
print("\n".join(errors))
|
||||
return 1
|
||||
print(f"Current {args.kind} bundle: {installable_root(args.host, args.kind)}")
|
||||
elif args.sync:
|
||||
print(sync_generated(args.host, args.kind))
|
||||
else:
|
||||
assert args.output is not None
|
||||
print(build(args.host, args.kind, args.output))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"title": "Agent Plugins MCP Configuration",
|
||||
"description": "Machine-readable schema for mcp.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"description": "Canonical identifier of the MCP configuration schema for the Agent Plugins version targeted by this document."
|
||||
},
|
||||
"mcpServers": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "$ref": "#/$defs/server" }
|
||||
}
|
||||
},
|
||||
"required": ["$schema", "mcpServers"],
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"server": {
|
||||
"title": "MCP server",
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/stdioServer" },
|
||||
{ "$ref": "#/$defs/streamableHttpServer" },
|
||||
{ "$ref": "#/$defs/sseServer" }
|
||||
]
|
||||
},
|
||||
"stdioServer": {
|
||||
"title": "stdio MCP server",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "const": "stdio" },
|
||||
"command": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Executable token. Resolution rules are defined by the Agent Plugins specification."
|
||||
},
|
||||
"args": { "type": "array", "items": { "type": "string" } },
|
||||
"env": {
|
||||
"type": "object",
|
||||
"propertyNames": { "not": { "enum": ["PLUGIN_ROOT", "PLUGIN_DATA"] } },
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"cwd": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))",
|
||||
"description": "Plugin-relative, PLUGIN_ROOT-rooted, or PLUGIN_DATA-rooted working directory. Filesystem containment is validated separately."
|
||||
}
|
||||
},
|
||||
"required": ["type", "command"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"streamableHttpServer": {
|
||||
"title": "Streamable HTTP MCP server",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "const": "streamable-http" },
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification."
|
||||
},
|
||||
"headers": { "$ref": "#/$defs/headers" }
|
||||
},
|
||||
"required": ["type", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sseServer": {
|
||||
"title": "Legacy HTTP+SSE MCP server",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "const": "sse" },
|
||||
"url": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "MCP endpoint URL. URL semantics are defined by the Agent Plugins specification."
|
||||
},
|
||||
"headers": { "$ref": "#/$defs/headers" }
|
||||
},
|
||||
"required": ["type", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"headers": {
|
||||
"title": "HTTP headers",
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"title": "Agent Plugins Manifest",
|
||||
"description": "Machine-readable schema for plugin.json in Agent Plugins 1.0.0. The Agent Plugins specification defines additional semantic and operational requirements.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"description": "Canonical identifier of the plugin manifest schema for the Agent Plugins version targeted by this document."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$",
|
||||
"description": "Human-readable plugin name."
|
||||
},
|
||||
"version": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"author": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"email": { "type": "string" },
|
||||
"url": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"homepage": { "type": "string" },
|
||||
"repository": { "type": "string" },
|
||||
"license": { "type": "string" },
|
||||
"keywords": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"description": "Client-specific manifest data keyed by reverse-domain extension namespace. Agent Plugins assigns no semantics to namespace object contents.",
|
||||
"additionalProperties": { "type": "object" }
|
||||
}
|
||||
},
|
||||
"required": ["$schema", "name"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
from skills_ref import validate as validate_skill
|
||||
|
||||
SCHEMAS = Path(__file__).resolve().parent / "schemas"
|
||||
|
||||
|
||||
def _schema_errors(path: Path, schema_name: str) -> list[str]:
|
||||
if not path.is_file():
|
||||
return [f"{path.name}: file is required"]
|
||||
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
return [f"{path.name}: {error}"]
|
||||
|
||||
schema = json.loads((SCHEMAS / schema_name).read_text(encoding="utf-8"))
|
||||
errors = Draft202012Validator(schema).iter_errors(value)
|
||||
return [
|
||||
f"{path.name}{''.join(f'.{part}' for part in error.absolute_path)}: {error.message}"
|
||||
for error in sorted(errors, key=lambda item: tuple(str(part) for part in item.absolute_path))
|
||||
]
|
||||
|
||||
|
||||
def validate_bundle(root: Path, kind: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not root.is_dir():
|
||||
return [f"{root}: directory is required"]
|
||||
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_symlink():
|
||||
errors.append(f"{path.relative_to(root)}: symlinks are not allowed in release bundles")
|
||||
|
||||
if kind == "portable":
|
||||
errors.extend(_schema_errors(root / "plugin.json", "plugin.schema.json"))
|
||||
if (root / "mcp.json").exists():
|
||||
errors.extend(_schema_errors(root / "mcp.json", "mcp.schema.json"))
|
||||
skills = root / "skills"
|
||||
if skills.is_dir():
|
||||
for skill in sorted(path for path in skills.iterdir() if path.is_dir()):
|
||||
errors.extend(f"skills/{skill.name}: {error}" for error in validate_skill(skill))
|
||||
elif kind == "native":
|
||||
for path in sorted(root.rglob("*.json")):
|
||||
relative = path.relative_to(root)
|
||||
try:
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
errors.append(f"{relative}: {error}")
|
||||
return sorted(errors)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate a generated Mem0 plugin bundle")
|
||||
parser.add_argument("root", type=Path)
|
||||
parser.add_argument("--kind", choices=("portable", "native"), required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
errors = validate_bundle(args.root, args.kind)
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Validated {args.kind} bundle: {args.root}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user