f80d21fa06
Review point. The portable bundle is built with host "coding-agent", and the build wrote that straight into PLATFORM_APPLICATION, so every portable install sent X-Application: coding-agent. That value is not in the platform's allowlist, so it was already being dropped server-side. The effect was the worst of both: the wire claimed we knew the editor, the stored event recorded that we did not, and nothing said which was right. An absent header says the same thing honestly and costs a lookup. HARNESS_ID stays "coding-agent". It is the PostHog-side label, it is true, and grouping portable installs together there is useful. Native bundles are unchanged apart from the regenerated comment. Covered by two new build tests: portable declares no application, and each native names the host it was generated for. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb
291 lines
11 KiB
Python
291 lines
11 KiB
Python
#!/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 _render_harness_id(host: str, *, portable: bool = False) -> str:
|
|
"""Emit core/_harness_id.py for one host.
|
|
|
|
Carries both vocabularies from a single definition: the PostHog `source` tag
|
|
and the platform's X-Mem0-Source / X-Application pair. Keeping them together
|
|
is what stops the two from drifting into separate vocabularies for the same
|
|
thing.
|
|
|
|
The portable bundle runs in whatever editor a user drops it into, so it does
|
|
not know its host and must not guess one. HARNESS_ID stays "coding-agent",
|
|
which is true and useful for grouping in PostHog, but PLATFORM_APPLICATION is
|
|
left empty: X-Application names a real host app, is checked against an
|
|
allowlist server-side, and a value that is always discarded is worse than no
|
|
value -- it reads like an attribution we have and do not.
|
|
"""
|
|
tag = host.upper().replace("-", "_") + "_PLUGIN"
|
|
application = "" if portable else host
|
|
return (
|
|
'"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n'
|
|
"\n"
|
|
f'HARNESS_ID = "{host}"\n'
|
|
f'SOURCE_TAG = "{tag}"\n'
|
|
"\n"
|
|
"# Platform-side vocabulary (mem0_event.source + X-Application). The whole\n"
|
|
"# plugin family is one source; which editor it runs in is the application.\n"
|
|
"# An empty application means the host is unknown, and memory_core omits\n"
|
|
"# the header entirely rather than sending a placeholder.\n"
|
|
'PLATFORM_SOURCE = "MEM0_PLUGIN"\n'
|
|
f'PLATFORM_APPLICATION = "{application}"\n'
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
# Generated per host so identity does not depend on an entrypoint remembering
|
|
# to call telemetry.init(). mcp_server.py and the detached telemetry.py sender
|
|
# never did, which is how MCP searches reported harness=generic and every
|
|
# batch they drained was labelled MEM0_PLUGIN regardless of the real host.
|
|
(core / "_harness_id.py").write_text(_render_harness_id(host, portable=portable), encoding="utf-8")
|
|
|
|
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())
|