fix(mem0-plugin): v0.2.2 (#5234)
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"source": "./mem0-plugin",
|
"source": "./mem0-plugin",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows.",
|
||||||
"version": "0.2.1"
|
"version": "0.2.2"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"source": "./mem0-plugin",
|
"source": "./mem0-plugin",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search.",
|
||||||
"version": "0.2.1"
|
"version": "0.2.2"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mem0",
|
"name": "Mem0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mem0",
|
"name": "Mem0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0",
|
"name": "mem0",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
|
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mem0",
|
"name": "Mem0",
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
{
|
{
|
||||||
"hooks": {
|
"hooks": {
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CODEX_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"SessionStart": [
|
"SessionStart": [
|
||||||
{
|
{
|
||||||
"matcher": "startup|resume",
|
"matcher": "startup|resume",
|
||||||
@@ -42,12 +54,7 @@
|
|||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_post_commit.sh",
|
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_post_commit.sh",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
},
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Bash",
|
|
||||||
"hooks": [
|
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
{
|
{
|
||||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh",
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh",
|
||||||
"matcher": "Write|Edit"
|
"matcher": "Write|Edit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
|
||||||
|
"matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory",
|
||||||
|
"timeout": 3
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"postToolUse": [
|
"postToolUse": [
|
||||||
|
|||||||
@@ -54,6 +54,16 @@
|
|||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/enforce_metadata_defaults.sh",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"PostToolUse": [
|
"PostToolUse": [
|
||||||
@@ -74,12 +84,7 @@
|
|||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_commit.sh",
|
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_commit.sh",
|
||||||
"timeout": 5
|
"timeout": 5
|
||||||
}
|
},
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"matcher": "Bash",
|
|
||||||
"hooks": [
|
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh",
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Shared content-chunking utilities for mem0-plugin import scripts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
MIN_CHUNK_CHARS = 50
|
||||||
|
MAX_CHUNK_CHARS = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
def split_by_headers(content: str, header_prefix: str = "## ") -> list[str]:
|
||||||
|
"""Split content by Markdown header lines (e.g. '## ').
|
||||||
|
|
||||||
|
The header line is included at the start of each chunk.
|
||||||
|
Returns a list of non-empty chunk strings.
|
||||||
|
"""
|
||||||
|
chunks: list[str] = []
|
||||||
|
current_lines: list[str] = []
|
||||||
|
|
||||||
|
for line in content.splitlines(keepends=True):
|
||||||
|
if line.startswith(header_prefix) and current_lines:
|
||||||
|
chunk = "".join(current_lines).strip()
|
||||||
|
if chunk:
|
||||||
|
chunks.append(chunk)
|
||||||
|
current_lines = [line]
|
||||||
|
else:
|
||||||
|
current_lines.append(line)
|
||||||
|
|
||||||
|
if current_lines:
|
||||||
|
chunk = "".join(current_lines).strip()
|
||||||
|
if chunk:
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def split_by_hr_or_headers(content: str) -> list[str]:
|
||||||
|
"""Split content by '---' horizontal rules or '## ' headers.
|
||||||
|
|
||||||
|
Used for .continue/rules.md which may use either convention.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Split on lines that are exactly "---" or start with "## "
|
||||||
|
chunks: list[str] = []
|
||||||
|
current_lines: list[str] = []
|
||||||
|
|
||||||
|
for line in content.splitlines(keepends=True):
|
||||||
|
is_hr = re.match(r"^---\s*$", line)
|
||||||
|
is_h2 = line.startswith("## ")
|
||||||
|
|
||||||
|
if (is_hr or is_h2) and current_lines:
|
||||||
|
chunk = "".join(current_lines).strip()
|
||||||
|
if chunk:
|
||||||
|
chunks.append(chunk)
|
||||||
|
current_lines = [] if is_hr else [line]
|
||||||
|
else:
|
||||||
|
current_lines.append(line)
|
||||||
|
|
||||||
|
if current_lines:
|
||||||
|
chunk = "".join(current_lines).strip()
|
||||||
|
if chunk:
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
def filter_and_truncate(chunks: list[str]) -> list[str]:
|
||||||
|
"""Filter out chunks shorter than MIN_CHUNK_CHARS, truncate long chunks."""
|
||||||
|
result: list[str] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
if len(chunk) < MIN_CHUNK_CHARS:
|
||||||
|
continue
|
||||||
|
if len(chunk) > MAX_CHUNK_CHARS:
|
||||||
|
chunk = chunk[:MAX_CHUNK_CHARS]
|
||||||
|
result.append(chunk)
|
||||||
|
return result
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
Resolution priority (project_id):
|
Resolution priority (project_id):
|
||||||
1. MEM0_PROJECT_ID env var (explicit override)
|
1. MEM0_PROJECT_ID env var (explicit override)
|
||||||
2. ~/.mem0/project_map.json lookup by cwd
|
2. ~/.mem0/project_map.json lookup by cwd
|
||||||
|
2b. ~/.mem0/project_map.json lookup by remote hash (self-healing fallback)
|
||||||
3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
|
3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
|
||||||
e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
|
e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
|
||||||
4. Fallback: basename of cwd
|
4. Fallback: basename of cwd
|
||||||
@@ -10,6 +11,7 @@ Resolution priority (project_id):
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -34,6 +36,19 @@ def resolve_project_id(cwd: str | None = None) -> str:
|
|||||||
mapped = project_map.get(cwd, "").strip()
|
mapped = project_map.get(cwd, "").strip()
|
||||||
if mapped:
|
if mapped:
|
||||||
return mapped
|
return mapped
|
||||||
|
# 2b. Remote hash fallback (self-healing when folder is moved/renamed)
|
||||||
|
remote_key = _remote_hash_key(cwd)
|
||||||
|
if remote_key:
|
||||||
|
mapped = project_map.get(remote_key, "").strip()
|
||||||
|
if mapped:
|
||||||
|
# Self-heal: write the new CWD key so future lookups are fast
|
||||||
|
project_map[cwd] = mapped
|
||||||
|
try:
|
||||||
|
with open(map_path, "w") as f:
|
||||||
|
json.dump(project_map, f, indent=2)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return mapped
|
||||||
except (OSError, json.JSONDecodeError, AttributeError):
|
except (OSError, json.JSONDecodeError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -76,7 +91,7 @@ def resolve_branch(cwd: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def save_project_mapping(cwd: str, project_id: str) -> None:
|
def save_project_mapping(cwd: str, project_id: str) -> None:
|
||||||
"""Write cwd -> project_id into ~/.mem0/project_map.json."""
|
"""Write cwd -> project_id (and remote hash key -> project_id) into ~/.mem0/project_map.json."""
|
||||||
mem0_dir = os.path.expanduser("~/.mem0")
|
mem0_dir = os.path.expanduser("~/.mem0")
|
||||||
os.makedirs(mem0_dir, exist_ok=True)
|
os.makedirs(mem0_dir, exist_ok=True)
|
||||||
map_path = os.path.join(mem0_dir, "project_map.json")
|
map_path = os.path.join(mem0_dir, "project_map.json")
|
||||||
@@ -88,10 +103,40 @@ def save_project_mapping(cwd: str, project_id: str) -> None:
|
|||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
project_map = {}
|
project_map = {}
|
||||||
project_map[cwd] = project_id
|
project_map[cwd] = project_id
|
||||||
|
# Also write the remote hash key so the mapping survives folder moves/renames
|
||||||
|
remote_key = _remote_hash_key(cwd)
|
||||||
|
if remote_key:
|
||||||
|
project_map[remote_key] = project_id
|
||||||
with open(map_path, "w") as f:
|
with open(map_path, "w") as f:
|
||||||
json.dump(project_map, f, indent=2)
|
json.dump(project_map, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_hash_key(cwd: str | None = None) -> str:
|
||||||
|
"""Return a stable key derived from the git remote URL.
|
||||||
|
|
||||||
|
Runs ``git config --get remote.origin.url`` in *cwd* and returns a string
|
||||||
|
of the form ``remote:<sha256(url)[:16]>``. Returns an empty string when
|
||||||
|
the directory is not a git repo or has no remote configured.
|
||||||
|
"""
|
||||||
|
if cwd is None:
|
||||||
|
cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "config", "--get", "remote.origin.url"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
url = result.stdout.strip()
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
digest = hashlib.sha256(url.encode()).hexdigest()[:16]
|
||||||
|
return f"remote:{digest}"
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _remote_url_to_slug(url: str) -> str:
|
def _remote_url_to_slug(url: str) -> str:
|
||||||
"""Convert a git remote URL to a deterministic slug.
|
"""Convert a git remote URL to a deterministic slug.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from _chunking import filter_and_truncate, split_by_headers
|
||||||
from _identity import resolve_api_key, resolve_user_id
|
from _identity import resolve_api_key, resolve_user_id
|
||||||
from _project import resolve_branch, resolve_project_id
|
from _project import resolve_branch, resolve_project_id
|
||||||
|
|
||||||
@@ -198,10 +199,24 @@ def main() -> None:
|
|||||||
log.debug("Cannot read %s: %s", filename, e)
|
log.debug("Cannot read %s: %s", filename, e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if post_memory(api_key, content, user_id, filename, project_id, branch):
|
is_markdown = filename.endswith(".md")
|
||||||
|
if is_markdown:
|
||||||
|
chunks = filter_and_truncate(split_by_headers(content))
|
||||||
|
else:
|
||||||
|
chunks = filter_and_truncate([content])
|
||||||
|
|
||||||
|
if not chunks:
|
||||||
|
chunks = [content[:10000]]
|
||||||
|
|
||||||
|
success = True
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
chunk_name = f"{filename}[{i+1}/{len(chunks)}]" if len(chunks) > 1 else filename
|
||||||
|
if not post_memory(api_key, chunk, user_id, chunk_name, project_id, branch):
|
||||||
|
success = False
|
||||||
|
|
||||||
|
if success:
|
||||||
hashes[hash_key] = current_hash
|
hashes[hash_key] = current_hash
|
||||||
updated = True
|
updated = True
|
||||||
# on API failure we don't update the hash — retry next session
|
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
save_hashes(hashes)
|
save_hashes(hashes)
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PreToolUse hook for mcp__mem0__add_memory.
|
||||||
|
# Injects default metadata fields (confidence, files, source, type) when the
|
||||||
|
# agent omits them. Uses the hookSpecificOutput.updatedInput contract to
|
||||||
|
# actually modify the tool call parameters.
|
||||||
|
#
|
||||||
|
# Hook contract:
|
||||||
|
# exit 0 = allow. If stdout contains {"hookSpecificOutput": {"updatedInput": ...}},
|
||||||
|
# the updatedInput replaces the tool's input parameters.
|
||||||
|
# exit 2 = block (stderr shown as rejection reason).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INPUT=$(cat)
|
||||||
|
|
||||||
|
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
|
||||||
|
case "$TOOL_NAME" in
|
||||||
|
mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory) ;;
|
||||||
|
*) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // "{}"' 2>/dev/null)
|
||||||
|
|
||||||
|
PATCHED=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
try:
|
||||||
|
inp = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
meta = inp.get('metadata') or {}
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
if 'confidence' not in meta:
|
||||||
|
meta['confidence'] = 0.7
|
||||||
|
changed = True
|
||||||
|
if 'files' not in meta:
|
||||||
|
meta['files'] = ['*']
|
||||||
|
changed = True
|
||||||
|
if 'source' not in meta:
|
||||||
|
meta['source'] = 'auto_capture'
|
||||||
|
changed = True
|
||||||
|
if 'type' not in meta:
|
||||||
|
meta['type'] = 'task_learning'
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
inp['metadata'] = meta
|
||||||
|
print(json.dumps(inp))
|
||||||
|
" <<< "$TOOL_INPUT" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ -n "$PATCHED" ]; then
|
||||||
|
# Use hookSpecificOutput.updatedInput to actually modify the tool call
|
||||||
|
jq -n --argjson updated "$PATCHED" '{
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"permissionDecision": "allow",
|
||||||
|
"updatedInput": $updated
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -11,6 +11,8 @@ REQ_STAMP="${DATA_DIR}/requirements.txt"
|
|||||||
|
|
||||||
mkdir -p "${DATA_DIR}"
|
mkdir -p "${DATA_DIR}"
|
||||||
|
|
||||||
|
LOCKDIR="${DATA_DIR}/.install-lock"
|
||||||
|
|
||||||
needs_install=false
|
needs_install=false
|
||||||
|
|
||||||
if [ ! -f "${VENV_DIR}/bin/python3" ]; then
|
if [ ! -f "${VENV_DIR}/bin/python3" ]; then
|
||||||
@@ -20,13 +22,29 @@ elif ! diff -q "${REQ_SRC}" "${REQ_STAMP}" >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${needs_install}" = "true" ]; then
|
if [ "${needs_install}" = "true" ]; then
|
||||||
python3 -m venv "${VENV_DIR}" 2>/dev/null || python -m venv "${VENV_DIR}"
|
if mkdir "${LOCKDIR}" 2>/dev/null; then
|
||||||
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
|
# We acquired the lock — proceed with installation
|
||||||
if "${VENV_DIR}/bin/pip" install --quiet -r "${REQ_SRC}" 2>/dev/null; then
|
trap 'rmdir "${LOCKDIR}" 2>/dev/null || true' EXIT
|
||||||
cp "${REQ_SRC}" "${REQ_STAMP}"
|
python3 -m venv "${VENV_DIR}" 2>/dev/null || python -m venv "${VENV_DIR}"
|
||||||
|
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
|
||||||
|
if "${VENV_DIR}/bin/pip" install --quiet -r "${REQ_SRC}" 2>/dev/null; then
|
||||||
|
cp "${REQ_SRC}" "${REQ_STAMP}"
|
||||||
|
rm -f "${DATA_DIR}/.install-failed"
|
||||||
|
else
|
||||||
|
rm -f "${REQ_STAMP}"
|
||||||
|
touch "${DATA_DIR}/.install-failed"
|
||||||
|
echo "mem0 plugin: failed to install Python dependencies" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
rm -f "${REQ_STAMP}"
|
# Another process holds the lock — wait up to 60s for it to finish
|
||||||
echo "mem0 plugin: failed to install Python dependencies" >&2
|
for i in $(seq 1 60); do
|
||||||
exit 0
|
[ ! -d "${LOCKDIR}" ] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
# Check if the other process's install failed
|
||||||
|
if [ -f "${DATA_DIR}/.install-failed" ]; then
|
||||||
|
echo "mem0 plugin: dependency installation failed (by another session)" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -24,87 +24,15 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from _chunking import (
|
||||||
|
filter_and_truncate,
|
||||||
|
split_by_headers,
|
||||||
|
split_by_hr_or_headers,
|
||||||
|
)
|
||||||
from _identity import resolve_api_key, resolve_user_id
|
from _identity import resolve_api_key, resolve_user_id
|
||||||
from _project import resolve_branch, resolve_project_id
|
from _project import resolve_branch, resolve_project_id
|
||||||
|
|
||||||
API_URL = "https://api.mem0.ai"
|
API_URL = "https://api.mem0.ai"
|
||||||
MIN_CHUNK_CHARS = 50
|
|
||||||
MAX_CHUNK_CHARS = 10_000
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Content splitting utilities
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def split_by_headers(content: str, header_prefix: str = "## ") -> list[str]:
|
|
||||||
"""Split content by Markdown header lines (e.g. '## ').
|
|
||||||
|
|
||||||
The header line is included at the start of each chunk.
|
|
||||||
Returns a list of non-empty chunk strings.
|
|
||||||
"""
|
|
||||||
chunks: list[str] = []
|
|
||||||
current_lines: list[str] = []
|
|
||||||
|
|
||||||
for line in content.splitlines(keepends=True):
|
|
||||||
if line.startswith(header_prefix) and current_lines:
|
|
||||||
chunk = "".join(current_lines).strip()
|
|
||||||
if chunk:
|
|
||||||
chunks.append(chunk)
|
|
||||||
current_lines = [line]
|
|
||||||
else:
|
|
||||||
current_lines.append(line)
|
|
||||||
|
|
||||||
if current_lines:
|
|
||||||
chunk = "".join(current_lines).strip()
|
|
||||||
if chunk:
|
|
||||||
chunks.append(chunk)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
def split_by_hr_or_headers(content: str) -> list[str]:
|
|
||||||
"""Split content by '---' horizontal rules or '## ' headers.
|
|
||||||
|
|
||||||
Used for .continue/rules.md which may use either convention.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
# Split on lines that are exactly "---" or start with "## "
|
|
||||||
chunks: list[str] = []
|
|
||||||
current_lines: list[str] = []
|
|
||||||
|
|
||||||
for line in content.splitlines(keepends=True):
|
|
||||||
is_hr = re.match(r"^---\s*$", line)
|
|
||||||
is_h2 = line.startswith("## ")
|
|
||||||
|
|
||||||
if (is_hr or is_h2) and current_lines:
|
|
||||||
chunk = "".join(current_lines).strip()
|
|
||||||
if chunk:
|
|
||||||
chunks.append(chunk)
|
|
||||||
current_lines = [] if is_hr else [line]
|
|
||||||
else:
|
|
||||||
current_lines.append(line)
|
|
||||||
|
|
||||||
if current_lines:
|
|
||||||
chunk = "".join(current_lines).strip()
|
|
||||||
if chunk:
|
|
||||||
chunks.append(chunk)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
def filter_and_truncate(chunks: list[str]) -> list[str]:
|
|
||||||
"""Filter out chunks shorter than MIN_CHUNK_CHARS, truncate long chunks."""
|
|
||||||
result: list[str] = []
|
|
||||||
for chunk in chunks:
|
|
||||||
if len(chunk) < MIN_CHUNK_CHARS:
|
|
||||||
continue
|
|
||||||
if len(chunk) > MAX_CHUNK_CHARS:
|
|
||||||
chunk = chunk[:MAX_CHUNK_CHARS]
|
|
||||||
result.append(chunk)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# API helpers
|
# API helpers
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ esac
|
|||||||
|
|
||||||
# Detect stack traces and error patterns in command output
|
# Detect stack traces and error patterns in command output
|
||||||
HAS_ERROR=""
|
HAS_ERROR=""
|
||||||
if echo "$TOOL_RESULT" | grep -qiE '(Traceback \(most recent|Error:|Exception:|panic:|FAILED|fatal:|FAIL:| at .+\.[a-z]+:[0-9]+|error\[E[0-9]+\])'; then
|
if echo "$TOOL_RESULT" | grep -qE '(Traceback \(most recent call last\)|panic: |FATAL:|error\[E[0-9]+\])'; then
|
||||||
|
HAS_ERROR="true"
|
||||||
|
elif [ "$(echo "$TOOL_RESULT" | grep -cE '(Error:|Exception:)')" -ge 2 ]; then
|
||||||
HAS_ERROR="true"
|
HAS_ERROR="true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Hook: PreToolUse (matcher: Bash)
|
# Hook: PreToolUse (matcher: Bash)
|
||||||
#
|
#
|
||||||
# Detects `git commit` commands and:
|
# Detects `git commit` commands and searches for relevant memories
|
||||||
# 1. Fires on_pre_commit.py in the background to capture staged changes as memory
|
# about the changed files, surfacing them as pre-commit context.
|
||||||
# 2. Searches for relevant memories about the changed files and surfaces them
|
|
||||||
#
|
#
|
||||||
# Input: JSON on stdin with tool_name, tool_input
|
# Input: JSON on stdin with tool_name, tool_input
|
||||||
# Output: JSON with additionalContext (relevant memories for the commit)
|
# Output: JSON with additionalContext (relevant memories for the commit)
|
||||||
|
|||||||
@@ -44,8 +44,15 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# C5: commit prompts default OFF — only prompt if explicitly enabled in mem0.md
|
||||||
|
MEM0_CWD=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
|
||||||
|
COMMIT_PROMPTS=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.commit_prompts "$MEM0_CWD" 2>/dev/null || echo "")
|
||||||
|
if [ "$COMMIT_PROMPTS" != "true" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Extract changed files from commit
|
# Extract changed files from commit
|
||||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | head -10 || echo "")
|
CHANGED_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | head -10 || echo "")
|
||||||
if [ -z "$CHANGED_FILES" ]; then
|
if [ -z "$CHANGED_FILES" ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Pre-commit memory capture.
|
|
||||||
|
|
||||||
Captures a summary of staged changes as a mem0 memory before each commit.
|
|
||||||
Runs as a background fire-and-forget call — never blocks the commit.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
git diff --cached --stat | python3 on_pre_commit.py
|
|
||||||
# or with full diff:
|
|
||||||
git diff --cached | python3 on_pre_commit.py --full
|
|
||||||
|
|
||||||
Env vars required: MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY)
|
|
||||||
Env vars optional: MEM0_RESOLVED_USER_ID, MEM0_PROJECT_ID, MEM0_BRANCH
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
|
||||||
from _identity import resolve_api_key, resolve_user_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
from _project import resolve_branch, resolve_project_id
|
|
||||||
except ImportError:
|
|
||||||
def resolve_project_id() -> str:
|
|
||||||
return os.path.basename(os.getcwd())
|
|
||||||
|
|
||||||
def resolve_branch() -> str:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
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"],
|
|
||||||
capture_output=True, text=True, timeout=5,
|
|
||||||
)
|
|
||||||
return result.stdout.strip() if result.returncode == 0 else ""
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def get_staged_summary() -> str:
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "diff", "--cached", "--stat"],
|
|
||||||
capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
return result.stdout.strip() if result.returncode == 0 else ""
|
|
||||||
except Exception:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
api_key = resolve_api_key()
|
|
||||||
if not api_key:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
diff_input = sys.stdin.read().strip() if not sys.stdin.isatty() else ""
|
|
||||||
staged = diff_input or get_staged_summary()
|
|
||||||
if not staged or len(staged) < 10:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or resolve_user_id()
|
|
||||||
project_id = os.environ.get("MEM0_PROJECT_ID") or resolve_project_id()
|
|
||||||
branch = os.environ.get("MEM0_BRANCH") or resolve_branch()
|
|
||||||
commit_msg = get_commit_message()
|
|
||||||
|
|
||||||
lines = staged.splitlines()
|
|
||||||
if len(lines) > 30:
|
|
||||||
staged = "\n".join(lines[:30]) + f"\n... ({len(lines) - 30} more lines)"
|
|
||||||
|
|
||||||
content = f"## Commit Context\n\nBranch: {branch}\n"
|
|
||||||
if commit_msg:
|
|
||||||
content += f"Message: {commit_msg}\n"
|
|
||||||
content += f"\n### Staged Changes\n```\n{staged}\n```"
|
|
||||||
|
|
||||||
body = json.dumps({
|
|
||||||
"messages": [{"role": "user", "content": content}],
|
|
||||||
"user_id": user_id,
|
|
||||||
"app_id": project_id,
|
|
||||||
"metadata": {
|
|
||||||
"type": "commit_context",
|
|
||||||
"branch": branch,
|
|
||||||
"source": "pre-commit",
|
|
||||||
},
|
|
||||||
"infer": False,
|
|
||||||
}).encode()
|
|
||||||
|
|
||||||
req = urllib.request.Request(
|
|
||||||
"https://api.mem0.ai/v3/memories/add/",
|
|
||||||
data=body,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Token {api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=10):
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -41,6 +41,15 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Check for failed dependency installation and warn the user
|
||||||
|
_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}"
|
||||||
|
if [ -f "${_DATA_DIR}/.install-failed" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ mem0 SDK installation failed. Some features may not work."
|
||||||
|
echo "Run: ${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}/scripts/ensure_deps.sh"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout)
|
# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout)
|
||||||
MEM0_COUNT="?"
|
MEM0_COUNT="?"
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
@@ -96,10 +105,14 @@ if command -v python3 >/dev/null 2>&1; then
|
|||||||
MEM0_PROJECT_CONFIG=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --full "$MEM0_CWD_RESOLVED" 2>/dev/null || echo "{}")
|
MEM0_PROJECT_CONFIG=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --full "$MEM0_CWD_RESOLVED" 2>/dev/null || echo "{}")
|
||||||
fi
|
fi
|
||||||
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
|
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
|
||||||
|
_CONFIG_KEYS=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))" 2>/dev/null || echo "?")
|
||||||
echo "### Project Config (mem0.md)"
|
echo "### Project Config (mem0.md)"
|
||||||
echo "\`\`\`json"
|
echo "\`mem0.md loaded (${_CONFIG_KEYS} sections configured)\`"
|
||||||
echo "$MEM0_PROJECT_CONFIG"
|
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||||
echo "\`\`\`"
|
echo "\`\`\`json"
|
||||||
|
echo "$MEM0_PROJECT_CONFIG"
|
||||||
|
echo "\`\`\`"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -108,6 +121,8 @@ if [ "$SOURCE" = "startup" ]; then
|
|||||||
_SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--')
|
_SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--')
|
||||||
_ONBOARD_MARKER="$HOME/.mem0/.onboarded_${_SAFE_PID}"
|
_ONBOARD_MARKER="$HOME/.mem0/.onboarded_${_SAFE_PID}"
|
||||||
if [ ! -f "$_ONBOARD_MARKER" ]; then
|
if [ ! -f "$_ONBOARD_MARKER" ]; then
|
||||||
|
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||||
|
touch "$_ONBOARD_MARKER"
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
## Mem0 First Run — Automatic Onboarding
|
## Mem0 First Run — Automatic Onboarding
|
||||||
|
|
||||||
@@ -135,6 +150,22 @@ IMPORTANT: Do NOT skip this step. Always bootstrap context first.
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Detect native Claude Code auto-memory for THIS project
|
||||||
|
_PROJ_KEY=$(printf '%s' "$MEM0_CWD_RESOLVED" | tr '/' '-')
|
||||||
|
_MEMORY_MD="$HOME/.claude/projects/${_PROJ_KEY}/memory/MEMORY.md"
|
||||||
|
if [ -f "$_MEMORY_MD" ] && [ -s "$_MEMORY_MD" ]; then
|
||||||
|
cat <<MEMEOF
|
||||||
|
|
||||||
|
### Native auto-memory detected
|
||||||
|
|
||||||
|
Found \`$_MEMORY_MD\`. The mem0 plugin handles all memory storage.
|
||||||
|
To avoid two parallel memory systems:
|
||||||
|
- Add \`"autoMemoryEnabled": false\` to \`~/.claude/settings.json\`
|
||||||
|
- Or run \`/mem0:import\` to migrate existing MEMORY.md content into mem0
|
||||||
|
|
||||||
|
MEMEOF
|
||||||
|
fi
|
||||||
|
|
||||||
# Auto-import declarative project files in background
|
# Auto-import declarative project files in background
|
||||||
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
|
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
|
||||||
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
|
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
|
||||||
|
|||||||
@@ -24,9 +24,17 @@ if [ ${#RESULT_SUMMARY} -lt 50 ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Read subagent skip list from config, default to Explore|Plan
|
||||||
|
_SKIP_CSV=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.subagent_skip "$(git rev-parse --show-toplevel 2>/dev/null || echo ".")" 2>/dev/null || echo "")
|
||||||
|
if [ -n "$_SKIP_CSV" ]; then
|
||||||
|
_SKIP_PATTERN=$(echo "$_SKIP_CSV" | tr -d ' ' | tr ',' '|')
|
||||||
|
else
|
||||||
|
_SKIP_PATTERN="Explore|Plan"
|
||||||
|
fi
|
||||||
|
|
||||||
# Skip explorer/plan agents — read-only, rarely produce storable learnings
|
# Skip explorer/plan agents — read-only, rarely produce storable learnings
|
||||||
case "$AGENT_TYPE" in
|
case "$AGENT_TYPE" in
|
||||||
Explore|Plan)
|
$_SKIP_PATTERN)
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
|
|
||||||
# Detect stack traces and error patterns in the prompt (no API needed)
|
# Detect stack traces and error patterns in the prompt (no API needed)
|
||||||
HAS_ERROR=""
|
HAS_ERROR=""
|
||||||
if echo "$PROMPT" | grep -qiE '(Traceback|Error:|Exception:|panic:|FAILED|fatal:| at .+\.[a-z]+:[0-9]+)'; then
|
if echo "$PROMPT" | grep -qE '(Traceback|panic:)'; then
|
||||||
|
HAS_ERROR="true"
|
||||||
|
elif echo "$PROMPT" | grep -qE '^\s*fatal: '; then
|
||||||
|
HAS_ERROR="true"
|
||||||
|
elif [ "$(echo "$PROMPT" | grep -cE '(Error:|Exception:|FAIL:)')" -ge 2 ]; then
|
||||||
HAS_ERROR="true"
|
HAS_ERROR="true"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ def load_full_config(cwd: str | None = None) -> dict:
|
|||||||
if ignore:
|
if ignore:
|
||||||
config["ignore"] = ignore
|
config["ignore"] = ignore
|
||||||
|
|
||||||
|
settings = parse_section_kv(content, "Settings")
|
||||||
|
if settings:
|
||||||
|
config["settings"] = settings
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
@@ -229,10 +233,44 @@ def main() -> int:
|
|||||||
|
|
||||||
With ``--full``, prints the complete config. Without it, prints only
|
With ``--full``, prints the complete config. Without it, prints only
|
||||||
retention policies (backward-compatible).
|
retention policies (backward-compatible).
|
||||||
|
|
||||||
|
With ``--key <dotted.path>``, prints the scalar value at that path in the
|
||||||
|
full config (e.g. ``--key settings.commit_prompts``). Prints an empty
|
||||||
|
string when the key is absent. Exits 1 only on unexpected errors.
|
||||||
"""
|
"""
|
||||||
full_mode = "--full" in sys.argv
|
full_mode = "--full" in sys.argv
|
||||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
|
||||||
cwd = args[0] if args else os.getcwd()
|
# Extract --key <value>
|
||||||
|
key_path: str | None = None
|
||||||
|
raw_args = sys.argv[1:]
|
||||||
|
filtered_args: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(raw_args):
|
||||||
|
if raw_args[i] == "--key" and i + 1 < len(raw_args):
|
||||||
|
key_path = raw_args[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif raw_args[i].startswith("--key="):
|
||||||
|
key_path = raw_args[i][len("--key="):]
|
||||||
|
i += 1
|
||||||
|
elif raw_args[i].startswith("--"):
|
||||||
|
i += 1 # skip other flags like --full
|
||||||
|
else:
|
||||||
|
filtered_args.append(raw_args[i])
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
cwd = filtered_args[0] if filtered_args else os.getcwd()
|
||||||
|
|
||||||
|
if key_path is not None:
|
||||||
|
config = load_full_config(cwd)
|
||||||
|
# Traverse dotted path
|
||||||
|
value: object = config
|
||||||
|
for part in key_path.split("."):
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
value = None
|
||||||
|
break
|
||||||
|
value = value.get(part)
|
||||||
|
print(value if value is not None else "")
|
||||||
|
return 0
|
||||||
|
|
||||||
if full_mode:
|
if full_mode:
|
||||||
config = load_full_config(cwd)
|
config = load_full_config(cwd)
|
||||||
|
|||||||
@@ -27,7 +27,16 @@ import sys
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
PLUGIN_VERSION = "0.2.1"
|
|
||||||
|
def _load_plugin_version() -> str:
|
||||||
|
try:
|
||||||
|
plugin_json = os.path.join(os.path.dirname(__file__), "..", ".claude-plugin", "plugin.json")
|
||||||
|
with open(plugin_json) as f:
|
||||||
|
return json.load(f).get("version", "unknown")
|
||||||
|
except (OSError, json.JSONDecodeError, KeyError):
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
PLUGIN_VERSION = _load_plugin_version()
|
||||||
|
|
||||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||||
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
||||||
@@ -42,10 +51,10 @@ def _sha256(value: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _distinct_id() -> str:
|
def _distinct_id() -> str:
|
||||||
"""Stable anonymous ID: MD5 of API key if available, else SHA-256 of username."""
|
"""Stable anonymous ID: SHA-256 of API key if available, else SHA-256 of username."""
|
||||||
api_key = os.environ.get("MEM0_API_KEY") or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") or ""
|
api_key = os.environ.get("MEM0_API_KEY") or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") or ""
|
||||||
if api_key:
|
if api_key:
|
||||||
return hashlib.md5(api_key.encode()).hexdigest()
|
return hashlib.sha256(api_key.encode()).hexdigest()[:32]
|
||||||
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
|
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
|
||||||
return _sha256(user_id)
|
return _sha256(user_id)
|
||||||
|
|
||||||
@@ -75,6 +84,7 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di
|
|||||||
"distinct_id": _distinct_id(),
|
"distinct_id": _distinct_id(),
|
||||||
"event": event_name,
|
"event": event_name,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
**(properties or {}),
|
||||||
"source": "plugin",
|
"source": "plugin",
|
||||||
"platform": detect_platform(),
|
"platform": detect_platform(),
|
||||||
"plugin_version": PLUGIN_VERSION,
|
"plugin_version": PLUGIN_VERSION,
|
||||||
@@ -84,7 +94,6 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di
|
|||||||
"sample_rate": SAMPLE_RATE,
|
"sample_rate": SAMPLE_RATE,
|
||||||
"$process_person_profile": False,
|
"$process_person_profile": False,
|
||||||
"$lib": "posthog-python",
|
"$lib": "posthog-python",
|
||||||
**(properties or {}),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
---
|
|
||||||
name: mem0-digest
|
|
||||||
description: >
|
|
||||||
Summarize recent memory activity for the current project. Shows new memories,
|
|
||||||
categories touched, and growth trends over the past 7 days.
|
|
||||||
TRIGGER: user runs /mem0:digest, or asks "weekly summary", "what's new in memory",
|
|
||||||
"mem0 digest", "memory recap".
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mem0 Weekly Digest
|
|
||||||
|
|
||||||
Summarize recent memory activity for the current project.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
### Step 1: Fetch recent memories
|
|
||||||
|
|
||||||
Call `search_memories` in parallel with different time-scoped queries:
|
|
||||||
|
|
||||||
1. `query="decisions made this week"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"created_at": {"gte": "<7 days ago YYYY-MM-DD>"}}]}`, `limit=20`
|
|
||||||
2. `query="bugs errors fixes"`, same time filter, `limit=20`
|
|
||||||
3. `query="patterns conventions learnings"`, same time filter, `limit=20`
|
|
||||||
|
|
||||||
Also call `get_memories` with `user_id` + `app_id` to get the full count for comparison.
|
|
||||||
|
|
||||||
### Step 2: Deduplicate and analyze
|
|
||||||
|
|
||||||
Merge results by memory ID. For each memory, extract:
|
|
||||||
- `created_at` date
|
|
||||||
- `categories[0]` or `metadata.type`
|
|
||||||
- First 100 chars of content
|
|
||||||
|
|
||||||
Group into:
|
|
||||||
- **New this week** (created in last 7 days)
|
|
||||||
- **Older** (created before, but surfaced by search)
|
|
||||||
|
|
||||||
Calculate:
|
|
||||||
- Total memories in project
|
|
||||||
- Memories added in last 7 days
|
|
||||||
- Most active categories this week
|
|
||||||
- Days with most activity
|
|
||||||
|
|
||||||
### Step 3: Display
|
|
||||||
|
|
||||||
```
|
|
||||||
## mem0 Weekly Digest — <project_id>
|
|
||||||
Period: <start_date> to <today>
|
|
||||||
|
|
||||||
### New Memories This Week: <N>
|
|
||||||
<category>: <count>
|
|
||||||
- <memory summary, 80 chars> (<date>)
|
|
||||||
- ...
|
|
||||||
<category>: <count>
|
|
||||||
- ...
|
|
||||||
|
|
||||||
### Activity Pattern
|
|
||||||
Most active day: <day> (<N> memories)
|
|
||||||
Categories touched: <list>
|
|
||||||
|
|
||||||
### Project Totals
|
|
||||||
Total memories: <N> (up <N> from last week)
|
|
||||||
Top categories: <top 3 by count>
|
|
||||||
|
|
||||||
### Highlights
|
|
||||||
<2-3 sentence summary of the most important decisions, learnings, or patterns stored this week>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Write digest to file
|
|
||||||
|
|
||||||
After displaying, write the digest to `~/.mem0/weekly-digest.md` for persistence
|
|
||||||
and external consumption (email, Slack, etc.):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.mem0
|
|
||||||
```
|
|
||||||
|
|
||||||
Write the full digest output (same markdown shown in terminal) to `~/.mem0/weekly-digest.md`
|
|
||||||
using the Write tool. **Overwrite** the file each time — it always contains the latest digest.
|
|
||||||
|
|
||||||
Also append a one-line summary to `~/.mem0/digest-history.log` for trend tracking:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
echo "<YYYY-MM-DD> | <project_id> | +<new_count> memories | top: <top_category>" >> ~/.mem0/digest-history.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Print at the end:
|
|
||||||
```
|
|
||||||
Digest saved to ~/.mem0/weekly-digest.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Schedule recurring digests
|
|
||||||
|
|
||||||
When invoked with `--schedule` (e.g., `/mem0:digest --schedule weekly`), register
|
|
||||||
a cloud routine via Claude Code's `/schedule` command:
|
|
||||||
|
|
||||||
```
|
|
||||||
/schedule <frequency> /mem0:digest
|
|
||||||
```
|
|
||||||
|
|
||||||
For example:
|
|
||||||
- `/schedule weekly on Monday 9am /mem0:digest` — digest every Monday morning
|
|
||||||
- `/schedule daily at 8am /mem0:digest` — daily digest
|
|
||||||
|
|
||||||
Print:
|
|
||||||
```
|
|
||||||
Digest scheduled: <frequency>
|
|
||||||
Manage at: https://claude.ai/code/routines
|
|
||||||
```
|
|
||||||
|
|
||||||
If `/schedule` is unavailable, print a cron one-liner the user can install manually:
|
|
||||||
```bash
|
|
||||||
# macOS/Linux — weekly Monday 9am
|
|
||||||
(crontab -l 2>/dev/null; echo "0 9 * * 1 cd PROJECT_DIR && claude -p '/mem0:digest' >> /tmp/mem0-digest.log 2>&1") | crontab -
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Empty state
|
|
||||||
|
|
||||||
If no memories in the last 7 days:
|
|
||||||
```
|
|
||||||
No new memories in the past week for <project_id>.
|
|
||||||
Total project memories: <N>.
|
|
||||||
Tip: mem0 captures learnings automatically as you work. Start coding!
|
|
||||||
```
|
|
||||||
@@ -235,6 +235,58 @@ In auto mode:
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Forget mode (targeted deletion)
|
||||||
|
|
||||||
|
When invoked with `--forget` (e.g., `/mem0:dream --forget auth module decisions`
|
||||||
|
or `/mem0:dream --forget <memory_id>`), skip consolidation and go straight to
|
||||||
|
search-confirm-delete:
|
||||||
|
|
||||||
|
### F1: Parse input
|
||||||
|
|
||||||
|
The argument after `--forget` is either:
|
||||||
|
- A search query: `/mem0:dream --forget auth module decisions`
|
||||||
|
- A memory ID: `/mem0:dream --forget <memory_id>`
|
||||||
|
|
||||||
|
If no argument after `--forget`, ask: "What should I forget? Provide a search query or memory ID."
|
||||||
|
|
||||||
|
### F2: Find memories
|
||||||
|
|
||||||
|
**If memory ID provided** (looks like a UUID or hex string):
|
||||||
|
- Call `get_memory` with the ID to verify it exists.
|
||||||
|
- Show: `Found: "<memory content first 120 chars>" (created <date>)`
|
||||||
|
|
||||||
|
**If search query provided:**
|
||||||
|
- Call `search_memories` with:
|
||||||
|
- `query=<user's query>`
|
||||||
|
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
|
||||||
|
- `limit=10`
|
||||||
|
- Show numbered list:
|
||||||
|
```
|
||||||
|
Found <N> memories matching "<query>":
|
||||||
|
1. <content, 120 chars> (type: <type>, created: <date>) [ID: <short_id>]
|
||||||
|
2. ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### F3: Confirm
|
||||||
|
|
||||||
|
Ask: "Delete which memories? Enter numbers (e.g., 1,3,5), 'all', or 'cancel'."
|
||||||
|
For a single memory ID: "Delete this memory? [y/N]"
|
||||||
|
|
||||||
|
**Never delete without confirmation.**
|
||||||
|
|
||||||
|
### F4: Delete and report
|
||||||
|
|
||||||
|
Call `delete_memory` for each confirmed entry. Report: `Deleted <N> memories.`
|
||||||
|
|
||||||
|
### Undo recent writes
|
||||||
|
|
||||||
|
If the user says "undo last N memories" or "undo last write":
|
||||||
|
1. Run `python3 "$SCRIPT_DIR/session_stats.py" peek` to get `recent_ids`.
|
||||||
|
2. Show last N entries, ask for confirmation.
|
||||||
|
3. Delete confirmed entries via `delete_memory`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Scheduling recurring dreams
|
## Scheduling recurring dreams
|
||||||
|
|
||||||
When invoked with `--schedule` (e.g., `/mem0:dream --schedule weekly`), register a
|
When invoked with `--schedule` (e.g., `/mem0:dream --schedule weekly`), register a
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
name: mem0-forget
|
|
||||||
description: >
|
|
||||||
Delete memories by search query or memory ID. Shows matches for confirmation
|
|
||||||
before deleting. Safe — always confirms before destructive action.
|
|
||||||
TRIGGER: user runs /mem0:forget <query>, or says "forget this", "delete memory",
|
|
||||||
"remove that memory about X".
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mem0 Forget
|
|
||||||
|
|
||||||
Delete specific memories from mem0.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
### Step 1: Parse input
|
|
||||||
|
|
||||||
The user provides either:
|
|
||||||
- A search query: `/mem0:forget auth module decisions`
|
|
||||||
- A memory ID: `/mem0:forget <memory_id>`
|
|
||||||
|
|
||||||
If no argument, ask: "What should I forget? Provide a search query or memory ID."
|
|
||||||
|
|
||||||
### Step 2: Find memories
|
|
||||||
|
|
||||||
**If memory ID provided** (looks like a UUID or hex string):
|
|
||||||
- Call `get_memory` with the ID to verify it exists.
|
|
||||||
- Show: `Found: "<memory content first 120 chars>" (created <date>)`
|
|
||||||
|
|
||||||
**If search query provided:**
|
|
||||||
- Call `search_memories` with:
|
|
||||||
- `query=<user's query>`
|
|
||||||
- `user_id=<active_user_id>`
|
|
||||||
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
|
|
||||||
- `limit=10`
|
|
||||||
- Show numbered list:
|
|
||||||
```
|
|
||||||
Found <N> memories matching "<query>":
|
|
||||||
1. <content, 120 chars> (type: <type>, created: <date>) [ID: <short_id>]
|
|
||||||
2. ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Confirm
|
|
||||||
|
|
||||||
Ask: "Delete which memories? Enter numbers (e.g., 1,3,5), 'all', or 'cancel'."
|
|
||||||
|
|
||||||
For a single memory ID, ask: "Delete this memory? [y/N]"
|
|
||||||
|
|
||||||
**Never delete without confirmation.** This is destructive.
|
|
||||||
|
|
||||||
### Step 4: Delete
|
|
||||||
|
|
||||||
For each confirmed memory, call `delete_memory` with the memory ID.
|
|
||||||
|
|
||||||
### Step 5: Report
|
|
||||||
|
|
||||||
```
|
|
||||||
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."
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
---
|
|
||||||
name: mem0-import-tools
|
|
||||||
description: >
|
|
||||||
Import memories from competing AI tool configuration files into mem0.
|
|
||||||
Supports Cursor (.cursorrules), GitHub Copilot (.github/copilot-instructions.md),
|
|
||||||
Cline (memory-bank/), and Continue (.continue/rules.md).
|
|
||||||
TRIGGER: user runs /mem0:import-tools, or asks "import from cursor",
|
|
||||||
"import cursorrules", "import from cline", "import from copilot",
|
|
||||||
"import from continue", "migrate from cursor", "migrate memories".
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mem0 Import from Competing Tools
|
|
||||||
|
|
||||||
Import configuration and memory files from other AI coding tools into mem0.
|
|
||||||
|
|
||||||
## Supported Tools
|
|
||||||
|
|
||||||
| Tool | Default file/directory |
|
|
||||||
|------|----------------------|
|
|
||||||
| Cursor | `.cursorrules` |
|
|
||||||
| GitHub Copilot | `.github/copilot-instructions.md` |
|
|
||||||
| Cline | `memory-bank/` (directory of `.md` files) |
|
|
||||||
| Continue | `.continue/rules.md` |
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
### Step 1: Detect which tool files exist
|
|
||||||
|
|
||||||
Check for the presence of each tool's file/directory in the current working directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check each location
|
|
||||||
test -f .cursorrules && echo "cursor: .cursorrules"
|
|
||||||
test -f .github/copilot-instructions.md && echo "copilot: .github/copilot-instructions.md"
|
|
||||||
test -d memory-bank/ && echo "cline: memory-bank/"
|
|
||||||
test -f .continue/rules.md && echo "continue: .continue/rules.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Report findings and ask user
|
|
||||||
|
|
||||||
List all found files to the user. For example:
|
|
||||||
|
|
||||||
```
|
|
||||||
Found the following tool configuration files:
|
|
||||||
[1] Cursor rules: .cursorrules
|
|
||||||
[2] Cline memory bank: memory-bank/
|
|
||||||
|
|
||||||
Which would you like to import? (enter numbers, comma-separated, or "all"):
|
|
||||||
```
|
|
||||||
|
|
||||||
If no files are found, print:
|
|
||||||
```
|
|
||||||
No competing tool configuration files found in the current directory.
|
|
||||||
Checked: .cursorrules, .github/copilot-instructions.md, memory-bank/, .continue/rules.md
|
|
||||||
```
|
|
||||||
and stop.
|
|
||||||
|
|
||||||
### Step 3: Run the import script for each selected tool
|
|
||||||
|
|
||||||
Determine the plugin root. Use the appropriate variable for the current platform:
|
|
||||||
- Claude Code: `${CLAUDE_PLUGIN_ROOT}`
|
|
||||||
- Codex: `${CODEX_PLUGIN_ROOT}`
|
|
||||||
- Cursor: `${CURSOR_PLUGIN_ROOT}`
|
|
||||||
|
|
||||||
For each tool the user selected, run the corresponding sub-command:
|
|
||||||
|
|
||||||
**Cursor (.cursorrules):**
|
|
||||||
```bash
|
|
||||||
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" cursorrules --path .cursorrules
|
|
||||||
```
|
|
||||||
|
|
||||||
**GitHub Copilot:**
|
|
||||||
```bash
|
|
||||||
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" copilot --path .github/copilot-instructions.md
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cline:**
|
|
||||||
```bash
|
|
||||||
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" cline --path memory-bank/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Continue:**
|
|
||||||
```bash
|
|
||||||
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" continue --path .continue/rules.md
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Report results
|
|
||||||
|
|
||||||
After each script runs, echo its output to the user. Then print a combined summary:
|
|
||||||
|
|
||||||
```
|
|
||||||
Import complete.
|
|
||||||
Cursor: <N> memories
|
|
||||||
Copilot: <N> memories
|
|
||||||
Total: <N> memories imported into project <project_id>
|
|
||||||
```
|
|
||||||
|
|
||||||
Adjust the summary to reflect only the tools that were actually imported.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Memories are imported with `infer=False` — no AI inference is applied, content is stored as-is.
|
|
||||||
- Each section or file becomes a separate memory tagged with `metadata.source=<tool>-import` and `metadata.type=project_profile`.
|
|
||||||
- Sections shorter than 50 characters are automatically skipped (too short to be useful).
|
|
||||||
- Content longer than 10,000 characters is automatically truncated per chunk.
|
|
||||||
- You can re-run this skill safely — duplicate content will be caught by mem0's deduplication.
|
|
||||||
@@ -98,6 +98,87 @@ If any failed:
|
|||||||
Imported <N>/<total> memories into project <project_id> (<failed> failed)
|
Imported <N>/<total> memories into project <project_id> (<failed> failed)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Importing from competing AI tools (`--tools`)
|
||||||
|
|
||||||
|
When invoked with `--tools` (e.g., `/mem0:import --tools`), detect and import
|
||||||
|
from competing AI tool configuration files:
|
||||||
|
|
||||||
|
### Supported tools
|
||||||
|
|
||||||
|
| Tool | File/directory |
|
||||||
|
|------|---------------|
|
||||||
|
| Cursor | `.cursorrules` |
|
||||||
|
| GitHub Copilot | `.github/copilot-instructions.md` |
|
||||||
|
| Cline | `memory-bank/` (directory of `.md` files) |
|
||||||
|
| Continue | `.continue/rules.md` |
|
||||||
|
|
||||||
|
### T1: Detect
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -f .cursorrules && echo "cursor: .cursorrules"
|
||||||
|
test -f .github/copilot-instructions.md && echo "copilot: .github/copilot-instructions.md"
|
||||||
|
test -d memory-bank/ && echo "cline: memory-bank/"
|
||||||
|
test -f .continue/rules.md && echo "continue: .continue/rules.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
### T2: Ask user
|
||||||
|
|
||||||
|
List found files, ask which to import (numbers, comma-separated, or "all").
|
||||||
|
If none found:
|
||||||
|
```
|
||||||
|
No competing tool configuration files found.
|
||||||
|
Checked: .cursorrules, .github/copilot-instructions.md, memory-bank/, .continue/rules.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### T3: Run import
|
||||||
|
|
||||||
|
For each selected tool:
|
||||||
|
```bash
|
||||||
|
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" <tool> --path <file>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tools: `cursorrules`, `copilot`, `cline`, `continue`.
|
||||||
|
|
||||||
|
### T4: Report
|
||||||
|
|
||||||
|
```
|
||||||
|
Import complete.
|
||||||
|
Cursor: <N> memories
|
||||||
|
Copilot: <N> memories
|
||||||
|
Total: <N> memories imported into project <project_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes: `infer=False`, tagged `metadata.source=<tool>-import`, sections <50 chars
|
||||||
|
skipped, chunks >10k chars truncated, safe to re-run (deduplication handles it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Importing Claude Code's native MEMORY.md
|
||||||
|
|
||||||
|
When invoked with a path to Claude Code's native `MEMORY.md` file (typically
|
||||||
|
`~/.claude/projects/<proj-key>/memory/MEMORY.md`), or when `on_session_start.sh`
|
||||||
|
detects native auto-memory and the user chooses to import:
|
||||||
|
|
||||||
|
1. Read the file. It contains newline-separated memory entries (one fact per line,
|
||||||
|
sometimes with `- ` bullet prefix).
|
||||||
|
2. Split by non-empty lines. Each line becomes one memory.
|
||||||
|
3. Skip lines shorter than 20 characters or lines that are just headers (`#`).
|
||||||
|
4. For each line, call `add_memory` with:
|
||||||
|
- `messages=[{"role": "user", "content": "<line>"}]`
|
||||||
|
- `user_id=<active_user_id>`
|
||||||
|
- `app_id=<active_project_id>`
|
||||||
|
- `metadata={"type": "task_learning", "source": "memory-md-import", "confidence": 0.8}`
|
||||||
|
- `infer=False`
|
||||||
|
5. Report: `Imported <N> memories from MEMORY.md into project <project_id>`
|
||||||
|
6. Suggest disabling native auto-memory:
|
||||||
|
```
|
||||||
|
To avoid duplicate memory systems, add to ~/.claude/settings.json:
|
||||||
|
"autoMemoryEnabled": false
|
||||||
|
```
|
||||||
|
|
||||||
|
This handles the cold-start gap when a user has been using Claude Code's native
|
||||||
|
memory and switches to mem0.
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
- If the parser script is not found at `<PLUGIN_ROOT>/scripts/parse_export_file.py`, print an error and stop.
|
- If the parser script is not found at `<PLUGIN_ROOT>/scripts/parse_export_file.py`, print an error and stop.
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
name: mem0-list-projects
|
||||||
|
description: >
|
||||||
|
List all project scopes (app_ids) that have stored memories for the current
|
||||||
|
user. Essential for /mem0:switch-project discoverability and cross-project
|
||||||
|
workflows.
|
||||||
|
TRIGGER: user runs /mem0:list-projects, or asks "what projects does mem0 know",
|
||||||
|
"list my mem0 projects", "show all projects", "which app_ids exist".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mem0 List Projects
|
||||||
|
|
||||||
|
Show all known project scopes for the current user.
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
### Step 1: Fetch memories to discover app_ids
|
||||||
|
|
||||||
|
There is no dedicated "list projects" API endpoint. Discover projects by fetching
|
||||||
|
the user's memories and extracting distinct `app_id` values.
|
||||||
|
|
||||||
|
Call `get_memories` with:
|
||||||
|
- `user_id=<active_user_id>`
|
||||||
|
- `page_size=200`
|
||||||
|
|
||||||
|
Do NOT pass `app_id` — we want memories across ALL projects.
|
||||||
|
|
||||||
|
If the response indicates more pages, paginate until all are fetched (up to 1000
|
||||||
|
memories max to avoid excessive API calls).
|
||||||
|
|
||||||
|
### Step 2: Extract distinct projects
|
||||||
|
|
||||||
|
For each memory, read the `app_id` field (may also appear as `metadata.app_id`
|
||||||
|
on older memories). Collect distinct values.
|
||||||
|
|
||||||
|
For each project, count:
|
||||||
|
- Total memories
|
||||||
|
- Most recent `created_at` date
|
||||||
|
- Top 3 `metadata.type` values by frequency
|
||||||
|
|
||||||
|
### Step 3: Display
|
||||||
|
|
||||||
|
```
|
||||||
|
## mem0 Projects for <user_id>
|
||||||
|
|
||||||
|
| Project | Memories | Last Active | Top Categories |
|
||||||
|
|---------|----------|-------------|---------------|
|
||||||
|
| <app_id_1> | <count> | <date> | decision, convention, anti_pattern |
|
||||||
|
| <app_id_2> | <count> | <date> | task_learning, environmental |
|
||||||
|
| ... | | | |
|
||||||
|
|
||||||
|
Active project: <current_project_id> ← (current)
|
||||||
|
Total: <N> projects, <M> total memories
|
||||||
|
```
|
||||||
|
|
||||||
|
Mark the current project with `← (current)`.
|
||||||
|
|
||||||
|
### Step 4: Empty state
|
||||||
|
|
||||||
|
If zero memories found:
|
||||||
|
```
|
||||||
|
No projects found for user <user_id>.
|
||||||
|
Run /mem0:onboard in a project directory to get started.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Suggest next actions
|
||||||
|
|
||||||
|
```
|
||||||
|
Switch project: /mem0:switch-project <name>
|
||||||
|
Search across all: /mem0:tour --all-projects
|
||||||
|
```
|
||||||
@@ -49,6 +49,21 @@ filters={"AND": [
|
|||||||
|
|
||||||
Empty results are normal. Proceed without context — they don't mean the system is broken.
|
Empty results are normal. Proceed without context — they don't mean the system is broken.
|
||||||
|
|
||||||
|
### Contradiction detection at search time
|
||||||
|
|
||||||
|
After receiving search results, scan for contradictions before using them:
|
||||||
|
|
||||||
|
1. If 2+ results address the **same topic** (same `metadata.type`, overlapping file paths or entity names) but assert **opposing facts**, surface BOTH to the user instead of silently picking one.
|
||||||
|
2. Format:
|
||||||
|
```
|
||||||
|
⚠️ Conflicting memories found:
|
||||||
|
- [mem0:<id1>] "<content1>" (confidence: <score1>, <date1>)
|
||||||
|
- [mem0:<id2>] "<content2>" (confidence: <score2>, <date2>)
|
||||||
|
Which is current?
|
||||||
|
```
|
||||||
|
3. After the user resolves: update the loser via `update_memory` to mark it superseded, or delete it. Store the winner's fact as authoritative if not already.
|
||||||
|
4. If the user doesn't resolve, default to the more recent memory with higher confidence, but note the ambiguity in your response.
|
||||||
|
|
||||||
### How to search well
|
### How to search well
|
||||||
|
|
||||||
When you do search, run **2–4 parallel** `search_memories` calls at different angles instead of one query echoing the user's prompt.
|
When you do search, run **2–4 parallel** `search_memories` calls at different angles instead of one query echoing the user's prompt.
|
||||||
@@ -130,6 +145,31 @@ search_memories(query="auth",
|
|||||||
|
|
||||||
Extract key learnings and store them using the `add_memory` tool:
|
Extract key learnings and store them using the `add_memory` tool:
|
||||||
|
|
||||||
|
### REQUIRED metadata fields
|
||||||
|
|
||||||
|
Every `add_memory` call MUST include these metadata fields. Do NOT omit them:
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `type` | string | YES | Memory category: `decision`, `task_learning`, `anti_pattern`, `convention`, `user_preference`, `environmental`, `session_state`, `compact_summary` |
|
||||||
|
| `confidence` | float | YES | 0.0–1.0 confidence score. Default: `0.7` for inferred learnings, `0.9` for explicit user statements |
|
||||||
|
| `files` | list[str] | YES | File paths relevant to this memory. Use `["*"]` for project-wide learnings |
|
||||||
|
| `source` | string | YES | How the memory was captured: `user_request`, `auto_capture`, `post_commit`, `error_recovery` |
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"type": "decision",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"files": ["src/auth/login.py", "src/auth/middleware.py"],
|
||||||
|
"source": "user_request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**If you omit `confidence` or `files`, the memory will be harder to rank and retrieve later.**
|
||||||
|
|
||||||
- **Decisions made** -> Include metadata `{"type": "decision"}`
|
- **Decisions made** -> Include metadata `{"type": "decision"}`
|
||||||
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
|
- **Strategies that worked** -> Include metadata `{"type": "task_learning"}`
|
||||||
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
|
- **Failed approaches** -> Include metadata `{"type": "anti_pattern"}`
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Then retry the categories script.
|
|||||||
|
|
||||||
## Step 5: Mark project as onboarded
|
## Step 5: Mark project as onboarded
|
||||||
|
|
||||||
Create a marker file so SessionStart won't re-trigger onboarding next session:
|
The marker file is already created by `on_session_start.sh` on first display of the onboard prompt. Touch it here for idempotency:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
_SAFE_PID=$(printf '%s' "<active_project_id>" | tr '/:' '--')
|
_SAFE_PID=$(printf '%s' "<active_project_id>" | tr '/:' '--')
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
name: mem0-peek
|
|
||||||
description: >
|
|
||||||
Quick search — compact one-liner results. Faster and lighter than /mem0:tour.
|
|
||||||
Takes a search query as argument.
|
|
||||||
TRIGGER: user runs /mem0:peek <query>, or says "quick search mem0",
|
|
||||||
"what do we know about X", "peek at memories about Y".
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mem0 Peek
|
|
||||||
|
|
||||||
Quick search with compact output. Lighter than `/mem0:tour`.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
### Step 1: Parse query
|
|
||||||
|
|
||||||
The user provides a search query: `/mem0:peek auth middleware`
|
|
||||||
|
|
||||||
If no query provided, ask: "What should I search for?"
|
|
||||||
|
|
||||||
### Step 2: Search
|
|
||||||
|
|
||||||
Run 2 parallel `search_memories` calls:
|
|
||||||
|
|
||||||
1. Broad: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
|
|
||||||
2. Targeted: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"metadata": {"type": "decision"}}]}`, `limit=5`
|
|
||||||
|
|
||||||
### Step 3: Display
|
|
||||||
|
|
||||||
Deduplicate by ID, then show compact results:
|
|
||||||
|
|
||||||
```
|
|
||||||
## mem0 peek: "<query>" (<N> results)
|
|
||||||
|
|
||||||
1. [decision] Auth module uses JWT with RS256 keys (2025-05-15) [mem0:a3f8b2c1]
|
|
||||||
2. [anti_pattern] Don't use symmetric HS256 — leaked in env (2025-05-10) [mem0:7e2d9f4a]
|
|
||||||
3. [convention] All middleware in src/middleware/ (2025-05-08) [mem0:c4d5e6f7]
|
|
||||||
```
|
|
||||||
|
|
||||||
Format: `<number>. [<type>] <content, 80 chars> (<date>) [mem0:<short_id>]`
|
|
||||||
|
|
||||||
If no results:
|
|
||||||
```
|
|
||||||
No memories matching "<query>" for project <project_id>.
|
|
||||||
```
|
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
---
|
---
|
||||||
name: mem0-pin
|
name: mem0-pin
|
||||||
description: >
|
description: >
|
||||||
Pin important memories so they surface prominently. Updates metadata to mark
|
Pin important memories so they surface prominently and are protected from
|
||||||
a memory as pinned. Pinned memories should be prioritized during search.
|
consolidation. New pins use immutable: true (SDK v2.1.33+) to prevent
|
||||||
TRIGGER: user runs /mem0:pin <query or ID>, or says "pin this memory",
|
deduplication from modifying or removing the memory. Existing memories get
|
||||||
"mark as important", "always remember this".
|
metadata.pinned: true as a search-time filter; full consolidation protection
|
||||||
|
requires delete + re-add. TRIGGER: user runs /mem0:pin <query or ID>, or
|
||||||
|
says "pin this memory", "mark as important", "always remember this".
|
||||||
---
|
---
|
||||||
|
|
||||||
# Mem0 Pin
|
# Mem0 Pin
|
||||||
@@ -35,12 +37,28 @@ This is required because `update_memory` replaces the full memory — a metadata
|
|||||||
|
|
||||||
### Step 3: Pin it
|
### Step 3: Pin it
|
||||||
|
|
||||||
|
There are two sub-cases depending on whether the user wants to pin an **existing** memory or create a **new** pinned memory from scratch.
|
||||||
|
|
||||||
|
#### 3a: Pinning a new memory (preferred path for full protection)
|
||||||
|
|
||||||
|
Use `add_memory` with `immutable: true` as a top-level parameter. The `immutable` flag is a first-class platform parameter (added in SDK v2.1.33) that prevents the memory from being modified or removed by consolidation/deduplication. `metadata.pinned: true` is also included as a search-time filter aid.
|
||||||
|
|
||||||
|
```python
|
||||||
|
add_memory(
|
||||||
|
messages=[{"role": "user", "content": "<text to pin>"}],
|
||||||
|
user_id=<user_id>,
|
||||||
|
immutable=True,
|
||||||
|
metadata={"pinned": True},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3b: Pinning an existing memory
|
||||||
|
|
||||||
Call `update_memory` with:
|
Call `update_memory` with:
|
||||||
- `memory_id=<selected_id>`
|
- `memory_id=<selected_id>`
|
||||||
- `data=<original_text>` (preserve the existing content)
|
- `data=<original_text>` (preserve the existing content)
|
||||||
- `metadata=` merge `original_metadata` with `{"pinned": true}`
|
- `metadata=` merge `original_metadata` with `{"pinned": true}`
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
```python
|
||||||
updated_meta = {**original_metadata, "pinned": True}
|
updated_meta = {**original_metadata, "pinned": True}
|
||||||
update_memory(memory_id=<selected_id>, data=<original_text>, metadata=updated_meta)
|
update_memory(memory_id=<selected_id>, data=<original_text>, metadata=updated_meta)
|
||||||
@@ -48,12 +66,23 @@ update_memory(memory_id=<selected_id>, data=<original_text>, metadata=updated_me
|
|||||||
|
|
||||||
**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 and explicit metadata.
|
**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 and explicit metadata.
|
||||||
|
|
||||||
|
**Consolidation-protection limitation:** `update_memory` cannot set the `immutable` flag on an existing memory. If the user needs full consolidation protection for an existing memory, they must delete it and re-add it using `add_memory` with `immutable: true` (path 3a above). In the confirm step (Step 4), note this limitation if the user pinned an existing memory via `update_memory`.
|
||||||
|
|
||||||
### Step 4: Confirm
|
### Step 4: Confirm
|
||||||
|
|
||||||
|
For new pins created via `add_memory` with `immutable: true`:
|
||||||
|
```
|
||||||
|
Pinned: "<memory content, first 80 chars>..."
|
||||||
|
Memory ID: <id>
|
||||||
|
Pinned with consolidation protection (immutable). This memory will not be modified or removed by deduplication.
|
||||||
|
```
|
||||||
|
|
||||||
|
For existing memories pinned via `update_memory`:
|
||||||
```
|
```
|
||||||
Pinned: "<memory content, first 80 chars>..."
|
Pinned: "<memory content, first 80 chars>..."
|
||||||
Memory ID: <id>
|
Memory ID: <id>
|
||||||
Pinned memories surface first when relevant to a search.
|
Pinned memories surface first when relevant to a search.
|
||||||
|
Note: consolidation protection (immutable flag) requires delete + re-add. Say "re-pin this with full protection" to do that.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Unpin
|
### Unpin
|
||||||
|
|||||||
@@ -94,3 +94,56 @@ Print a compact dashboard with an ASCII histogram for category distribution:
|
|||||||
- **Access count buckets:** Read `metadata.access_count` (default 0 if absent). Buckets: 0, 1–5, 6–20, 20+.
|
- **Access count buckets:** Read `metadata.access_count` (default 0 if absent). Buckets: 0, 1–5, 6–20, 20+.
|
||||||
|
|
||||||
Skip any section with zero data.
|
Skip any section with zero data.
|
||||||
|
|
||||||
|
## Weekly digest mode
|
||||||
|
|
||||||
|
When invoked with `--weekly` (e.g., `/mem0:stats --weekly`), append a weekly
|
||||||
|
activity digest after the standard stats dashboard:
|
||||||
|
|
||||||
|
### W1: Fetch recent memories
|
||||||
|
|
||||||
|
Call `search_memories` in parallel with time-scoped queries:
|
||||||
|
1. `query="decisions made this week"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"created_at": {"gte": "<7 days ago YYYY-MM-DD>"}}]}`, `limit=20`
|
||||||
|
2. `query="bugs errors fixes"`, same time filter, `limit=20`
|
||||||
|
3. `query="patterns conventions learnings"`, same time filter, `limit=20`
|
||||||
|
|
||||||
|
### W2: Analyze
|
||||||
|
|
||||||
|
Merge by ID. Group into "New this week" by `categories[0]` or `metadata.type`.
|
||||||
|
Calculate: memories added last 7 days, most active categories, most active day.
|
||||||
|
|
||||||
|
### W3: Display
|
||||||
|
|
||||||
|
Append after the standard stats:
|
||||||
|
|
||||||
|
```
|
||||||
|
### Weekly Digest (<start_date> to <today>)
|
||||||
|
|
||||||
|
New Memories This Week: <N>
|
||||||
|
<category>: <count>
|
||||||
|
- <memory summary, 80 chars> (<date>)
|
||||||
|
- ...
|
||||||
|
|
||||||
|
Activity Pattern
|
||||||
|
Most active day: <day> (<N> memories)
|
||||||
|
Categories touched: <list>
|
||||||
|
|
||||||
|
Highlights
|
||||||
|
<2-3 sentence summary of most important decisions/learnings this week>
|
||||||
|
```
|
||||||
|
|
||||||
|
### W4: Write digest file
|
||||||
|
|
||||||
|
Write to `~/.mem0/weekly-digest.md` (overwrite). Append one-line to
|
||||||
|
`~/.mem0/digest-history.log`:
|
||||||
|
```
|
||||||
|
<YYYY-MM-DD> | <project_id> | +<new_count> memories | top: <top_category>
|
||||||
|
```
|
||||||
|
|
||||||
|
### W5: Empty state
|
||||||
|
|
||||||
|
If no new memories in 7 days:
|
||||||
|
```
|
||||||
|
No new memories in the past week for <project_id>.
|
||||||
|
Total project memories: <N>.
|
||||||
|
```
|
||||||
|
|||||||
@@ -11,6 +11,55 @@ description: >
|
|||||||
|
|
||||||
Show the user what mem0 has stored for the current project.
|
Show the user what mem0 has stored for the current project.
|
||||||
|
|
||||||
|
## Cross-project mode
|
||||||
|
|
||||||
|
When invoked with `--all-projects` (e.g., `/mem0:tour --all-projects` or
|
||||||
|
`/mem0:tour --all-projects auth middleware`), search across ALL projects:
|
||||||
|
|
||||||
|
1. Call `get_memories` with `user_id=<active_user_id>`, `page_size=200` — **no `app_id` filter**.
|
||||||
|
2. If a search query was also provided, run `search_memories` with `query=<query>`,
|
||||||
|
`filters={"AND": [{"user_id": "<id>"}]}`, `limit=20` — again no `app_id`.
|
||||||
|
3. Group results by `app_id` first, then by category within each project.
|
||||||
|
4. Display:
|
||||||
|
```
|
||||||
|
## Cross-Project Tour for <user_id>
|
||||||
|
|
||||||
|
### <app_id_1> (<N> memories)
|
||||||
|
#### Architecture Decisions
|
||||||
|
- <memory content>
|
||||||
|
...
|
||||||
|
|
||||||
|
### <app_id_2> (<N> memories)
|
||||||
|
...
|
||||||
|
|
||||||
|
---
|
||||||
|
Total: <N> memories across <M> projects
|
||||||
|
```
|
||||||
|
5. Mark the current project with `← (current)` in the heading.
|
||||||
|
|
||||||
|
If `--all-projects` is NOT present, use the standard single-project flow below.
|
||||||
|
|
||||||
|
## Peek mode (compact search)
|
||||||
|
|
||||||
|
When `/mem0:tour` receives a search query argument (e.g., `/mem0:tour auth middleware`)
|
||||||
|
WITHOUT `--all-projects`, run in **peek mode** — compact one-liner results:
|
||||||
|
|
||||||
|
1. Run 2 parallel `search_memories` calls:
|
||||||
|
- Broad: `query=<query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
|
||||||
|
- Targeted: `query=<query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"metadata": {"type": "decision"}}]}`, `limit=5`
|
||||||
|
2. Deduplicate by ID, display compact results:
|
||||||
|
```
|
||||||
|
## mem0 search: "<query>" (<N> results)
|
||||||
|
|
||||||
|
1. [decision] Auth module uses JWT with RS256 keys (2025-05-15) [mem0:a3f8b2c1]
|
||||||
|
2. [anti_pattern] Don't use symmetric HS256 — leaked in env (2025-05-10) [mem0:7e2d9f4a]
|
||||||
|
3. [convention] All middleware in src/middleware/ (2025-05-08) [mem0:c4d5e6f7]
|
||||||
|
```
|
||||||
|
Format: `<number>. [<type>] <content, 80 chars> (<date>) [mem0:<short_id>]`
|
||||||
|
3. If no results: `No memories matching "<query>" for project <project_id>.`
|
||||||
|
|
||||||
|
If no query argument and no `--all-projects` flag, use the full tour flow below.
|
||||||
|
|
||||||
## Execution
|
## Execution
|
||||||
|
|
||||||
### Step 1: Fetch ALL memories for this project
|
### Step 1: Fetch ALL memories for this project
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ def _scripts_on_path():
|
|||||||
sys.path.remove(abs_scripts)
|
sys.path.remove(abs_scripts)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_project_map():
|
||||||
|
"""Remove project_map.json before each test to prevent cross-test pollution."""
|
||||||
|
map_path = os.path.expanduser("~/.mem0/project_map.json")
|
||||||
|
if os.path.isfile(map_path):
|
||||||
|
os.remove(map_path)
|
||||||
|
yield
|
||||||
|
if os.path.isfile(map_path):
|
||||||
|
os.remove(map_path)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def tmp_git_repo(tmp_path):
|
def tmp_git_repo(tmp_path):
|
||||||
"""Create a temp dir with a git repo and HTTPS remote."""
|
"""Create a temp dir with a git repo and HTTPS remote."""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
|||||||
|
|
||||||
def test_split_by_headers_cursorrules():
|
def test_split_by_headers_cursorrules():
|
||||||
"""split_by_headers correctly splits .cursorrules content on ## headers."""
|
"""split_by_headers correctly splits .cursorrules content on ## headers."""
|
||||||
from import_competing_tools import split_by_headers
|
from _chunking import split_by_headers
|
||||||
|
|
||||||
content = """\
|
content = """\
|
||||||
# My Cursor Rules
|
# My Cursor Rules
|
||||||
@@ -57,7 +57,7 @@ Write tests for all utility functions.
|
|||||||
|
|
||||||
def test_split_by_headers_copilot():
|
def test_split_by_headers_copilot():
|
||||||
"""split_by_headers correctly splits copilot-instructions.md on ## headers."""
|
"""split_by_headers correctly splits copilot-instructions.md on ## headers."""
|
||||||
from import_competing_tools import split_by_headers
|
from _chunking import split_by_headers
|
||||||
|
|
||||||
content = """\
|
content = """\
|
||||||
## Code Style
|
## Code Style
|
||||||
@@ -78,7 +78,7 @@ Follow clean architecture principles. Keep business logic in domain layer.
|
|||||||
|
|
||||||
def test_split_by_headers_no_headers():
|
def test_split_by_headers_no_headers():
|
||||||
"""split_by_headers returns entire content as one chunk if no headers found."""
|
"""split_by_headers returns entire content as one chunk if no headers found."""
|
||||||
from import_competing_tools import split_by_headers
|
from _chunking import split_by_headers
|
||||||
|
|
||||||
content = "This file has no headers at all. Just plain text."
|
content = "This file has no headers at all. Just plain text."
|
||||||
chunks = split_by_headers(content, "## ")
|
chunks = split_by_headers(content, "## ")
|
||||||
@@ -88,7 +88,7 @@ def test_split_by_headers_no_headers():
|
|||||||
|
|
||||||
def test_split_cline_multiple_md_files(tmp_path):
|
def test_split_cline_multiple_md_files(tmp_path):
|
||||||
"""cmd_cline processes multiple .md files from memory-bank/ directory."""
|
"""cmd_cline processes multiple .md files from memory-bank/ directory."""
|
||||||
from import_competing_tools import filter_and_truncate
|
from _chunking import filter_and_truncate
|
||||||
|
|
||||||
# Create a temporary memory-bank directory with .md files
|
# Create a temporary memory-bank directory with .md files
|
||||||
mb_dir = tmp_path / "memory-bank"
|
mb_dir = tmp_path / "memory-bank"
|
||||||
@@ -124,7 +124,7 @@ def test_split_cline_multiple_md_files(tmp_path):
|
|||||||
|
|
||||||
def test_split_by_hr_or_headers_continue():
|
def test_split_by_hr_or_headers_continue():
|
||||||
"""split_by_hr_or_headers correctly splits .continue/rules.md."""
|
"""split_by_hr_or_headers correctly splits .continue/rules.md."""
|
||||||
from import_competing_tools import split_by_hr_or_headers
|
from _chunking import split_by_hr_or_headers
|
||||||
|
|
||||||
content = """\
|
content = """\
|
||||||
## First Section
|
## First Section
|
||||||
@@ -150,7 +150,7 @@ Third section without a header (just after HR).
|
|||||||
|
|
||||||
def test_filter_and_truncate_skips_short():
|
def test_filter_and_truncate_skips_short():
|
||||||
"""filter_and_truncate skips chunks shorter than MIN_CHUNK_CHARS (50)."""
|
"""filter_and_truncate skips chunks shorter than MIN_CHUNK_CHARS (50)."""
|
||||||
from import_competing_tools import filter_and_truncate
|
from _chunking import filter_and_truncate
|
||||||
|
|
||||||
chunks = [
|
chunks = [
|
||||||
"Short", # < 50 chars, should be filtered
|
"Short", # < 50 chars, should be filtered
|
||||||
@@ -165,7 +165,7 @@ def test_filter_and_truncate_skips_short():
|
|||||||
|
|
||||||
def test_filter_and_truncate_truncates_long():
|
def test_filter_and_truncate_truncates_long():
|
||||||
"""filter_and_truncate truncates chunks over MAX_CHUNK_CHARS (10000)."""
|
"""filter_and_truncate truncates chunks over MAX_CHUNK_CHARS (10000)."""
|
||||||
from import_competing_tools import MAX_CHUNK_CHARS, filter_and_truncate
|
from _chunking import MAX_CHUNK_CHARS, filter_and_truncate
|
||||||
|
|
||||||
long_chunk = "X" * (MAX_CHUNK_CHARS + 500)
|
long_chunk = "X" * (MAX_CHUNK_CHARS + 500)
|
||||||
result = filter_and_truncate([long_chunk])
|
result = filter_and_truncate([long_chunk])
|
||||||
|
|||||||
@@ -422,3 +422,18 @@ def test_cli_full_flag(tmp_path):
|
|||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
assert "retention" in data
|
assert "retention" in data
|
||||||
assert "search" in data
|
assert "search" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_full_config_settings_section(tmp_path):
|
||||||
|
"""Settings section is parsed by load_full_config."""
|
||||||
|
mem0_md = tmp_path / "mem0.md"
|
||||||
|
mem0_md.write_text("""\
|
||||||
|
## Settings
|
||||||
|
commit_prompts: true
|
||||||
|
subagent_skip: Explore, Plan, code-reviewer
|
||||||
|
""")
|
||||||
|
sys.path.insert(0, SCRIPTS_DIR)
|
||||||
|
from parse_mem0_config import load_full_config
|
||||||
|
config = load_full_config(str(tmp_path))
|
||||||
|
assert config.get("settings", {}).get("commit_prompts") == "true"
|
||||||
|
assert config.get("settings", {}).get("subagent_skip") == "Explore, Plan, code-reviewer"
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Tests for on_pre_commit.py — pre-commit memory capture."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
|
||||||
|
|
||||||
|
|
||||||
def test_import_succeeds():
|
|
||||||
"""on_pre_commit module can be imported."""
|
|
||||||
import on_pre_commit
|
|
||||||
|
|
||||||
assert hasattr(on_pre_commit, "main")
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_api_key_exits_zero(monkeypatch):
|
|
||||||
"""main() exits 0 when no API key is set."""
|
|
||||||
import on_pre_commit
|
|
||||||
|
|
||||||
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
|
||||||
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
|
|
||||||
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
|
|
||||||
|
|
||||||
assert on_pre_commit.main() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_diff_exits_zero(monkeypatch):
|
|
||||||
"""main() exits 0 when stdin diff is empty."""
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
import on_pre_commit
|
|
||||||
|
|
||||||
monkeypatch.setenv("MEM0_API_KEY", "test-key")
|
|
||||||
monkeypatch.setattr("sys.stdin", StringIO(""))
|
|
||||||
|
|
||||||
assert on_pre_commit.main() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_staged_summary_runs():
|
|
||||||
"""get_staged_summary doesn't crash even outside a git repo."""
|
|
||||||
import on_pre_commit
|
|
||||||
|
|
||||||
result = on_pre_commit.get_staged_summary()
|
|
||||||
assert isinstance(result, str)
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_commit_message_runs():
|
|
||||||
"""get_commit_message doesn't crash even outside a git repo."""
|
|
||||||
import on_pre_commit
|
|
||||||
|
|
||||||
result = on_pre_commit.get_commit_message()
|
|
||||||
assert isinstance(result, str)
|
|
||||||
@@ -105,3 +105,67 @@ def test_resolve_project_id_priority_order(tmp_git_repo, monkeypatch):
|
|||||||
# Env var overrides everything
|
# Env var overrides everything
|
||||||
monkeypatch.setenv("MEM0_PROJECT_ID", "from-env")
|
monkeypatch.setenv("MEM0_PROJECT_ID", "from-env")
|
||||||
assert resolve_project_id(str(tmp_git_repo)) == "from-env"
|
assert resolve_project_id(str(tmp_git_repo)) == "from-env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_hash_key_format(tmp_git_repo):
|
||||||
|
"""_remote_hash_key() returns 'remote:<16-char-hex>' for a repo with a remote."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from _project import _remote_hash_key
|
||||||
|
|
||||||
|
key = _remote_hash_key(str(tmp_git_repo))
|
||||||
|
assert re.fullmatch(r"remote:[0-9a-f]{16}", key), (
|
||||||
|
f"Expected 'remote:<16-char-hex>', got {key!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_hash_key_no_git(tmp_no_git):
|
||||||
|
"""_remote_hash_key() returns empty string when not in a git repo."""
|
||||||
|
from _project import _remote_hash_key
|
||||||
|
|
||||||
|
key = _remote_hash_key(str(tmp_no_git))
|
||||||
|
assert key == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_project_mapping_writes_remote_key(tmp_git_repo):
|
||||||
|
"""save_project_mapping() writes both the CWD key and the remote hash key."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from _project import save_project_mapping
|
||||||
|
|
||||||
|
save_project_mapping(str(tmp_git_repo), "my-project")
|
||||||
|
map_path = os.path.expanduser("~/.mem0/project_map.json")
|
||||||
|
with open(map_path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
assert data[str(tmp_git_repo)] == "my-project"
|
||||||
|
remote_keys = [k for k in data if re.fullmatch(r"remote:[0-9a-f]{16}", k)]
|
||||||
|
assert remote_keys, "Expected at least one remote:<hash> key in project_map.json"
|
||||||
|
assert data[remote_keys[0]] == "my-project"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_project_id_remote_hash_fallback(tmp_git_repo, tmp_path):
|
||||||
|
"""Moving the project folder: remote hash key is used as fallback."""
|
||||||
|
from _project import resolve_project_id, save_project_mapping
|
||||||
|
|
||||||
|
# Save mapping for original location
|
||||||
|
save_project_mapping(str(tmp_git_repo), "stable-project")
|
||||||
|
|
||||||
|
# Simulate folder move: resolve using a different CWD path that shares the same remote.
|
||||||
|
# We use a second tmp_git_repo with the same remote URL to mimic a renamed directory.
|
||||||
|
import subprocess as _sp
|
||||||
|
new_repo = tmp_path / "moved_repo"
|
||||||
|
new_repo.mkdir()
|
||||||
|
_sp.run(["git", "init"], cwd=new_repo, capture_output=True, check=True)
|
||||||
|
_sp.run(
|
||||||
|
["git", "remote", "add", "origin", "https://github.com/mem0ai/mem0.git"],
|
||||||
|
cwd=new_repo,
|
||||||
|
capture_output=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The new CWD is NOT in project_map, but remote hash should match
|
||||||
|
pid = resolve_project_id(str(new_repo))
|
||||||
|
assert pid == "stable-project", (
|
||||||
|
f"Expected 'stable-project' via remote hash fallback, got {pid!r}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ def test_posthog_payload_structure(monkeypatch):
|
|||||||
assert payload["event"] == "plugin.session_start"
|
assert payload["event"] == "plugin.session_start"
|
||||||
assert "distinct_id" in payload
|
assert "distinct_id" in payload
|
||||||
assert payload["properties"]["source"] == "plugin"
|
assert payload["properties"]["source"] == "plugin"
|
||||||
assert payload["properties"]["plugin_version"] == "0.2.1"
|
assert isinstance(payload["properties"]["plugin_version"], str)
|
||||||
|
assert payload["properties"]["plugin_version"] != ""
|
||||||
assert payload["properties"]["memory_count"] == 5
|
assert payload["properties"]["memory_count"] == 5
|
||||||
assert payload["properties"]["$process_person_profile"] is False
|
assert payload["properties"]["$process_person_profile"] is False
|
||||||
|
|
||||||
@@ -66,13 +67,40 @@ def test_posthog_payload_structure(monkeypatch):
|
|||||||
assert "test-project" not in raw
|
assert "test-project" not in raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_props_override_caller_props(monkeypatch):
|
||||||
|
"""H8: system properties must win over caller-supplied properties."""
|
||||||
|
import telemetry
|
||||||
|
|
||||||
|
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "testuser")
|
||||||
|
monkeypatch.setenv("MEM0_PROJECT_ID", "test-project")
|
||||||
|
monkeypatch.delenv("MEM0_API_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
|
||||||
|
|
||||||
|
# Caller tries to override system-controlled properties
|
||||||
|
caller_props = {
|
||||||
|
"source": "CALLER_OVERRIDE",
|
||||||
|
"platform": "CALLER_OVERRIDE",
|
||||||
|
"plugin_version": "CALLER_OVERRIDE",
|
||||||
|
"memory_count": 42,
|
||||||
|
}
|
||||||
|
payload = telemetry.build_posthog_payload("plugin.test", caller_props)
|
||||||
|
props = payload["properties"]
|
||||||
|
|
||||||
|
# System props must win
|
||||||
|
assert props["source"] == "plugin"
|
||||||
|
assert props["platform"] == telemetry.detect_platform()
|
||||||
|
assert props["plugin_version"] == telemetry.PLUGIN_VERSION
|
||||||
|
# Caller-only props still present
|
||||||
|
assert props["memory_count"] == 42
|
||||||
|
|
||||||
|
|
||||||
def test_distinct_id_from_api_key(monkeypatch):
|
def test_distinct_id_from_api_key(monkeypatch):
|
||||||
import hashlib
|
import hashlib
|
||||||
|
|
||||||
import telemetry
|
import telemetry
|
||||||
|
|
||||||
monkeypatch.setenv("MEM0_API_KEY", "m0-testkey123")
|
monkeypatch.setenv("MEM0_API_KEY", "m0-testkey123")
|
||||||
expected = hashlib.md5(b"m0-testkey123").hexdigest()
|
expected = hashlib.sha256(b"m0-testkey123").hexdigest()[:32]
|
||||||
assert telemetry._distinct_id() == expected
|
assert telemetry._distinct_id() == expected
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user