feat(mem0-plugin): auto coding categories, global search, OpenCode parity (#5300)

This commit is contained in:
Kartik
2026-05-30 00:18:46 +05:30
committed by GitHub
parent 9328c36a46
commit 1019f0e17c
19 changed files with 748 additions and 138 deletions
+3 -1
View File
@@ -70,6 +70,7 @@ if command -v python3 >/dev/null 2>&1; then
MEM0_SEARCH_LIMIT=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('search_limit',10))" 2>/dev/null || echo "10")
MEM0_RETENTION_SESSION_DAYS=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('retention_session_days',90))" 2>/dev/null || echo "90")
MEM0_CONFIDENCE_THRESHOLD=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin).get('confidence_threshold',0.3))" 2>/dev/null || echo "0.3")
MEM0_GLOBAL_SEARCH=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('global_search',False)).lower())" 2>/dev/null || echo "false")
MEM0_DEBUG=$(echo "$_SETTINGS_JSON" | python3 -c "import sys,json; print(str(json.load(sys.stdin).get('debug',False)).lower())" 2>/dev/null || echo "false")
else
MEM0_AUTO_SAVE="true"
@@ -77,9 +78,10 @@ else
MEM0_SEARCH_LIMIT="10"
MEM0_RETENTION_SESSION_DAYS="90"
MEM0_CONFIDENCE_THRESHOLD="0.3"
MEM0_GLOBAL_SEARCH="false"
MEM0_DEBUG="false"
fi
export MEM0_AUTO_SAVE MEM0_AUTO_SEARCH MEM0_SEARCH_LIMIT MEM0_RETENTION_SESSION_DAYS MEM0_CONFIDENCE_THRESHOLD MEM0_DEBUG
export MEM0_AUTO_SAVE MEM0_AUTO_SEARCH MEM0_SEARCH_LIMIT MEM0_RETENTION_SESSION_DAYS MEM0_CONFIDENCE_THRESHOLD MEM0_GLOBAL_SEARCH MEM0_DEBUG
# Also resolve project context
. "$_SCRIPT_DIR/_project.sh"
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""Auto-configure mem0's coding-category taxonomy in the background.
Runs from the SessionStart hook (startup only), exactly like auto_import.py.
mem0 auto-tags every memory with `categories`; by default that list is
consumer-oriented (food, hobbies, ...), which is useless for code. This script
replaces it with the coding-focused taxonomy defined in
``setup_coding_categories.CODING_CATEGORIES`` so search and retrieval are tuned
for development work — without the user ever being asked during onboarding.
Design (mirrors auto_import.py):
- Resolve the API key; do nothing if it is absent.
- Gate on a state file (``~/.mem0/categories_setup.json``) keyed by a hash of
the API key -> a hash of the taxonomy. Categories are scoped to the mem0
*project* tied to the API key (NOT to the local repo), so this only needs to
run once per account, and re-runs only if the taxonomy itself changes.
- Hold a lock file so concurrent sessions don't race.
- Reuse the proven SDK path (``client.project.update``) via the plugin venv.
- Always exit 0; log to stderr only. Must never block a session.
Run with no arguments (background) or in the foreground for onboarding to print
a parseable status line.
Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_API_KEY) and the mem0ai SDK,
which ensure_deps.sh installs into ${CLAUDE_PLUGIN_DATA}/venv on session start.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# Importing setup_coding_categories also injects the plugin venv's site-packages
# onto sys.path (its module-level bootstrap), so ``from mem0 import MemoryClient``
# works even when this script is run with the system python3.
from _identity import resolve_api_key # noqa: E402
from setup_coding_categories import CODING_CATEGORIES, _categories_match # noqa: E402
log = logging.getLogger("mem0-auto-categories")
log.setLevel(logging.DEBUG)
_handler = logging.StreamHandler(sys.stderr)
_handler.setFormatter(logging.Formatter("[mem0-auto-categories] %(message)s"))
log.addHandler(_handler)
if os.environ.get("MEM0_DEBUG"):
_log_dir = os.path.expanduser("~/.mem0")
try:
os.makedirs(_log_dir, exist_ok=True)
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
_file_handler.setFormatter(logging.Formatter("[mem0-auto-categories] %(asctime)s %(message)s"))
log.addHandler(_file_handler)
except OSError:
pass
STATE_FILE = os.path.expanduser("~/.mem0/categories_setup.json")
LOCK_FILE = os.path.expanduser("~/.mem0/categories_setup.lock")
# --------------------------------------------------------------------------- #
# Fingerprints #
# --------------------------------------------------------------------------- #
def categories_fingerprint(categories: list = CODING_CATEGORIES) -> str:
"""Stable, order-independent 16-hex digest of the category taxonomy.
Reordering the categories yields the same fingerprint; adding, removing, or
editing a category changes it (so the taxonomy re-applies on upgrade).
"""
pairs = sorted(
(str(key), str(value))
for entry in categories
if isinstance(entry, dict)
for key, value in entry.items()
)
payload = json.dumps(pairs, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
def apikey_fingerprint(api_key: str) -> str:
"""Opaque 16-hex digest of the API key. Never stores the key itself."""
return hashlib.sha256(api_key.encode("utf-8")).hexdigest()[:16]
# --------------------------------------------------------------------------- #
# State file #
# --------------------------------------------------------------------------- #
def load_state(path: str = STATE_FILE) -> dict:
"""Load the apikey-fingerprint -> categories-fingerprint map; {} on any error."""
if not os.path.isfile(path):
return {}
try:
with open(path) as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
def save_state(state: dict, path: str = STATE_FILE) -> None:
"""Persist the state map, creating the parent directory if needed."""
parent = os.path.dirname(path)
if parent:
try:
os.makedirs(parent, exist_ok=True)
except OSError as e:
log.warning("Could not create state dir: %s", e)
return
try:
with open(path, "w") as f:
json.dump(state, f, indent=2)
except OSError as e:
log.warning("Could not save categories state: %s", e)
def is_applied(state: dict, key_fp: str, cat_fp: str) -> bool:
"""True only when this API key has already had this exact taxonomy applied."""
return state.get(key_fp) == cat_fp
# --------------------------------------------------------------------------- #
# SDK interaction (client injected for testability) #
# --------------------------------------------------------------------------- #
def make_client():
"""Construct a MemoryClient. Imported lazily so this module loads without the SDK."""
from mem0 import MemoryClient
return MemoryClient()
def fetch_current_categories(client) -> list | None:
"""Return the project's current custom_categories, or None if unavailable."""
current = client.project.get(fields=["custom_categories"])
if isinstance(current, dict):
return current.get("custom_categories")
return None
def apply_categories(client, proposed: list = CODING_CATEGORIES) -> str:
"""Install the coding taxonomy if it isn't already in place.
Returns "already-configured" when the project already matches (no write), or
"applied" after a successful ``project.update``. Raises on API failure.
"""
current = fetch_current_categories(client)
if _categories_match(current, proposed):
return "already-configured"
client.project.update(custom_categories=proposed)
return "applied"
# --------------------------------------------------------------------------- #
# Lock (mirrors auto_import.py) #
# --------------------------------------------------------------------------- #
def _acquire_lock() -> bool:
"""Try to acquire a file lock. Returns False if another instance is running."""
try:
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return True
except FileExistsError:
try:
if time.time() - os.path.getmtime(LOCK_FILE) > 120:
os.unlink(LOCK_FILE)
return _acquire_lock()
except OSError:
pass
return False
def _release_lock() -> None:
try:
os.unlink(LOCK_FILE)
except OSError:
pass
# --------------------------------------------------------------------------- #
# Entry point #
# --------------------------------------------------------------------------- #
def main() -> None:
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping coding-categories setup")
return
key_fp = apikey_fingerprint(api_key)
cat_fp = categories_fingerprint()
state = load_state()
if is_applied(state, key_fp, cat_fp):
log.debug("Coding categories already configured for this account (cached); skipping")
return
os.environ["MEM0_API_KEY"] = api_key
try:
client = make_client()
except ImportError:
log.debug("mem0ai SDK not ready yet (venv installing?); will retry next session")
return
except Exception as e:
log.warning("Could not initialise MemoryClient: %s", e)
return
try:
result = apply_categories(client)
except Exception as e:
log.warning("Could not configure coding categories: %s", e)
return
state[key_fp] = cat_fp
save_state(state)
if result == "applied":
log.info("Applied %d coding categories", len(CODING_CATEGORIES))
else:
log.info("Coding categories already configured")
if __name__ == "__main__":
if not _acquire_lock():
log.debug("Another auto_setup_categories instance is running — skipping")
sys.exit(0)
try:
main()
except Exception as e: # never block a session
log.error("Unexpected error: %s", e)
finally:
_release_lock()
sys.exit(0)
@@ -45,6 +45,7 @@ trap 'rm -f "$_PATCH_OUT"' EXIT
_MEM0_TOOL_INPUT="$TOOL_INPUT" \
_MEM0_USER_ID="${MEM0_RESOLVED_USER_ID:-}" \
_MEM0_APP_ID="${MEM0_PROJECT_ID:-}" \
_MEM0_GLOBAL_SEARCH="${MEM0_GLOBAL_SEARCH:-false}" \
_MEM0_HANDLER="$HANDLER" \
python3 <<'PYEOF' > "$_PATCH_OUT" 2>/dev/null || true
import json, os, sys
@@ -58,6 +59,7 @@ except Exception:
handler = os.environ.get("_MEM0_HANDLER", "")
resolved_uid = os.environ.get("_MEM0_USER_ID", "")
resolved_aid = os.environ.get("_MEM0_APP_ID", "")
global_search = os.environ.get("_MEM0_GLOBAL_SEARCH", "false") == "true"
changed = False
@@ -177,7 +179,11 @@ if handler == "add_memory":
inp["metadata"] = meta
elif handler in ("search_memories", "get_memories"):
changed = inject_filter_identity(inp, resolved_uid, resolved_aid)
if global_search:
inp["filters"] = {"OR": [{"user_id": "*"}]}
changed = True
else:
changed = inject_filter_identity(inp, resolved_uid, resolved_aid)
elif handler == "delete_all":
changed = inject_top_level_identity(inp, resolved_uid, resolved_aid)
+1
View File
@@ -16,6 +16,7 @@ DEFAULTS = {
"search_limit": 10,
"retention_session_days": 90,
"confidence_threshold": 0.3,
"global_search": False,
"debug": False,
}
+30 -8
View File
@@ -76,6 +76,7 @@ import json, os, urllib.request, urllib.error
api_key = os.environ.get('MEM0_API_KEY', '')
user_id = os.environ.get('MEM0_RESOLVED_USER_ID', 'default')
app_id = os.environ.get('MEM0_PROJECT_ID', '')
global_search = os.environ.get('MEM0_GLOBAL_SEARCH', 'false') == 'true'
def get_count(filters):
body = json.dumps({'filters': filters}).encode()
@@ -93,8 +94,11 @@ def get_count(filters):
return 0
try:
base = [{'user_id': user_id}, {'app_id': app_id}]
total = get_count({'AND': base})
if global_search:
filters = {'OR': [{'user_id': '*'}]}
else:
filters = {'AND': [{'user_id': user_id}, {'app_id': app_id}]}
total = get_count(filters)
print(total)
except Exception:
print('?')
@@ -105,21 +109,30 @@ _UID="${MEM0_RESOLVED_USER_ID:-${USER:-default}}"
_ANN="${_MEM0_IDENTITY_ANNOTATION:-}"
_PID="${MEM0_PROJECT_ID:-unknown}"
_BR="${MEM0_BRANCH:-unknown}"
_GS="${MEM0_GLOBAL_SEARCH:-false}"
if [ "$_GS" = "true" ]; then
_SCOPE_LABEL="scope=global"
_SCOPE_INSTR="Global search is ON — searches return all memories across all users and projects. Writes still use user_id: \`${_UID}\`, app_id: \`${_PID}\`."
else
_SCOPE_LABEL="project=${_PID}"
_SCOPE_INSTR="Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:
- user_id: \`${_UID}\`
- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)"
fi
cat <<BANNER
## Mem0 Active
\`user=${_UID}${_ANN} | project=${_PID} | branch=${_BR} | memories=${MEM0_COUNT}\`
\`user=${_UID}${_ANN} | ${_SCOPE_LABEL} | branch=${_BR} | memories=${MEM0_COUNT}\`
IMPORTANT: In your FIRST response, display this exact status line as your opening line:
\`\`\`
Mem0 Active | user=${_UID}${_ANN} | project=${_PID} | branch=${_BR} | memories=${MEM0_COUNT}
Mem0 Active | user=${_UID}${_ANN} | ${_SCOPE_LABEL} | branch=${_BR} | memories=${MEM0_COUNT}
\`\`\`
Always include \`user_id\` + \`app_id\` in every \`search_memories\` filter and \`add_memory\` call:
- user_id: \`${_UID}\`
- app_id: \`${_PID}\` (project scope — passed as top-level \`app_id\`, NOT in metadata)
${_SCOPE_INSTR}
After completing any task, decision, or meaningful exchange, proactively store learnings via \`add_memory\`. Do NOT wait until the session ends — store memories incrementally as work progresses. Focus on: decisions made, bugs fixed, patterns discovered, user preferences, or task outcomes. Aim for 1–3 memories per substantial interaction.
@@ -140,7 +153,7 @@ fi
if [ "$SOURCE" = "startup" ]; then
if [ "$MEM0_COUNT" = "0" ]; then
echo "New project with 0 memories. Invoke the mem0:onboard skill to import project files and install coding categories."
echo "New project with 0 memories. Invoke the mem0:onboard skill to import project files. Coding categories install automatically in the background."
else
echo "Search mem0 for recent decisions and task learnings before responding. Run 2 parallel searches: one for decision type, one for task_learning type."
fi
@@ -154,6 +167,15 @@ if [ "$SOURCE" = "startup" ]; then
MEM0_CWD="$MEM0_CWD_RESOLVED" \
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
# Configure the coding-category taxonomy in the background (idempotent, never blocks).
# Prefer the venv python since this path needs the mem0ai SDK.
_VENV_PY="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}/venv/bin/python3"
if [ -x "$_VENV_PY" ]; then
MEM0_CWD="$MEM0_CWD_RESOLVED" "$_VENV_PY" "$SCRIPT_DIR/auto_setup_categories.py" 2>/dev/null &
else
MEM0_CWD="$MEM0_CWD_RESOLVED" python3 "$SCRIPT_DIR/auto_setup_categories.py" 2>/dev/null &
fi
elif [ "$SOURCE" = "resume" ]; then
echo "Session resumed. Search mem0 for session_state and decision memories to pick up where you left off. Run 2 parallel searches."