From 10b8fc4beda267e9ee751fd366cccf7d86cae237 Mon Sep 17 00:00:00 2001 From: Deshraj Yadav Date: Wed, 29 Jul 2026 20:17:17 -0700 Subject: [PATCH] fix(mem0-agent): read the key from the plugin config, add cross-surface verification The desktop app never sources a shell rc, so a key that exists only as MEM0_API_KEY in .zshrc is invisible there and the plugin silently no-ops. Claude Code injects the userConfig value as CLAUDE_PLUGIN_OPTION_API_KEY (v1 read it, v2 did not). - resolve_api_key() -> (key, source): env, plugin config, legacy plugin config, keychain - health now reports WHERE the key came from, which is how you tell the two apart - every logged event records the editor, and a new 'sessions' command lists which surfaces have actually built packs and written memories Co-Authored-By: Claude Fable 5 --- integrations/mem0-agent/src/mem0_agent/cli.py | 60 ++++++++++++++++++- integrations/mem0-agent/src/mem0_agent/ctx.py | 9 ++- .../mem0-agent/src/mem0_agent/settings.py | 32 +++++++--- integrations/mem0-agent/tests/test_cli.py | 52 +++++++++++++++- 4 files changed, 142 insertions(+), 11 deletions(-) diff --git a/integrations/mem0-agent/src/mem0_agent/cli.py b/integrations/mem0-agent/src/mem0_agent/cli.py index cdfb33f02..2645b751b 100644 --- a/integrations/mem0-agent/src/mem0_agent/cli.py +++ b/integrations/mem0-agent/src/mem0_agent/cli.py @@ -288,10 +288,63 @@ def cmd_maintain(args) -> int: return 0 +def cmd_sessions(args) -> int: + """Which surfaces have actually used memory. The cross-editor verification view.""" + from .settings import HOME + + root = HOME / "sessions" + if not root.exists(): + print("no sessions recorded yet") + return 0 + rows = [] + for d in root.iterdir(): + if not d.is_dir(): + continue + events = [] + try: + events = [json.loads(x) for x in (d / "events.jsonl").read_text().splitlines() if x.strip()] + except Exception: + pass + if not events and not args.all: + continue + editors = sorted({e.get("editor") or "?" for e in events}) + packs = [e for e in events if e.get("event") == "context"] + flushes = [e for e in events if e.get("event") == "flush"] + obs = [e for e in events if e.get("event") == "capture_observe"] + rows.append({ + "session": d.name, + "editor": ",".join(editors) or "-", + "app": (events[-1].get("app_id") if events else "-") or "-", + "last": (events[-1].get("at") if events else "") or "", + "packs": len(packs), + "rows": sum(int(e.get("rows") or 0) for e in packs), + "turns": len(obs), + "sent": sum(int(e.get("sent") or 0) for e in flushes), + "mtime": d.stat().st_mtime, + }) + rows.sort(key=lambda r: r["mtime"], reverse=True) + rows = rows[: args.limit] + if not rows: + print("no sessions with recorded activity yet") + return 0 + + print(f"{'session':22s} {'editor':14s} {'project':18s} {'packs':>5s} {'served':>6s} {'turns':>5s} {'wrote':>5s} last") + for r in rows: + print(f"{r['session'][:22]:22s} {r['editor'][:14]:14s} {r['app'][:18]:18s} " + f"{r['packs']:5d} {r['rows']:6d} {r['turns']:5d} {r['sent']:5d} {r['last']}") + editors = sorted({e for r in rows for e in r["editor"].split(",") if e and e != "-"}) + print(f"\nsurfaces seen: {', '.join(editors) or 'none'}") + return 0 + + def cmd_health(args) -> int: c = build(args, hook_input()) + from .settings import resolve_api_key + + _, key_source = resolve_api_key() checks: list[tuple[str, bool, str]] = [] - checks.append(("credentials", c.api is not None, c.reason or "key found")) + checks.append(("credentials", c.api is not None, + f"from {key_source}" if c.api else (c.reason or "not found"))) if c.api: status, body = c.api.ping() ok = status == 200 @@ -441,6 +494,11 @@ def main(argv: list[str] | None = None) -> int: sp = sub.add_parser("health", help="diagnose the memory layer") sp.set_defaults(fn=cmd_health) + sp = sub.add_parser("sessions", help="which editors/surfaces have used memory") + sp.add_argument("--limit", type=int, default=15) + sp.add_argument("--all", action="store_true", help="include sessions with no activity") + sp.set_defaults(fn=cmd_sessions) + sp = sub.add_parser("stats", help="what was captured and served") sp.add_argument("--session", action="store_true") sp.set_defaults(fn=cmd_stats) diff --git a/integrations/mem0-agent/src/mem0_agent/ctx.py b/integrations/mem0-agent/src/mem0_agent/ctx.py index 4c63ca495..d00055e7e 100644 --- a/integrations/mem0-agent/src/mem0_agent/ctx.py +++ b/integrations/mem0-agent/src/mem0_agent/ctx.py @@ -50,7 +50,14 @@ class Ctx: return meta def log(self, event: str, **fields) -> None: - self.state.append("events.jsonl", {"event": event, **fields}) + """Every event carries the editor and a timestamp, so `sessions` can prove which + surface -- terminal CLI, desktop app, another editor -- actually did the work.""" + import time + + self.state.append("events.jsonl", { + "event": event, "editor": self.editor, "app_id": self.app_id, + "at": time.strftime("%Y-%m-%dT%H:%M:%S"), **fields, + }) def build(session_id: str | None = None, cwd: str | None = None, *, strict: bool = False) -> Ctx: diff --git a/integrations/mem0-agent/src/mem0_agent/settings.py b/integrations/mem0-agent/src/mem0_agent/settings.py index 26f38a9dd..6a468ca7a 100644 --- a/integrations/mem0-agent/src/mem0_agent/settings.py +++ b/integrations/mem0-agent/src/mem0_agent/settings.py @@ -93,17 +93,35 @@ class Settings: # --------------------------- credentials --------------------------- -def get_api_key() -> str | None: - """Env first (explicit beats implicit), then the OS keychain. Never rc files.""" - key = os.environ.get("MEM0_API_KEY") - if key: - return key.strip() +# Order matters. The desktop app does not source your shell rc, so a key that only exists +# as MEM0_API_KEY in .zshrc is invisible there -- the plugin option is what makes the two +# surfaces behave the same. Shell rc files are never read. +KEY_SOURCES: tuple[tuple[str, str], ...] = ( + ("env", "MEM0_API_KEY"), + ("plugin config", "CLAUDE_PLUGIN_OPTION_API_KEY"), + ("plugin config (legacy)", "CLAUDE_PLUGIN_OPTION_MEM0_API_KEY"), +) + + +def resolve_api_key() -> tuple[str | None, str]: + """Returns (key, where it came from). The source is what diagnostics report.""" + for label, var in KEY_SOURCES: + val = (os.environ.get(var) or "").strip() + if val: + return val, label try: import keyring # optional dependency - return keyring.get_password(KEYRING_SERVICE, "api_key") + val = keyring.get_password(KEYRING_SERVICE, "api_key") + if val: + return val.strip(), "keychain" except Exception: - return None + pass + return None, "not found" + + +def get_api_key() -> str | None: + return resolve_api_key()[0] def store_api_key(key: str) -> bool: diff --git a/integrations/mem0-agent/tests/test_cli.py b/integrations/mem0-agent/tests/test_cli.py index 2d7a11cd8..7b328f71c 100644 --- a/integrations/mem0-agent/tests/test_cli.py +++ b/integrations/mem0-agent/tests/test_cli.py @@ -115,7 +115,7 @@ def test_config_reports_and_updates(tmp_path, monkeypatch): def test_every_subcommand_is_registered(): """The generated hook manifest invokes these by name; a rename must fail loudly.""" for cmd in ("setup", "onboard", "context", "observe", "flush", "assist-error", - "remember", "forget", "maintain", "health", "stats", "config"): + "remember", "forget", "maintain", "health", "stats", "config", "sessions"): with pytest.raises(SystemExit) as e: cli.main([cmd, "--help"]) assert e.value.code == 0 @@ -128,7 +128,7 @@ def test_hook_manifest_commands_all_exist(): manifest = pathlib.Path(__file__).resolve().parents[1] / "hooks/hooks.json" data = json.loads(manifest.read_text()) known = {"setup", "onboard", "context", "observe", "flush", "assist-error", - "remember", "forget", "maintain", "health", "stats", "config"} + "remember", "forget", "maintain", "health", "stats", "config", "sessions"} found = 0 for entries in data["hooks"].values(): for entry in entries: @@ -182,3 +182,51 @@ def test_every_manifest_command_parses_verbatim(monkeypatch): checked += 1 assert checked >= 6 assert ran, "the manifest should invoke real subcommands" + + +def test_key_resolution_prefers_env_then_plugin_config_then_keychain(monkeypatch): + """The desktop app never sources your shell rc, so the plugin-config variable is what + makes the app and the terminal behave identically. v1 read it; v2 originally did not.""" + from mem0_agent import settings as S + + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) + monkeypatch.setattr(S, "KEYRING_SERVICE", "mem0-agent-test-missing") + + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "m0-legacy") + assert S.resolve_api_key() == ("m0-legacy", "plugin config (legacy)") + + monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_API_KEY", "m0-plugin") + assert S.resolve_api_key() == ("m0-plugin", "plugin config") + + monkeypatch.setenv("MEM0_API_KEY", "m0-env") + assert S.resolve_api_key() == ("m0-env", "env") + + +def test_no_shell_rc_is_ever_read(): + """v1 grepped ~/.zshrc for the key and re-exported it in plaintext. Never again. + + Checks real string literals only -- docstrings are allowed to mention the old + behaviour, since explaining why it is gone is the point of those comments. + """ + import ast + import pathlib + + rc_names = (".zshrc", ".bashrc", ".bash_profile", ".profile", ".zprofile") + offenders = [] + for f in (pathlib.Path(__file__).resolve().parents[1] / "src/mem0_agent").rglob("*.py"): + tree = ast.parse(f.read_text()) + docstrings = set() + for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + doc = ast.get_docstring(node, clean=False) + if doc is not None: + docstrings.add(doc) + for node in ast.walk(tree): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if node.value in docstrings: + continue + if any(rc in node.value for rc in rc_names): + offenders.append(f"{f.name}:{node.lineno} {node.value[:40]!r}") + assert not offenders, f"shell rc files must never be read: {offenders}"