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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user