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)
This commit is contained in:
kartik-mem0
2026-05-21 18:46:08 +05:30
parent 70f2f60727
commit 1daa7ece66
11 changed files with 220 additions and 38 deletions
+19
View File
@@ -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"],
+12 -16
View File
@@ -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 "?")
+6 -3
View File
@@ -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 <<EOF
Task completed: "$TASK_SUBJECT"
@@ -31,7 +34,7 @@ Extract key learnings from this completed task and store them using the mem0 \`a
Memories can be as detailed as needed — include full context, reasoning, code snippets, and examples.
Only store genuinely useful learnings — skip if the task was trivial.
Include \`app_id\` = \`"$MEM0_PROJECT_ID"\` as a top-level parameter in every \`add_memory\` call (not in metadata).
Include \`app_id\` = \`"$_PROJECT"\` as a top-level parameter in every \`add_memory\` call (not in metadata).
EOF
exit 0
+12 -2
View File
@@ -42,17 +42,21 @@ def _save(stats: dict) -> 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":
+3 -1
View File
@@ -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(<loser_id>, data={"metadata": {"confidence": 0.1, "superseded_by": "<winner_id>", "source": "mem0-dream"}})`
- First call `get_memory(<loser_id>)` to read its current text content.
- Then call `update_memory(<loser_id>, data=<original_text_content>)` 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.
+15
View File
@@ -59,3 +59,18 @@ Deleted <N> 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."
+55
View File
@@ -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: <N> pairs
[mem0:<id1>] ≈ [mem0:<id2>] — both about "<shared topic>"
```
### 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: <N>
[mem0:<id>] — 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: <N>
[mem0:<idA>] vs [mem0:<idB>] — conflicting on "<topic>"
```
### 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: <N>
```
### Quality summary
```
## Memory Quality
| Metric | Count | Action |
|----------------|-------|---------------------------------|
| Duplicates | <N> | Run /mem0:dream to merge |
| Stale | <N> | Run /mem0:dream to prune |
| Contradictions | <N> | Run /mem0:dream to resolve |
| Orphans | <N> | Consider retagging via MCP |
```
If all counts are 0: `Memory quality: clean. No duplicates, stale entries, or contradictions found.`
+22
View File
@@ -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=<id>)
current_text = mem["content"] # or mem["memory"], depending on response shape
# 2. Update with access_count bump
update_memory(
memory_id=<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.
+17 -6
View File
@@ -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=<selected_id>`
- `metadata={"pinned": true}`
- `data=<original_text>` (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: "<memory content, first 80 chars>..."
@@ -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: "<content>..."`
1. Call `get_memory` to read current content.
2. Call `update_memory` with `data=<original_text>` to preserve content.
3. Print: `Unpinned: "<content>..."`
+16 -10
View File
@@ -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 (<project_id>)
Total memories: <N>
By category:
Architecture Decisions: <N>
Anti-Patterns: <N>
Task Learnings: <N>
Coding Conventions: <N>
User Preferences: <N>
Tooling & Setup: <N>
Session State: <N>
Other: <N>
decision ████████████████ 24
convention ██████████░░░░░░ 15
anti_pattern ████░░░░░░░░░░░░ 6
task_learning ███░░░░░░░░░░░░░ 5
user_preference ██░░░░░░░░░░░░░░ 3
session_state █░░░░░░░░░░░░░░░ 2
Oldest memory: <date>
Newest memory: <date>
@@ -73,4 +73,10 @@ Print a compact dashboard:
Branch: <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.
+43
View File
@@ -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"}