feat(mem0-plugin): auto coding categories, global search, OpenCode parity (#5300)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -16,6 +16,7 @@ DEFAULTS = {
|
||||
"search_limit": 10,
|
||||
"retention_session_days": 90,
|
||||
"confidence_threshold": 0.3,
|
||||
"global_search": False,
|
||||
"debug": False,
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
Reference in New Issue
Block a user