fix(plugin): deterministic mem0 user_id resolution
Hooks previously fell back to $USER when MEM0_USER_ID wasn't set,
producing a different user_id on every machine for the same person.
Result: a single account with memories scattered across many user
buckets, none of which can see each other.
Resolution priority (same in bash and python):
1. MEM0_USER_ID env var (explicit override)
2. ~/.mem0/identity.json cache (pinned to MEM0_API_KEY fingerprint)
3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
4. Fallback: $USER, else "default"
Same MEM0_API_KEY across machines now yields the same user_id without
the user having to set MEM0_USER_ID by hand on every laptop.
Resolver shipped as two tiny files instead of a shared module:
_identity.sh -- sourced by bash hooks, exports MEM0_RESOLVED_USER_ID
_identity.py -- imported by on_pre_compact.py, exposes resolve_user_id()
Hook integration:
on_user_prompt.sh -- sources resolver, USER_ID interpolated into rubric
on_session_start.sh -- emits an "Active user_id: <X>" header before the
bootstrap text, so the agent's MCP search_memories/add_memory calls
use the same bucket the hooks write to (closes the agent-side half
of the symptom)
on_pre_compact.py -- replaces inline env lookup with resolve_user_id()
Existing memories under previous $USER values are not auto-migrated.
The cache file regenerates on key rotation (fingerprint mismatch).
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
"""Resolve mem0 user_id with deterministic priority.
|
||||||
|
|
||||||
|
Resolution priority:
|
||||||
|
1. MEM0_USER_ID env var (explicit override)
|
||||||
|
2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||||
|
3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||||
|
4. Fallback: $USER, else "default"
|
||||||
|
|
||||||
|
Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||||
|
the "47 user buckets per account" symptom from running on multiple
|
||||||
|
laptops with different $USER values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
_CACHE_PATH = os.path.expanduser("~/.mem0/identity.json")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_user_id() -> str:
|
||||||
|
explicit = os.environ.get("MEM0_USER_ID", "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
api_key = os.environ.get("MEM0_API_KEY", "").strip()
|
||||||
|
if api_key:
|
||||||
|
digest = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
|
||||||
|
fingerprint = digest[:8]
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(_CACHE_PATH, "r") as f:
|
||||||
|
cached = json.load(f)
|
||||||
|
if cached.get("api_key_fingerprint") == fingerprint and cached.get("user_id"):
|
||||||
|
return cached["user_id"]
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
derived = "mem0-" + digest[:12]
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(_CACHE_PATH), exist_ok=True)
|
||||||
|
with open(_CACHE_PATH, "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"user_id": derived,
|
||||||
|
"source": "api_key",
|
||||||
|
"api_key_fingerprint": fingerprint,
|
||||||
|
"resolved_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return derived
|
||||||
|
|
||||||
|
return os.environ.get("USER") or "default"
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Source this file. Sets MEM0_RESOLVED_USER_ID.
|
||||||
|
#
|
||||||
|
# Resolution priority:
|
||||||
|
# 1. MEM0_USER_ID env var (explicit override)
|
||||||
|
# 2. ~/.mem0/identity.json cache (pinned to current MEM0_API_KEY fingerprint)
|
||||||
|
# 3. Derived: "mem0-" + sha256(MEM0_API_KEY)[:12]
|
||||||
|
# 4. Fallback: $USER, else "default"
|
||||||
|
#
|
||||||
|
# Same MEM0_API_KEY across machines yields the same user_id, which fixes
|
||||||
|
# the "47 user buckets per account" symptom from running on multiple
|
||||||
|
# laptops with different $USER values.
|
||||||
|
|
||||||
|
_mem0_sha256() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum | cut -d' ' -f1
|
||||||
|
else
|
||||||
|
shasum -a 256 | cut -d' ' -f1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_mem0_resolve_identity() {
|
||||||
|
if [ -n "${MEM0_USER_ID:-}" ]; then
|
||||||
|
printf '%s' "$MEM0_USER_ID"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local api_key="${MEM0_API_KEY:-}"
|
||||||
|
local cache="$HOME/.mem0/identity.json"
|
||||||
|
|
||||||
|
if [ -n "$api_key" ]; then
|
||||||
|
local digest
|
||||||
|
digest=$(printf '%s' "$api_key" | _mem0_sha256)
|
||||||
|
local fp="${digest:0:8}"
|
||||||
|
|
||||||
|
if [ -f "$cache" ]; then
|
||||||
|
local cached_fp cached_id
|
||||||
|
cached_fp=$(jq -r '.api_key_fingerprint // ""' "$cache" 2>/dev/null)
|
||||||
|
cached_id=$(jq -r '.user_id // ""' "$cache" 2>/dev/null)
|
||||||
|
if [ "$cached_fp" = "$fp" ] && [ -n "$cached_id" ]; then
|
||||||
|
printf '%s' "$cached_id"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local derived="mem0-${digest:0:12}"
|
||||||
|
mkdir -p "$HOME/.mem0" 2>/dev/null && \
|
||||||
|
printf '{"user_id":"%s","source":"api_key","api_key_fingerprint":"%s","resolved_at":"%s"}\n' \
|
||||||
|
"$derived" "$fp" "$(date -u +%FT%TZ)" > "$cache" 2>/dev/null
|
||||||
|
printf '%s' "$derived"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "${USER:-default}"
|
||||||
|
}
|
||||||
|
|
||||||
|
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
|
||||||
|
export MEM0_RESOLVED_USER_ID
|
||||||
@@ -18,8 +18,11 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from _identity import resolve_user_id
|
||||||
|
|
||||||
log = logging.getLogger("mem0-capture")
|
log = logging.getLogger("mem0-capture")
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
@@ -219,7 +222,7 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
session_id = hook_input.get("session_id", "")
|
session_id = hook_input.get("session_id", "")
|
||||||
user_id = os.environ.get("MEM0_USER_ID", os.environ.get("USER", "default"))
|
user_id = resolve_user_id()
|
||||||
|
|
||||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||||
if not lines:
|
if not lines:
|
||||||
|
|||||||
@@ -21,9 +21,24 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=_identity.sh
|
||||||
|
. "$SCRIPT_DIR/_identity.sh"
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||||
|
|
||||||
|
# Identity line is emitted before every bootstrap variant so the agent
|
||||||
|
# uses the same user_id the hooks resolved. Without this, the agent's
|
||||||
|
# search_memories/add_memory MCP calls may bind to a different bucket
|
||||||
|
# than what the hooks write to.
|
||||||
|
echo "## Mem0 Identity"
|
||||||
|
echo ""
|
||||||
|
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||||
|
echo ""
|
||||||
|
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps memories under one bucket regardless of which machine you're on."
|
||||||
|
echo ""
|
||||||
|
|
||||||
if [ "$SOURCE" = "startup" ]; then
|
if [ "$SOURCE" = "startup" ]; then
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
## Mem0 Session Bootstrap
|
## Mem0 Session Bootstrap
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
USER_ID="${MEM0_USER_ID:-${USER:-default}}"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
# shellcheck source=_identity.sh
|
||||||
|
. "$SCRIPT_DIR/_identity.sh"
|
||||||
|
USER_ID="$MEM0_RESOLVED_USER_ID"
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
## Memory check
|
## Memory check
|
||||||
|
|||||||
Reference in New Issue
Block a user