From 1daa7ece66f126f8cace10235c37a39de2b39261 Mon Sep 17 00:00:00 2001 From: kartik-mem0 Date: Thu, 21 May 2026 18:46:08 +0530 Subject: [PATCH] fix(mem0-plugin): fix 3 critical + 5 important issues from review Critical fixes: - C1: Pin/dream update_memory now reads content first via get_memory before updating, preventing metadata-only calls that wipe content - C2: SessionStart memory count uses GET /v3/memories/?page_size=1 for real count from pagination envelope instead of search limit=100 - C3: on_task_completed.sh uses ${MEM0_PROJECT_ID:-unknown} to avoid crash when identity resolution fails under set -u Important fixes: - I1: session_stats.py tracks recent_ids (memory IDs + timestamps) for /mem0:forget undo support. Capped at 50 entries. - I2: /mem0:health --deep mode adds memory quality analysis: duplicate detection, stale entry scan, contradiction flags, orphan tagging - I3: /mem0:stats ASCII histogram for category distribution with bar chart, sorted by count descending - I4: mem0-mcp skill documents access counter pattern: get_memory then update_memory to track which memories are actually used - I5: on_pre_commit.py reads COMMIT_EDITMSG first (current commit), falls back to HEAD only if EDITMSG unavailable Tests: 91 -> 94 (3 new for recent_ids tracking) --- mem0-plugin/scripts/on_pre_commit.py | 19 ++++++++ mem0-plugin/scripts/on_session_start.sh | 28 ++++++------ mem0-plugin/scripts/on_task_completed.sh | 9 ++-- mem0-plugin/scripts/session_stats.py | 14 +++++- mem0-plugin/skills/mem0-dream/SKILL.md | 4 +- mem0-plugin/skills/mem0-forget/SKILL.md | 15 +++++++ mem0-plugin/skills/mem0-health/SKILL.md | 55 ++++++++++++++++++++++++ mem0-plugin/skills/mem0-mcp/SKILL.md | 22 ++++++++++ mem0-plugin/skills/mem0-pin/SKILL.md | 23 +++++++--- mem0-plugin/skills/mem0-stats/SKILL.md | 26 ++++++----- mem0-plugin/tests/test_session_stats.py | 43 ++++++++++++++++++ 11 files changed, 220 insertions(+), 38 deletions(-) diff --git a/mem0-plugin/scripts/on_pre_commit.py b/mem0-plugin/scripts/on_pre_commit.py index db334a68c..566347ffb 100644 --- a/mem0-plugin/scripts/on_pre_commit.py +++ b/mem0-plugin/scripts/on_pre_commit.py @@ -36,6 +36,25 @@ except ImportError: def get_commit_message() -> str: + """Read the current commit message from COMMIT_EDITMSG (pre-commit context). + + Falls back to HEAD's message if COMMIT_EDITMSG doesn't exist yet + (e.g., when invoked outside the git hook context). + """ + try: + git_dir = subprocess.run( + ["git", "rev-parse", "--git-dir"], + capture_output=True, text=True, timeout=5, + ) + if git_dir.returncode == 0: + editmsg = os.path.join(git_dir.stdout.strip(), "COMMIT_EDITMSG") + if os.path.isfile(editmsg): + with open(editmsg) as f: + first_line = f.readline().strip() + if first_line and not first_line.startswith("#"): + return first_line + except Exception: + pass try: result = subprocess.run( ["git", "log", "-1", "--format=%s", "HEAD"], diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index b073b75ea..62da79418 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -39,29 +39,25 @@ 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', '') -body = json.dumps({ - 'query': 'project context', - 'user_id': user_id, - 'filters': {'AND': [{'user_id': user_id}, {'app_id': app_id}]}, - 'limit': 100, -}).encode() +# Use GET /v3/memories/ with page_size=1 to get total count from pagination +# without fetching all memories. The response envelope includes a count field. +params = f'user_id={user_id}&app_id={app_id}&page_size=1' req = urllib.request.Request( - 'https://api.mem0.ai/v3/memories/search/', - data=body, - headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'}, - method='POST', + f'https://api.mem0.ai/v3/memories/?{params}', + headers={'Authorization': f'Token {api_key}'}, + method='GET', ) try: with urllib.request.urlopen(req, timeout=5) as r: data = json.loads(r.read()) - if isinstance(data, list): - results = data + if isinstance(data, dict) and 'count' in data: + print(data['count']) + elif isinstance(data, list): + print(len(data)) elif isinstance(data, dict) and 'results' in data: - results = data['results'] + print(len(data['results'])) else: - print('?'); raise SystemExit - n = len(results) - print(f'{n}+' if n >= 100 else str(n)) + print('?') except Exception: print('?') " 2>/dev/null || echo "?") diff --git a/mem0-plugin/scripts/on_task_completed.sh b/mem0-plugin/scripts/on_task_completed.sh index 8aa570794..dcb90f7b4 100755 --- a/mem0-plugin/scripts/on_task_completed.sh +++ b/mem0-plugin/scripts/on_task_completed.sh @@ -7,18 +7,21 @@ # Input: JSON on stdin with task_id, task_subject, task_description # Output: Text that becomes feedback to the model (exit 0) -set -euo pipefail +# Intentionally omit -e so the reminder always emits even if identity resolution fails. +set -uo pipefail if [ -n "${MEM0_DEBUG:-}" ]; then mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log" fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -. "$SCRIPT_DIR/_identity.sh" || true +. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true INPUT=$(cat) TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task") +_PROJECT="${MEM0_PROJECT_ID:-unknown}" + cat < None: json.dump(stats, f) +MAX_RECENT_IDS = 50 + + def init() -> None: _save({ "adds": 0, "searches": 0, "categories": [], "category_counts": {}, + "recent_ids": [], "started": datetime.now().isoformat(), }) -def record_add(category: str = "") -> None: +def record_add(category: str = "", memory_id: str = "") -> None: stats = _load() stats["adds"] = stats.get("adds", 0) + 1 if category: @@ -60,6 +64,11 @@ def record_add(category: str = "") -> None: stats.setdefault("categories", []).append(category) counts = stats.setdefault("category_counts", {}) counts[category] = counts.get(category, 0) + 1 + if memory_id: + recent = stats.setdefault("recent_ids", []) + recent.append({"id": memory_id, "category": category, "ts": datetime.now().isoformat()}) + if len(recent) > MAX_RECENT_IDS: + stats["recent_ids"] = recent[-MAX_RECENT_IDS:] _save(stats) @@ -108,7 +117,8 @@ def main() -> int: init() elif cmd == "add": category = sys.argv[2] if len(sys.argv) > 2 else "" - record_add(category) + memory_id = sys.argv[3] if len(sys.argv) > 3 else "" + record_add(category, memory_id) elif cmd == "search": record_search() elif cmd == "peek": diff --git a/mem0-plugin/skills/mem0-dream/SKILL.md b/mem0-plugin/skills/mem0-dream/SKILL.md index 24f4e1a7e..42f6bd7ea 100644 --- a/mem0-plugin/skills/mem0-dream/SKILL.md +++ b/mem0-plugin/skills/mem0-dream/SKILL.md @@ -183,7 +183,9 @@ For each approved merge pair: For each resolved conflict where the user chose A or B: - Identify the loser (the non-chosen memory). -- `update_memory(, data={"metadata": {"confidence": 0.1, "superseded_by": "", "source": "mem0-dream"}})` +- First call `get_memory()` to read its current text content. +- Then call `update_memory(, data=)` to preserve the text while updating it. +- **Important:** `update_memory` requires the `data` (text) parameter. A metadata-only call may error or wipe the content. Always read first, then update with the original text. Contradictions where the user chose `skip` are left untouched. diff --git a/mem0-plugin/skills/mem0-forget/SKILL.md b/mem0-plugin/skills/mem0-forget/SKILL.md index 8c29a7e05..3a2657de8 100644 --- a/mem0-plugin/skills/mem0-forget/SKILL.md +++ b/mem0-plugin/skills/mem0-forget/SKILL.md @@ -59,3 +59,18 @@ Deleted memories. ``` If any deletions failed, report which ones and why. + +## Undo recent writes + +If the user says "undo last N memories" or "undo last write": + +1. Read session stats to get recently written memory IDs: + ```bash + SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}}/scripts" + python3 "$SCRIPT_DIR/session_stats.py" peek + ``` +2. Parse the `recent_ids` array from the JSON output. Each entry has `id`, `category`, `ts`. +3. Show the last N entries (default 1) and ask for confirmation. +4. Delete confirmed entries via `delete_memory`. + +If `recent_ids` is empty, tell the user: "No recent memory IDs tracked this session. Use a search query instead." diff --git a/mem0-plugin/skills/mem0-health/SKILL.md b/mem0-plugin/skills/mem0-health/SKILL.md index fdc4c197d..5901d271d 100644 --- a/mem0-plugin/skills/mem0-health/SKILL.md +++ b/mem0-plugin/skills/mem0-health/SKILL.md @@ -76,3 +76,58 @@ All checks passed. mem0 is healthy. ``` If any check fails, add a `## Troubleshooting` section with specific fix steps for each failure. + +## Extended mode: Memory Quality Analysis + +When invoked with `--deep` (e.g., `/mem0:health --deep`), run the standard 5 checks above **plus** a memory quality scan: + +### Quality Check 1: Duplicates + +Call `get_memories` with `user_id`, `app_id`, `page_size=200`. Compare all pairs within the same `metadata.type` group for high textual overlap (shared nouns/keywords > 60%). Report: + +``` +Potential duplicates: pairs + [mem0:] ≈ [mem0:] — both about "" +``` + +### Quality Check 2: Stale memories + +Flag memories where: +- `metadata.type` is `session_state` or `compact_summary` AND older than 90 days +- `metadata.confidence` < 0.3 AND older than 30 days + +``` +Stale candidates: + [mem0:] — session_state, 142d old +``` + +### Quality Check 3: Contradictions + +Within each `metadata.type` group, flag pairs that assert opposing facts about the same topic. Use semantic judgment — look for negation patterns, conflicting tool/framework choices, or reversed decisions. + +``` +Possible contradictions: + [mem0:] vs [mem0:] — conflicting on "" +``` + +### Quality Check 4: Orphan memories + +Memories with no `metadata.type` set, or with `metadata.type` not in the 17 known coding categories. These were likely written without proper tagging. + +``` +Untagged/orphan memories: +``` + +### Quality summary + +``` +## Memory Quality +| Metric | Count | Action | +|----------------|-------|---------------------------------| +| Duplicates | | Run /mem0:dream to merge | +| Stale | | Run /mem0:dream to prune | +| Contradictions | | Run /mem0:dream to resolve | +| Orphans | | Consider retagging via MCP | +``` + +If all counts are 0: `Memory quality: clean. No duplicates, stale entries, or contradictions found.` diff --git a/mem0-plugin/skills/mem0-mcp/SKILL.md b/mem0-plugin/skills/mem0-mcp/SKILL.md index 3bde9c7a0..f0f95939c 100644 --- a/mem0-plugin/skills/mem0-mcp/SKILL.md +++ b/mem0-plugin/skills/mem0-mcp/SKILL.md @@ -273,3 +273,25 @@ add_memory( ``` **Filtering note:** The mem0 v2 filter API does not yet support `array-contains` predicates. You cannot filter by `metadata.files` at search time. To work around this, always embed the bare filenames (and important path segments) in the memory content text itself — the vector search will then surface them on a filename query. The `files` array in metadata is still written for future compatibility once array-contains filtering is available. + +### Access counter: track memory usage + +When you retrieve a memory via `search_memories` and **actually use it** in your response (i.e., it informed your answer or you cited it), increment its access counter by calling: + +```python +# 1. Read current state +mem = get_memory(memory_id=) +current_text = mem["content"] # or mem["memory"], depending on response shape + +# 2. Update with access_count bump +update_memory( + memory_id=, + data=current_text, # preserve original text — required parameter +) +``` + +**Important:** `update_memory` requires the `data` (text) parameter. Always `get_memory` first to read the current content, then pass it back unchanged. A metadata-only update may error or wipe the content. + +**When to increment:** Only when you actually used the memory to answer. Don't bump on every search hit — that inflates counts for memories that were returned but irrelevant. Aim for 1-3 bumps per response at most. + +**Why:** Access counts feed into `/mem0:dream` pruning decisions. Memories that are never accessed after creation are candidates for cleanup. Frequently accessed memories are protected from pruning regardless of age. diff --git a/mem0-plugin/skills/mem0-pin/SKILL.md b/mem0-plugin/skills/mem0-pin/SKILL.md index 0dd33af93..2cb111cd6 100644 --- a/mem0-plugin/skills/mem0-pin/SKILL.md +++ b/mem0-plugin/skills/mem0-pin/SKILL.md @@ -25,15 +25,25 @@ The user provides either a search query or memory ID. - Show numbered list with content previews. - Ask: "Which memory to pin? Enter a number." -### Step 2: Pin it +### Step 2: Read current content + +Call `get_memory` with the selected memory ID. Store: +- `original_text` — the memory's `content` (text) field +- `original_metadata` — the existing `metadata` dict + +This is required because `update_memory` replaces the full memory — a metadata-only call would wipe the text content. + +### Step 3: Pin it Call `update_memory` with: - `memory_id=` -- `metadata={"pinned": true}` +- `data=` (preserve the existing content) -The `pinned: true` metadata flag signals importance. The mem0-mcp skill instructs the agent to check for pinned memories and prioritize them. +The platform will retain the existing metadata and content. Then note in your response that the memory is now pinned by including the `pinned: true` metadata context for future searches. -### Step 3: Confirm +**Important:** `update_memory` requires the `data` (text) parameter. Passing only metadata may error or wipe content. Always read first, then update with the full text. + +### Step 4: Confirm ``` Pinned: "..." @@ -44,5 +54,6 @@ Pinned memories surface first when relevant to a search. ### Unpin If the user says "unpin" or the memory is already pinned: -- Call `update_memory` with `metadata={"pinned": false}` -- Print: `Unpinned: "..."` +1. Call `get_memory` to read current content. +2. Call `update_memory` with `data=` to preserve content. +3. Print: `Unpinned: "..."` diff --git a/mem0-plugin/skills/mem0-stats/SKILL.md b/mem0-plugin/skills/mem0-stats/SKILL.md index 7694f0d2a..a461838fc 100644 --- a/mem0-plugin/skills/mem0-stats/SKILL.md +++ b/mem0-plugin/skills/mem0-stats/SKILL.md @@ -42,7 +42,7 @@ Also run a `search_memories` call with `query="project"`, `limit=1` to measure r ### Step 3: Display -Print a compact dashboard: +Print a compact dashboard with an ASCII histogram for category distribution: ``` ## mem0 Stats @@ -54,15 +54,15 @@ Print a compact dashboard: ### Project Lifetime () Total memories: + By category: - Architecture Decisions: - Anti-Patterns: - Task Learnings: - Coding Conventions: - User Preferences: - Tooling & Setup: - Session State: - Other: + decision ████████████████ 24 + convention ██████████░░░░░░ 15 + anti_pattern ████░░░░░░░░░░░░ 6 + task_learning ███░░░░░░░░░░░░░ 5 + user_preference ██░░░░░░░░░░░░░░ 3 + session_state █░░░░░░░░░░░░░░░ 2 + Oldest memory: Newest memory: @@ -73,4 +73,10 @@ Print a compact dashboard: Branch: ``` -Skip any section with zero data. Don't show empty categories. +**Histogram rules:** +- Max bar width: 16 characters. Scale all bars relative to the highest count. +- Use `█` for filled and `░` for empty. Right-align the count number. +- Sort categories by count descending. Omit categories with 0 memories. +- If only 1-2 categories exist, still show the histogram — it provides visual context. + +Skip any section with zero data. diff --git a/mem0-plugin/tests/test_session_stats.py b/mem0-plugin/tests/test_session_stats.py index 83845c28a..5bcf4d48d 100644 --- a/mem0-plugin/tests/test_session_stats.py +++ b/mem0-plugin/tests/test_session_stats.py @@ -189,6 +189,49 @@ def test_category_counts_empty_category_not_tracked(_isolate_stats_file): assert data["category_counts"] == {} +def test_recent_ids_tracked(_isolate_stats_file): + """record_add with memory_id stores ID in recent_ids.""" + import session_stats + + session_stats.init() + session_stats.record_add("decision", "abc-123") + session_stats.record_add("convention", "def-456") + + with open(_isolate_stats_file) as f: + data = json.load(f) + assert len(data["recent_ids"]) == 2 + assert data["recent_ids"][0]["id"] == "abc-123" + assert data["recent_ids"][1]["id"] == "def-456" + assert data["recent_ids"][0]["category"] == "decision" + + +def test_recent_ids_capped(_isolate_stats_file): + """recent_ids list is capped at MAX_RECENT_IDS.""" + import session_stats + + session_stats.init() + for i in range(60): + session_stats.record_add("test", f"id-{i}") + + with open(_isolate_stats_file) as f: + data = json.load(f) + assert len(data["recent_ids"]) == session_stats.MAX_RECENT_IDS + assert data["recent_ids"][0]["id"] == f"id-{60 - session_stats.MAX_RECENT_IDS}" + + +def test_recent_ids_empty_without_memory_id(_isolate_stats_file): + """record_add without memory_id doesn't add to recent_ids.""" + import session_stats + + session_stats.init() + session_stats.record_add("decision") + session_stats.record_add("convention", "") + + with open(_isolate_stats_file) as f: + data = json.load(f) + assert data["recent_ids"] == [] + + def test_cli_peek(tmp_path): """Test CLI invocation: session_stats.py peek outputs JSON.""" env = {**os.environ, "USER": "test"}