diff --git a/integrations/mem0-agent/bin/mem0-agent b/integrations/mem0-agent/bin/mem0-agent new file mode 100755 index 000000000..ce0aa4f32 --- /dev/null +++ b/integrations/mem0-agent/bin/mem0-agent @@ -0,0 +1,60 @@ +#!/bin/sh +# Self-contained launcher. Hooks invoke this via ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent so +# the plugin never depends on a globally-installed console script. +# +# A pip-installed entry point is not dependable here: under pyenv the shim resolves against +# whichever Python version the current directory selects, so a repo with a .python-version +# pinning a different version fails with "pyenv: mem0-agent: command not found". +# +# The package has no third-party dependencies, so any CPython >= 3.10 can run it straight +# from source. + +set -eu + +PLUGIN_ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +SRC="$PLUGIN_ROOT/src" + +if [ ! -d "$SRC/mem0_agent" ]; then + echo "mem0-agent: package not found at $SRC" >&2 + exit 0 # never break a hook +fi + +# Does this interpreter exist and satisfy the minimum version? +usable() { + [ -n "${1:-}" ] || return 1 + command -v "$1" >/dev/null 2>&1 || return 1 + "$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)' >/dev/null 2>&1 +} + +PY="" +for candidate in \ + "${MEM0_AGENT_PYTHON:-}" \ + python3 \ + /opt/homebrew/bin/python3 \ + /usr/local/bin/python3 \ + /usr/bin/python3 +do + if usable "$candidate"; then + PY="$candidate" + break + fi +done + +if [ -z "$PY" ]; then + # Last resort: any pyenv version that is new enough. + for candidate in "$HOME"/.pyenv/versions/3.1[0-9]*/bin/python3; do + if usable "$candidate"; then + PY="$candidate" + break + fi + done +fi + +if [ -z "$PY" ]; then + echo "mem0-agent: no Python >= 3.10 found; set MEM0_AGENT_PYTHON" >&2 + exit 0 # never break a hook +fi + +PYTHONPATH="$SRC${PYTHONPATH:+:$PYTHONPATH}" +export PYTHONPATH +exec "$PY" -m mem0_agent.cli "$@" diff --git a/integrations/mem0-agent/hooks/generate.py b/integrations/mem0-agent/hooks/generate.py index 5a2e4ffe4..27fdf6362 100644 --- a/integrations/mem0-agent/hooks/generate.py +++ b/integrations/mem0-agent/hooks/generate.py @@ -215,8 +215,16 @@ def validate(spec: dict) -> dict: raise SpecError( f"hook {entry['id']!r} runs on {entry['event']} and must declare local_only: true" ) - if not str(entry["command"]).startswith("mem0-agent "): - raise SpecError(f"hook {entry['id']!r}: command must invoke the mem0-agent CLI") + # Commands must invoke the plugin's own bundled launcher. A bare console script is + # not dependable: under pyenv the shim resolves against whichever Python version the + # current directory selects, so a repo pinning a different version fails with + # "pyenv: mem0-agent: command not found". + cmd = str(entry["command"]) + if not cmd.startswith("${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent "): + raise SpecError( + f"hook {entry['id']!r}: command must invoke " + "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent" + ) return spec diff --git a/integrations/mem0-agent/hooks/hooks.json b/integrations/mem0-agent/hooks/hooks.json index 29609399b..2a444edb0 100644 --- a/integrations/mem0-agent/hooks/hooks.json +++ b/integrations/mem0-agent/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "MEM0_EDITOR=claude-code mem0-agent context --session-id \"$CLAUDE_SESSION_ID\"", + "command": "MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent context --session-id \"$CLAUDE_SESSION_ID\"", "timeout": 10, "statusMessage": "Loading mem0 context..." } @@ -18,7 +18,7 @@ "hooks": [ { "type": "command", - "command": "MEM0_EDITOR=claude-code MEM0_LOCAL_ONLY=1 mem0-agent observe --source prompt", + "command": "MEM0_EDITOR=claude-code MEM0_LOCAL_ONLY=1 ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent observe --source prompt", "timeout": 3 } ] @@ -30,7 +30,7 @@ "hooks": [ { "type": "command", - "command": "(MEM0_EDITOR=claude-code mem0-agent assist-error >/dev/null 2>&1 &)", + "command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error >/dev/null 2>&1 &)", "timeout": 5 } ] @@ -41,7 +41,7 @@ "hooks": [ { "type": "command", - "command": "(MEM0_EDITOR=claude-code mem0-agent flush >/dev/null 2>&1 &)", + "command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush >/dev/null 2>&1 &)", "timeout": 5 } ] @@ -52,7 +52,7 @@ "hooks": [ { "type": "command", - "command": "(MEM0_EDITOR=claude-code mem0-agent flush --reason precompact >/dev/null 2>&1 &)", + "command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact >/dev/null 2>&1 &)", "timeout": 5 } ] @@ -63,7 +63,7 @@ "hooks": [ { "type": "command", - "command": "(MEM0_EDITOR=claude-code mem0-agent flush --reason end >/dev/null 2>&1 &)", + "command": "(MEM0_EDITOR=claude-code ${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end >/dev/null 2>&1 &)", "timeout": 5 } ] diff --git a/integrations/mem0-agent/hooks/hooks.spec.yaml b/integrations/mem0-agent/hooks/hooks.spec.yaml index 3ac2f574a..1483051b9 100644 --- a/integrations/mem0-agent/hooks/hooks.spec.yaml +++ b/integrations/mem0-agent/hooks/hooks.spec.yaml @@ -1,4 +1,4 @@ -# mem0-agent hook wiring -- the single source of truth. +# "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent" hook wiring -- the single source of truth. # # v1 hand-maintained four editor dialects (Claude Code, Cursor, Codex, Antigravity). # They drifted: an event existed in one file and not another, timeouts disagreed, and @@ -71,7 +71,7 @@ hooks: - id: session-context event: "SessionStart" matcher: "startup|resume|compact" - command: "mem0-agent context --session-id {session_id}" + command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent context --session-id {session_id}" timeout: 10 background: false blocking: true @@ -82,7 +82,7 @@ hooks: - id: prompt-observe event: "UserPromptSubmit" matcher: null - command: "mem0-agent observe --source prompt" + command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent observe --source prompt" timeout: 3 background: false blocking: true @@ -93,7 +93,7 @@ hooks: - id: bash-assist-error event: "PostToolUse" matcher: "Bash" - command: "mem0-agent assist-error" + command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent assist-error" timeout: 5 background: true blocking: false @@ -104,7 +104,7 @@ hooks: - id: stop-flush event: "Stop" matcher: null - command: "mem0-agent flush" + command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush" timeout: 5 background: true blocking: false @@ -115,7 +115,7 @@ hooks: - id: precompact-flush event: "PreCompact" matcher: null - command: "mem0-agent flush --reason precompact" + command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason precompact" timeout: 5 background: true blocking: false @@ -126,7 +126,7 @@ hooks: - id: sessionend-flush event: "SessionEnd" matcher: null - command: "mem0-agent flush --reason end" + command: "${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent flush --reason end" timeout: 5 background: true blocking: false diff --git a/integrations/mem0-agent/src/mem0_agent/settings.py b/integrations/mem0-agent/src/mem0_agent/settings.py index cf2e8d005..d49021ecf 100644 --- a/integrations/mem0-agent/src/mem0_agent/settings.py +++ b/integrations/mem0-agent/src/mem0_agent/settings.py @@ -170,7 +170,9 @@ def resolve_app_id(cwd: str | None = None, settings: Settings | None = None) -> env override -> cwd map -> remote-hash map (self-healing after a move) -> owner-repo slug -> directory name. """ - override = os.environ.get("MEM0_PROJECT_ID") + # Deliberately NOT MEM0_PROJECT_ID: the v1 plugin exports that, and while both are + # installed it would silently override this plugin's project scope. + override = os.environ.get("MEM0_AGENT_APP_ID") if override: return override.strip() cwd = cwd or os.getcwd() diff --git a/integrations/mem0-agent/tests/test_cli.py b/integrations/mem0-agent/tests/test_cli.py index 15abcdc83..2d7a11cd8 100644 --- a/integrations/mem0-agent/tests/test_cli.py +++ b/integrations/mem0-agent/tests/test_cli.py @@ -134,8 +134,8 @@ def test_hook_manifest_commands_all_exist(): for entry in entries: for hook in entry.get("hooks", []): cmd = hook["command"] - assert "mem0-agent " in cmd - sub = cmd.split("mem0-agent ", 1)[1].split()[0] + assert "bin/mem0-agent " in cmd, "hooks must call the bundled launcher" + sub = cmd.split("bin/mem0-agent ", 1)[1].split()[0] assert sub in known, f"manifest invokes unknown subcommand {sub!r}" found += 1 assert found >= 6 @@ -174,7 +174,7 @@ def test_every_manifest_command_parses_verbatim(monkeypatch): for hook in entry.get("hooks", []): raw = hook["command"].strip("() ").split(">/dev/null")[0] tokens = shlex.split(raw) - idx = tokens.index("mem0-agent") + idx = next(i for i, t in enumerate(tokens) if t.endswith("bin/mem0-agent")) argv = [t for t in tokens[idx + 1:] if t != "&"] # shell vars like "$CLAUDE_SESSION_ID" become a literal in the test argv = ["session-x" if t.startswith("$") else t for t in argv] diff --git a/integrations/mem0-agent/tests/test_generate.py b/integrations/mem0-agent/tests/test_generate.py index 9a0da9ce4..90e80ef16 100644 --- a/integrations/mem0-agent/tests/test_generate.py +++ b/integrations/mem0-agent/tests/test_generate.py @@ -48,7 +48,10 @@ def test_every_hook_declares_its_contract(spec): assert entry["why"], f"{entry['id']} must say why it exists" assert isinstance(entry["local_only"], bool) assert isinstance(entry["background"], bool) - assert entry["command"].startswith("mem0-agent ") + assert entry["command"].startswith("${CLAUDE_PLUGIN_ROOT}/bin/mem0-agent "), ( + "hooks must call the plugin's bundled launcher, not a global console script " + "(a pyenv shim resolves against the directory's Python version and can vanish)" + ) def test_tiny_parser_handles_quotes_comments_and_nesting():