feat(mem0-plugin): onboarding, project scoping, identity banner (#5207)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.3",
|
||||
"version": "0.2.0",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Codex workflows using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
All notable changes to the Mem0 plugin will be documented in this file.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Added
|
||||
|
||||
- **Project-scoped memories:** Deterministic `project_id` from git remote (`_project.sh` / `_project.py`). Memories are now isolated per-repo via `metadata.project_id` on every `add_memory` and `search_memories` call. Same repo cloned twice → same `project_id`.
|
||||
- **Branch-aware tagging:** `metadata.branch` stamped on session-state and compact-summary memories. Enables branch-scoped recall (e.g. "what was I doing on feature/auth-rewrite?").
|
||||
- **Auto-import of project files:** SessionStart detects CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md — hashes them (SHA-256), imports changed files as `project_profile` memories. Idempotent across sessions.
|
||||
- **Active identity banner:** SessionStart now prints `user=X | project=Y | branch=Z | memories=N` instead of a silent bootstrap.
|
||||
- **Session-end report:** Stop hook prints `Session: wrote N memories, retrieved M. Categories touched: ...` and appends to `~/.mem0/session-log.md`.
|
||||
- **`/mem0:onboard` skill:** Post-install wizard — verifies API key, detects and imports project files, installs coding categories, prints setup summary. 30 seconds to value.
|
||||
- **`/mem0:tour` skill:** Shows all memories for the current project grouped by category. Proof-of-value demo.
|
||||
- **`/mem0:switch-project` skill:** Manual `project_id` override for monorepos and non-git directories. Persists to `~/.mem0/project_map.json`.
|
||||
- **Session stats tracker** (`session_stats.py`): Tracks memory adds/searches per session for the end-of-session report.
|
||||
|
||||
### Changed
|
||||
|
||||
- All hooks and `mem0-mcp/SKILL.md` now include `project_id` in every filter and metadata example.
|
||||
- SessionStart banner replaces the previous "## Mem0 Identity" block with a compact one-liner.
|
||||
- `on_pre_compact.py` and `capture_compact_summary.py` now include `project_id` and `branch` in stored metadata.
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -24,6 +24,18 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "mcp__mem0__",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_post_tool_use.sh",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -2,30 +2,37 @@
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_session_start.sh",
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_session_start_cursor.sh",
|
||||
"matcher": "startup|resume|compact"
|
||||
}
|
||||
],
|
||||
"preToolUse": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write.sh",
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh",
|
||||
"matcher": "Write|Edit"
|
||||
}
|
||||
],
|
||||
"postToolUse": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh",
|
||||
"matcher": "mcp__mem0__",
|
||||
"timeout": 3
|
||||
}
|
||||
],
|
||||
"preCompact": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact_cursor.sh"
|
||||
}
|
||||
],
|
||||
"stop": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop.sh",
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop_cursor.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"beforeSubmitPrompt": [
|
||||
{
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
|
||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt_cursor.sh",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
|
||||
@@ -23,6 +23,18 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "mcp__mem0__",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_tool_use.sh",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"hooks": [
|
||||
|
||||
@@ -15,3 +15,16 @@ def resolve_user_id() -> str:
|
||||
if explicit:
|
||||
return explicit
|
||||
return os.environ.get("USER") or "default"
|
||||
|
||||
|
||||
try:
|
||||
from _project import resolve_branch, resolve_project_id, save_project_mapping
|
||||
except ImportError:
|
||||
def resolve_project_id() -> str:
|
||||
return os.path.basename(os.getcwd())
|
||||
|
||||
def resolve_branch() -> str:
|
||||
return "unknown"
|
||||
|
||||
def save_project_mapping(cwd: str, project_id: str) -> None:
|
||||
pass
|
||||
|
||||
@@ -14,3 +14,6 @@ _mem0_resolve_identity() {
|
||||
|
||||
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
|
||||
export MEM0_RESOLVED_USER_ID
|
||||
|
||||
# Also resolve project context
|
||||
. "$( cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd )/_project.sh"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Resolve mem0 project_id and branch.
|
||||
|
||||
Resolution priority (project_id):
|
||||
1. MEM0_PROJECT_ID env var (explicit override)
|
||||
2. ~/.mem0/project_map.json lookup by cwd
|
||||
3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
|
||||
e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
|
||||
4. Fallback: basename of cwd
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
def resolve_project_id(cwd: str | None = None) -> str:
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
|
||||
# 1. Explicit override
|
||||
explicit = os.environ.get("MEM0_PROJECT_ID", "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
# 2. project_map.json lookup
|
||||
map_path = os.path.expanduser("~/.mem0/project_map.json")
|
||||
if os.path.isfile(map_path):
|
||||
try:
|
||||
with open(map_path) as f:
|
||||
project_map = json.load(f)
|
||||
mapped = project_map.get(cwd, "").strip()
|
||||
if mapped:
|
||||
return mapped
|
||||
except (OSError, json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
# 3. Git remote slug
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
remote_url = result.stdout.strip()
|
||||
if remote_url:
|
||||
slug = _remote_url_to_slug(remote_url)
|
||||
if slug:
|
||||
return slug
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
pass
|
||||
|
||||
# 4. Fallback: basename of cwd
|
||||
return os.path.basename(cwd) or "unknown"
|
||||
|
||||
|
||||
def resolve_branch(cwd: str | None = None) -> str:
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "branch", "--show-current"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
branch = result.stdout.strip()
|
||||
return branch if branch else "unknown"
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def save_project_mapping(cwd: str, project_id: str) -> None:
|
||||
"""Write cwd -> project_id into ~/.mem0/project_map.json."""
|
||||
mem0_dir = os.path.expanduser("~/.mem0")
|
||||
os.makedirs(mem0_dir, exist_ok=True)
|
||||
map_path = os.path.join(mem0_dir, "project_map.json")
|
||||
project_map: dict[str, str] = {}
|
||||
if os.path.isfile(map_path):
|
||||
try:
|
||||
with open(map_path) as f:
|
||||
project_map = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
project_map = {}
|
||||
project_map[cwd] = project_id
|
||||
with open(map_path, "w") as f:
|
||||
json.dump(project_map, f, indent=2)
|
||||
|
||||
|
||||
def _remote_url_to_slug(url: str) -> str:
|
||||
"""Convert a git remote URL to a deterministic slug.
|
||||
|
||||
Handles:
|
||||
- HTTPS: https://github.com/owner/repo.git
|
||||
- SSH: git@github.com:owner/repo.git
|
||||
- SSH: git@github.com-alias:owner/repo.git (custom host aliases)
|
||||
- ssh://: ssh://git@github.com/owner/repo.git
|
||||
- git://: git://github.com/owner/repo.git
|
||||
"""
|
||||
slug = url.strip()
|
||||
# Strip .git suffix
|
||||
if slug.endswith(".git"):
|
||||
slug = slug[:-4]
|
||||
# Strip protocol prefixes
|
||||
for prefix in ("https://", "http://", "ssh://", "git://"):
|
||||
if slug.startswith(prefix):
|
||||
slug = slug[len(prefix):]
|
||||
break
|
||||
else:
|
||||
# Handle git@ style (no protocol prefix matched)
|
||||
slug = re.sub(r"^git@", "", slug)
|
||||
# Replace the first colon (SSH host:path separator) with /
|
||||
slug = slug.replace(":", "/", 1)
|
||||
# Split on / and take last two components (owner, repo)
|
||||
parts = [p for p in slug.split("/") if p]
|
||||
if len(parts) >= 2:
|
||||
owner, repo = parts[-2], parts[-1]
|
||||
slug = f"{owner}-{repo}"
|
||||
elif parts:
|
||||
slug = parts[-1]
|
||||
else:
|
||||
return ""
|
||||
# Replace any remaining / and : with -
|
||||
slug = slug.replace("/", "-").replace(":", "-")
|
||||
return slug
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
# Source this file. Sets MEM0_PROJECT_ID and MEM0_BRANCH.
|
||||
#
|
||||
# Resolution priority (project_id):
|
||||
# 1. MEM0_PROJECT_ID env var (explicit override)
|
||||
# 2. ~/.mem0/project_map.json lookup by $PWD (requires jq)
|
||||
# 3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
|
||||
# e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
|
||||
# 4. Fallback: basename of $PWD
|
||||
#
|
||||
# Branch resolution:
|
||||
# git branch --show-current, fallback "unknown"
|
||||
|
||||
_mem0_resolve_project_id() {
|
||||
# 1. Explicit override
|
||||
if [ -n "${MEM0_PROJECT_ID:-}" ]; then
|
||||
printf '%s' "$MEM0_PROJECT_ID"
|
||||
return
|
||||
fi
|
||||
|
||||
# 2. project_map.json lookup by $PWD
|
||||
_mem0_map="$HOME/.mem0/project_map.json"
|
||||
if [ -f "$_mem0_map" ] && command -v jq >/dev/null 2>&1; then
|
||||
_mem0_mapped=$(jq -r --arg cwd "$PWD" '.[$cwd] // empty' "$_mem0_map" 2>/dev/null)
|
||||
if [ -n "$_mem0_mapped" ]; then
|
||||
printf '%s' "$_mem0_mapped"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Git remote slug
|
||||
_mem0_remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
if [ -n "$_mem0_remote_url" ]; then
|
||||
_mem0_slug="$_mem0_remote_url"
|
||||
# Strip .git suffix
|
||||
_mem0_slug="${_mem0_slug%.git}"
|
||||
# Strip protocol prefixes
|
||||
_mem0_slug="${_mem0_slug#https://}"
|
||||
_mem0_slug="${_mem0_slug#http://}"
|
||||
_mem0_slug="${_mem0_slug#ssh://}"
|
||||
_mem0_slug="${_mem0_slug#git://}"
|
||||
_mem0_slug="${_mem0_slug#git@}"
|
||||
# Replace first colon (SSH host:path separator) with /
|
||||
# shellcheck disable=SC2039
|
||||
_mem0_slug="${_mem0_slug/://}"
|
||||
# Keep only the last two path components (owner/repo)
|
||||
_mem0_owner=$(printf '%s' "$_mem0_slug" | awk -F'/' '{print $(NF-1)}')
|
||||
_mem0_repo=$(printf '%s' "$_mem0_slug" | awk -F'/' '{print $NF}')
|
||||
_mem0_slug="${_mem0_owner}-${_mem0_repo}"
|
||||
# Replace any remaining / and : with -
|
||||
# shellcheck disable=SC2039
|
||||
_mem0_slug="${_mem0_slug//\//-}"
|
||||
# shellcheck disable=SC2039
|
||||
_mem0_slug="${_mem0_slug//:/-}"
|
||||
if [ -n "$_mem0_slug" ]; then
|
||||
printf '%s' "$_mem0_slug"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Fallback: basename of $PWD
|
||||
printf '%s' "$(basename "$PWD")"
|
||||
}
|
||||
|
||||
_mem0_resolve_branch() {
|
||||
git branch --show-current 2>/dev/null || printf 'unknown'
|
||||
}
|
||||
|
||||
MEM0_PROJECT_ID="$(_mem0_resolve_project_id)"
|
||||
MEM0_BRANCH="$(_mem0_resolve_branch)"
|
||||
export MEM0_PROJECT_ID
|
||||
export MEM0_BRANCH
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-import declarative project files into mem0.
|
||||
|
||||
Runs in the background from the SessionStart hook (startup only).
|
||||
Imports CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md
|
||||
into mem0 as project profile memories, skipping unchanged files via
|
||||
SHA-256 hashing.
|
||||
|
||||
Input: MEM0_CWD env var (optional, defaults to os.getcwd())
|
||||
Output: stderr logs only (exit 0 always — must not block)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
from _project import resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-auto-import")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(message)s"))
|
||||
log.addHandler(_handler)
|
||||
|
||||
if os.environ.get("MEM0_DEBUG"):
|
||||
_log_dir = os.path.expanduser("~/.mem0")
|
||||
try:
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
||||
_file_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(asctime)s %(message)s"))
|
||||
log.addHandler(_file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MAX_FILE_SIZE = 100_000 # skip files over 100 KB
|
||||
TARGET_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", "mem0.md"]
|
||||
HASH_STORE = os.path.expanduser("~/.mem0/file_hashes.json")
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
"""Return the hex SHA-256 digest of a file."""
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def load_hashes() -> dict[str, str]:
|
||||
"""Load the hash store from disk; return empty dict on any error."""
|
||||
if not os.path.isfile(HASH_STORE):
|
||||
return {}
|
||||
try:
|
||||
with open(HASH_STORE) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_hashes(hashes: dict[str, str]) -> None:
|
||||
"""Persist the hash store to disk."""
|
||||
mem0_dir = os.path.expanduser("~/.mem0")
|
||||
os.makedirs(mem0_dir, exist_ok=True)
|
||||
try:
|
||||
with open(HASH_STORE, "w") as f:
|
||||
json.dump(hashes, f, indent=2)
|
||||
except OSError as e:
|
||||
log.warning("Could not save hash store: %s", e)
|
||||
|
||||
|
||||
def post_memory(api_key: str, content: str, user_id: str, filename: str, project_id: str) -> bool:
|
||||
"""POST a project profile memory to the Mem0 REST API."""
|
||||
body = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"## Project Profile: {filename}\n\nProject: {project_id}\n\n{content}",
|
||||
}
|
||||
],
|
||||
"user_id": user_id,
|
||||
"metadata": {
|
||||
"type": "project_profile",
|
||||
"file": filename,
|
||||
"project_id": project_id,
|
||||
"source": "auto-import",
|
||||
},
|
||||
"infer": False,
|
||||
}
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v1/memories/",
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Token {api_key}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
if resp.status in (200, 201):
|
||||
log.info("Imported %s (project=%s)", filename, project_id)
|
||||
return True
|
||||
log.warning("API returned status %d for %s", resp.status, filename)
|
||||
return False
|
||||
except urllib.error.URLError as e:
|
||||
log.warning("API call failed for %s: %s", filename, e)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
if not api_key:
|
||||
log.debug("MEM0_API_KEY not set, skipping auto-import")
|
||||
return
|
||||
|
||||
cwd = os.environ.get("MEM0_CWD", "").strip() or os.getcwd()
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id(cwd)
|
||||
|
||||
log.debug("Auto-import started: cwd=%s project=%s user=%s", cwd, project_id, user_id)
|
||||
|
||||
hashes = load_hashes()
|
||||
updated = False
|
||||
|
||||
for filename in TARGET_FILES:
|
||||
filepath = os.path.join(cwd, filename)
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
log.debug("Not found, skipping: %s", filename)
|
||||
continue
|
||||
|
||||
try:
|
||||
file_size = os.path.getsize(filepath)
|
||||
except OSError:
|
||||
log.debug("Cannot stat %s, skipping", filename)
|
||||
continue
|
||||
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
log.debug("Skipping %s: size %d exceeds %d bytes", filename, file_size, MAX_FILE_SIZE)
|
||||
continue
|
||||
|
||||
try:
|
||||
current_hash = sha256_file(filepath)
|
||||
except OSError as e:
|
||||
log.debug("Cannot hash %s: %s", filename, e)
|
||||
continue
|
||||
|
||||
hash_key = f"{project_id}:{filename}"
|
||||
if hashes.get(hash_key) == current_hash:
|
||||
log.debug("Unchanged, skipping: %s", filename)
|
||||
continue
|
||||
|
||||
try:
|
||||
with open(filepath, encoding="utf-8", errors="replace") as f:
|
||||
content = f.read()
|
||||
except OSError as e:
|
||||
log.debug("Cannot read %s: %s", filename, e)
|
||||
continue
|
||||
|
||||
if post_memory(api_key, content, user_id, filename, project_id):
|
||||
hashes[hash_key] = current_hash
|
||||
updated = True
|
||||
# on API failure we don't update the hash — retry next session
|
||||
|
||||
if updated:
|
||||
save_hashes(hashes)
|
||||
else:
|
||||
log.debug("No files imported this run")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e:
|
||||
log.error("Unexpected error: %s", e)
|
||||
sys.exit(0)
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: preToolUse (Cursor) — blocks writes to MEMORY.md
|
||||
#
|
||||
# Cursor variant of block_memory_write.sh. Returns JSON:
|
||||
# {"permission":"deny","agent_message":"..."} on block,
|
||||
# {"permission":"allow"} on pass.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // ""' 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
jq -cn '{permission:"allow"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$FILE_PATH" in
|
||||
*/MEMORY.md|*/.claude/memory/*|*/.cursor/memory/*)
|
||||
jq -cn --arg msg "Do not write to $FILE_PATH. Use the mem0 MCP add_memory tool instead to persist memories." \
|
||||
'{permission:"deny", agent_message:$msg}'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
jq -cn '{permission:"allow"}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
@@ -26,6 +26,7 @@ from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-compact-summary")
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -94,7 +95,7 @@ def find_compact_summary(lines: list[str]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str) -> bool:
|
||||
def store_summary(api_key: str, summary: str, user_id: str, session_id: str, project_id: str = "", branch: str = "") -> bool:
|
||||
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": summary}],
|
||||
@@ -103,6 +104,8 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str) ->
|
||||
"type": "compact_summary",
|
||||
"source": "session-start-compact",
|
||||
"session_id": session_id,
|
||||
"project_id": project_id,
|
||||
"branch": branch,
|
||||
},
|
||||
"infer": False,
|
||||
"expiration_date": expires,
|
||||
@@ -149,6 +152,8 @@ def main():
|
||||
|
||||
session_id = hook_input.get("session_id", "")
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id()
|
||||
branch = resolve_branch()
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
@@ -161,7 +166,7 @@ def main():
|
||||
return
|
||||
|
||||
log.info("Capturing compact summary (%d chars)", len(summary))
|
||||
store_summary(api_key, summary, user_id, session_id)
|
||||
store_summary(api_key, summary, user_id, session_id, project_id, branch)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PostToolUse — track mem0 MCP tool usage for session stats
|
||||
#
|
||||
# Fires after any tool call. We only care about mem0 MCP tools:
|
||||
# mcp__mem0__add_memory → record an add
|
||||
# mcp__mem0__search_memories → record a search
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input, tool_result
|
||||
# Output: none (exit 0, non-blocking)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
|
||||
|
||||
case "$TOOL_NAME" in
|
||||
mcp__mem0__add_memory)
|
||||
CATEGORY=$(echo "$INPUT" | jq -r '.tool_input.metadata.type // .tool_input.metadata.category // ""' 2>/dev/null || echo "")
|
||||
python3 "$SCRIPT_DIR/session_stats.py" add "$CATEGORY" 2>/dev/null || true
|
||||
;;
|
||||
mcp__mem0__search_memories|mcp__mem0__get_memories)
|
||||
python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: postToolUse (Cursor) — track mem0 MCP tool usage for session stats
|
||||
#
|
||||
# Wraps on_post_tool_use.sh. Cursor expects JSON output but PostToolUse
|
||||
# has no meaningful return value, so we output {} after tracking.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Run the shared tracker (output is ignored)
|
||||
"$SCRIPT_DIR/on_post_tool_use.sh" 2>/dev/null || true
|
||||
|
||||
echo '{}'
|
||||
exit 0
|
||||
@@ -24,6 +24,7 @@ from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _identity import resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
log = logging.getLogger("mem0-capture")
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -167,7 +168,7 @@ def build_content(state: dict, source: str) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "") -> bool:
|
||||
def store_memory(api_key: str, content: str, user_id: str, source: str, session_id: str = "", project_id: str = "", branch: str = "") -> bool:
|
||||
"""Store session state as a memory via the Mem0 REST API."""
|
||||
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
|
||||
body = {
|
||||
@@ -179,6 +180,8 @@ def store_memory(api_key: str, content: str, user_id: str, source: str, session_
|
||||
"type": "session_state",
|
||||
"source": source,
|
||||
"session_id": session_id,
|
||||
"project_id": project_id,
|
||||
"branch": branch,
|
||||
},
|
||||
"expiration_date": expires,
|
||||
}
|
||||
@@ -230,6 +233,8 @@ def main():
|
||||
|
||||
session_id = hook_input.get("session_id", "")
|
||||
user_id = resolve_user_id()
|
||||
project_id = resolve_project_id()
|
||||
branch = resolve_branch()
|
||||
|
||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||
if not lines:
|
||||
@@ -250,7 +255,7 @@ def main():
|
||||
len(state["bash_commands"]),
|
||||
)
|
||||
|
||||
store_memory(api_key, content, user_id, source, session_id)
|
||||
store_memory(api_key, content, user_id, source, session_id, project_id, branch)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -55,7 +55,7 @@ Tool call shape:
|
||||
add_memory(
|
||||
messages=[{"role":"user","content":"<the summary above>"}],
|
||||
user_id="<the active user_id from the SessionStart bootstrap>",
|
||||
metadata={"type":"session_state","source":"pre-compaction"},
|
||||
metadata={"type":"session_state","source":"pre-compaction","project_id":"<the active project_id>","branch":"<the active branch>"},
|
||||
infer=False,
|
||||
)
|
||||
```
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: preCompact (Cursor)
|
||||
#
|
||||
# Wraps on_pre_compact.sh and converts plain-text output to Cursor's
|
||||
# expected JSON format: {"user_message":"<text>"}
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
TEXT=$("$SCRIPT_DIR/on_pre_compact.sh" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TEXT" ]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -cn --arg msg "$TEXT" '{user_message:$msg}'
|
||||
exit 0
|
||||
@@ -25,18 +25,57 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=_identity.sh
|
||||
. "$SCRIPT_DIR/_identity.sh"
|
||||
|
||||
# Initialize session stats tracker
|
||||
python3 "$SCRIPT_DIR/session_stats.py" init 2>/dev/null || true
|
||||
|
||||
INPUT=$(cat)
|
||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
||||
|
||||
# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout)
|
||||
MEM0_COUNT="?"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
MEM0_COUNT=$(python3 -c "
|
||||
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')
|
||||
project_id = os.environ.get('MEM0_PROJECT_ID', '')
|
||||
body = json.dumps({
|
||||
'query': 'project context',
|
||||
'user_id': user_id,
|
||||
'filters': {'AND': [{'user_id': user_id}, {'metadata': {'project_id': project_id}}]},
|
||||
'limit': 100,
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
'https://api.mem0.ai/v2/memories/search/',
|
||||
data=body,
|
||||
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
results = json.loads(r.read())
|
||||
if isinstance(results, list):
|
||||
n = len(results)
|
||||
print(f'{n}+' if n >= 100 else str(n))
|
||||
else:
|
||||
print('?')
|
||||
except Exception:
|
||||
print('?')
|
||||
" 2>/dev/null || echo "?")
|
||||
fi
|
||||
|
||||
# Identity line is emitted before every bootstrap variant so the agent
|
||||
# uses the same user_id the hooks resolved. Without this, the agent's
|
||||
# search_memories/add_memory MCP calls may bind to a different bucket
|
||||
# than what the hooks write to.
|
||||
echo "## Mem0 Identity"
|
||||
echo "## Mem0 Active"
|
||||
echo ""
|
||||
echo "Active user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||
echo "\`user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT\`"
|
||||
echo ""
|
||||
echo "Always include \`{\"user_id\": \"$MEM0_RESOLVED_USER_ID\"}\` (wrapped in an \`AND\` clause) in every \`search_memories\` filter and as \`user_id\` on every \`add_memory\` call. This keeps the agent's MCP calls aligned with the bucket the hooks write to."
|
||||
echo "Always include \`user_id\` + \`metadata.project_id\` in every \`search_memories\` filter and \`add_memory\` call:"
|
||||
echo "- user_id: \`$MEM0_RESOLVED_USER_ID\`"
|
||||
echo "- project_id: \`$MEM0_PROJECT_ID\`"
|
||||
echo "- branch: \`$MEM0_BRANCH\` (include in session-state / compact-summary metadata)"
|
||||
echo ""
|
||||
|
||||
if [ "$SOURCE" = "startup" ]; then
|
||||
@@ -52,6 +91,10 @@ You have access to persistent memory via the mem0 MCP tools. Before doing anythi
|
||||
IMPORTANT: Do NOT skip this step. Always bootstrap context first.
|
||||
EOF
|
||||
|
||||
# Auto-import declarative project files in background
|
||||
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
|
||||
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
|
||||
|
||||
elif [ "$SOURCE" = "resume" ]; then
|
||||
cat <<'EOF'
|
||||
## Mem0 Session Resumed
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: sessionStart (Cursor)
|
||||
#
|
||||
# Wraps on_session_start.sh and converts plain-text output to Cursor's
|
||||
# expected JSON format: {"additional_context":"<text>"}
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
TEXT=$("$SCRIPT_DIR/on_session_start.sh" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TEXT" ]; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -cn --arg ctx "$TEXT" '{additional_context:$ctx}'
|
||||
exit 0
|
||||
@@ -25,6 +25,22 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Print session-end report
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
if [ -n "$REPORT" ]; then
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "**mem0 $REPORT**"
|
||||
echo "---"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Append to persistent session log (guarded — on_stop.sh uses set -euo pipefail)
|
||||
if [ -n "$REPORT" ]; then
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cat <<'EOF'
|
||||
Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool:
|
||||
|
||||
@@ -37,6 +53,8 @@ Before finishing, check if there are important learnings from this interaction t
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||
|
||||
Always include `"project_id"` in the metadata of any memory you store.
|
||||
EOF
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
|
||||
@@ -21,6 +21,8 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
INPUT=$(cat)
|
||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
||||
|
||||
@@ -29,20 +31,35 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REASON=$(cat <<'EOF'
|
||||
Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool:
|
||||
# Session-end report (best-effort, must not break JSON output)
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
REPORT_BLOCK=""
|
||||
if [ -n "$REPORT" ]; then
|
||||
REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n"
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata `{"type": "decision"}`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata `{"type": "task_learning"}`
|
||||
3. Did any approach fail? -> Store with metadata `{"type": "anti_pattern"}`
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata `{"type": "user_preference"}`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata `{"type": "environmental"}`
|
||||
REASON=$(cat <<EOF
|
||||
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\`
|
||||
3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\`
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\`
|
||||
|
||||
Memories can be as detailed as needed — include full context, reasoning, code snippets, file paths, and examples. Longer, searchable memories are more valuable than vague one-liners.
|
||||
|
||||
Always include \`"project_id"\` in the metadata of any memory you store.
|
||||
|
||||
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||
EOF
|
||||
)
|
||||
|
||||
jq -cn --arg reason "$REASON" '{decision:"block", reason:$reason}'
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: Stop (Cursor)
|
||||
#
|
||||
# Fires when Cursor agent completes a turn. Wraps the same logic as
|
||||
# on_stop.sh but outputs JSON (Cursor expects {"followup_message":"..."}).
|
||||
#
|
||||
# Input: JSON on stdin with status, loop_count, conversation_id, etc.
|
||||
# Output: JSON on stdout: {"followup_message":"<reminder text>"}
|
||||
|
||||
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)"
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
# Session-end report (best-effort)
|
||||
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
|
||||
REPORT_BLOCK=""
|
||||
if [ -n "$REPORT" ]; then
|
||||
REPORT_BLOCK="---\nmem0 $REPORT\n---\n\n"
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $REPORT" >> "$HOME/.mem0/session-log.md" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
MESSAGE=$(cat <<EOF
|
||||
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
|
||||
|
||||
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
|
||||
2. Were any new patterns or strategies discovered? -> Store with metadata \`{"type": "task_learning"}\`
|
||||
3. Did any approach fail? -> Store with metadata \`{"type": "anti_pattern"}\`
|
||||
4. Did you learn anything about the user's preferences? -> Store with metadata \`{"type": "user_preference"}\`
|
||||
5. Were there environment/setup discoveries? -> Store with metadata \`{"type": "environmental"}\`
|
||||
|
||||
Always include \`"project_id"\` in the metadata of any memory you store.
|
||||
|
||||
If nothing notable happened, it's fine to skip. Only store genuinely useful learnings.
|
||||
EOF
|
||||
)
|
||||
|
||||
jq -cn --arg msg "$MESSAGE" '{followup_message:$msg}'
|
||||
|
||||
# Capture transcript state in the background via Mem0 REST API
|
||||
echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=session-end 2>/dev/null &
|
||||
|
||||
exit 0
|
||||
@@ -13,6 +13,9 @@ 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
|
||||
|
||||
INPUT=$(cat)
|
||||
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
||||
|
||||
@@ -28,6 +31,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 \`"project_id": "$MEM0_PROJECT_ID"\` in metadata for all memories.
|
||||
EOF
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -61,11 +61,11 @@ improve your answer. The agent -- not this hook -- owns this decision.
|
||||
- Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`)
|
||||
with an array, and metadata uses a **nested** object (not dotted keys).
|
||||
Combine \`user_id\` with one \`metadata.type\` clause per call:
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
||||
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}]}\` when no metadata filter fits.
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
||||
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_ID"}}]}\` when no metadata filter fits.
|
||||
- Empty results are normal -- proceed without context.
|
||||
EOF
|
||||
|
||||
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: beforeSubmitPrompt (Cursor)
|
||||
#
|
||||
# Wraps on_user_prompt.sh and converts plain-text output to Cursor's
|
||||
# expected JSON format: {"continue":true,"user_message":"<text>"}
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
TEXT=$("$SCRIPT_DIR/on_user_prompt.sh" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$TEXT" ]; then
|
||||
jq -cn '{continue:true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
jq -cn --arg msg "$TEXT" '{continue:true, user_message:$msg}'
|
||||
exit 0
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Session stats tracker for mem0 plugin.
|
||||
|
||||
Tracks memory adds/searches per session.
|
||||
Uses /tmp/mem0_session_stats_$USER.json (single file per user, reset on init).
|
||||
|
||||
Usage:
|
||||
python session_stats.py init # reset for new session
|
||||
python session_stats.py add <category> # record a memory write
|
||||
python session_stats.py search # record a search
|
||||
python session_stats.py report # print summary, clean up temp file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
STATS_FILE = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
if os.path.isfile(STATS_FILE):
|
||||
try:
|
||||
with open(STATS_FILE) as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()}
|
||||
|
||||
|
||||
def _save(stats: dict) -> None:
|
||||
with open(STATS_FILE, "w") as f:
|
||||
json.dump(stats, f)
|
||||
|
||||
|
||||
def init() -> None:
|
||||
_save({"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()})
|
||||
|
||||
|
||||
def record_add(category: str = "") -> None:
|
||||
stats = _load()
|
||||
stats["adds"] = stats.get("adds", 0) + 1
|
||||
if category and category not in stats.get("categories", []):
|
||||
stats.setdefault("categories", []).append(category)
|
||||
_save(stats)
|
||||
|
||||
|
||||
def record_search() -> None:
|
||||
stats = _load()
|
||||
stats["searches"] = stats.get("searches", 0) + 1
|
||||
_save(stats)
|
||||
|
||||
|
||||
def report() -> str:
|
||||
stats = _load()
|
||||
adds = stats.get("adds", 0)
|
||||
searches = stats.get("searches", 0)
|
||||
categories = stats.get("categories", [])
|
||||
|
||||
# Clean up temp file after reading
|
||||
try:
|
||||
os.unlink(STATS_FILE)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if adds == 0 and searches == 0:
|
||||
return ""
|
||||
|
||||
parts = []
|
||||
parts.append(f"Session: wrote {adds} memories, retrieved {searches}")
|
||||
if categories:
|
||||
parts.append(f"Categories touched: {', '.join(categories)}")
|
||||
|
||||
return ". ".join(parts) + "."
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: session_stats.py [init|add|search|report]", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "init":
|
||||
init()
|
||||
elif cmd == "add":
|
||||
category = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||
record_add(category)
|
||||
elif cmd == "search":
|
||||
record_search()
|
||||
elif cmd == "report":
|
||||
result = report()
|
||||
if result:
|
||||
print(result)
|
||||
else:
|
||||
print("Session: no memory operations.")
|
||||
else:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -16,6 +16,23 @@ You have access to persistent memory via the mem0 MCP tools. Follow this protoco
|
||||
|
||||
Decide whether persistent memory context would improve your response, then act accordingly. Don't search by default — search deliberately.
|
||||
|
||||
## Project scoping
|
||||
|
||||
Every memory operation MUST be scoped to the current project:
|
||||
|
||||
- **On `add_memory`:** Always include `metadata.project_id` (the active project_id from SessionStart).
|
||||
- **On `search_memories`:** Always include `{"metadata": {"project_id": "<your_project_id>"}}` in the AND filter.
|
||||
- **Session-state memories:** Also include `metadata.branch` (the active branch from SessionStart).
|
||||
|
||||
Full filter template:
|
||||
```python
|
||||
filters={"AND": [
|
||||
{"user_id": "<your_user_id>"},
|
||||
{"metadata": {"project_id": "<your_project_id>"}},
|
||||
{"metadata": {"type": "decision"}}
|
||||
]}
|
||||
```
|
||||
|
||||
### Decide: search or skip?
|
||||
|
||||
**Search WHEN** the user:
|
||||
@@ -59,9 +76,9 @@ Combine `user_id` with one metadata clause per call:
|
||||
| `{"metadata": {"type": "user_preference"}}` | tooling, stack, style — always include for code work |
|
||||
| `{"metadata": {"type": "convention"}}` | established patterns in this project |
|
||||
|
||||
Full filter (replace `<your_user_id>` with the active user_id from your runtime):
|
||||
Full filter (replace `<your_user_id>` and `<your_project_id>` with the active values from SessionStart):
|
||||
```python
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]}
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "decision"}}]}
|
||||
```
|
||||
|
||||
### Worked example
|
||||
@@ -74,16 +91,16 @@ search_memories(query="Refactor the auth module to use JWT")
|
||||
# Hits whatever shares words. Misses prior decisions and preferences.
|
||||
```
|
||||
|
||||
Do (parallel — substitute the active `user_id` for `<your_user_id>`):
|
||||
Do (parallel — substitute the active `user_id` and `project_id` for the placeholders):
|
||||
```python
|
||||
search_memories(query="auth module decisions",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "decision"}}]})
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "decision"}}]})
|
||||
search_memories(query="JWT",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}]})
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}]})
|
||||
search_memories(query="auth refactor failures",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "anti_pattern"}}]})
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "anti_pattern"}}]})
|
||||
search_memories(query="auth",
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"type": "user_preference"}}]})
|
||||
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "user_preference"}}]})
|
||||
```
|
||||
|
||||
## After completing significant work
|
||||
@@ -116,7 +133,7 @@ When the user is asking about *current* state ("where were we", "what's the acti
|
||||
|
||||
```python
|
||||
# Last 90 days only
|
||||
{"AND": [{"user_id": "<id>"}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
|
||||
{"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
|
||||
```
|
||||
|
||||
Skip the recency filter when the user is asking about durable facts ("what conventions does this project use", "have we hit this bug before") — those are timeless and recency would hide them.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: mem0-onboard
|
||||
description: >
|
||||
Post-install onboarding wizard for the mem0 plugin.
|
||||
Detects CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md
|
||||
and offers to import them. Installs coding categories. Shows active identity.
|
||||
TRIGGER: user runs /mem0:onboard, or mentions "setup mem0", "configure mem0 plugin".
|
||||
---
|
||||
|
||||
# Mem0 Onboarding Wizard
|
||||
|
||||
Run this wizard to set up the mem0 plugin for the current project. Complete in ~30 seconds.
|
||||
|
||||
## Step 1: Verify API key
|
||||
|
||||
Check if `MEM0_API_KEY` is set in the current environment:
|
||||
|
||||
```bash
|
||||
echo "${MEM0_API_KEY:+SET}" || echo "NOT_SET"
|
||||
```
|
||||
|
||||
- If **NOT set**:
|
||||
1. Ask the user: "No MEM0_API_KEY found. Do you have one, or need to create one?"
|
||||
2. If they need one, provide two options:
|
||||
- **Browser**: Go to https://app.mem0.ai/dashboard/api-keys and copy the key
|
||||
- **CLI**: Run `pip install mem0-cli && mem0 init --agent --json` to mint a key without email
|
||||
3. Once they have the key, tell them to run: `export MEM0_API_KEY="m0-..."` in their terminal, then restart this Claude Code session (the env var must be set before Claude Code starts).
|
||||
4. **STOP here.** Do not proceed until the key is confirmed set.
|
||||
- If **SET**: Proceed to Step 2.
|
||||
|
||||
## Step 2: Show identity
|
||||
|
||||
Report the active identity to the user:
|
||||
- Call `search_memories` with `query="project setup"`, `user_id=<active_user_id>`, `filters={"AND": [{"user_id": "<active_user_id>"}, {"metadata": {"project_id": "<active_project_id>"}}]}`, `limit=1` to verify connectivity.
|
||||
- Print: `Connected. user=<user_id>, project=<project_id>, branch=<branch>`
|
||||
- If the search fails, troubleshoot the API key.
|
||||
|
||||
## Step 3: Detect and import project files
|
||||
|
||||
Check for these files in the project root:
|
||||
1. `CLAUDE.md`
|
||||
2. `AGENTS.md`
|
||||
3. `.cursorrules`
|
||||
4. `.windsurfrules`
|
||||
5. `mem0.md`
|
||||
|
||||
For each file found, ask the user: "Found `<filename>` (<size> bytes). Import into mem0? [Y/n]"
|
||||
|
||||
If user says yes (or default):
|
||||
- Read the file content
|
||||
- Call `add_memory` with:
|
||||
- `messages=[{"role": "user", "content": "## Project Profile: <filename>\n\nProject: <project_id>\n\n<file_content>"}]`
|
||||
- `user_id=<active_user_id>`
|
||||
- `metadata={"type": "project_profile", "file": "<filename>", "project_id": "<active_project_id>", "source": "onboard"}`
|
||||
- `infer=False`
|
||||
|
||||
## Step 4: Install coding categories
|
||||
|
||||
Ask: "Install coding categories optimized for development workflows? [Y/n]"
|
||||
|
||||
If yes, run the script directly (no external dependencies required — uses stdlib only).
|
||||
|
||||
The script lives at `scripts/setup_coding_categories.py` relative to the plugin root. Use the appropriate plugin root variable for the current platform:
|
||||
- Claude Code: `${CLAUDE_PLUGIN_ROOT}`
|
||||
- Codex: `${CODEX_PLUGIN_ROOT}`
|
||||
- Cursor: `${CURSOR_PLUGIN_ROOT}`
|
||||
|
||||
```bash
|
||||
python3 "<PLUGIN_ROOT>/scripts/setup_coding_categories.py" --apply
|
||||
```
|
||||
|
||||
If the script reports an error, show the error message and suggest checking the API key.
|
||||
|
||||
## Step 5: Summary
|
||||
|
||||
Print a summary:
|
||||
```
|
||||
Onboarding complete.
|
||||
user_id: <user_id>
|
||||
project_id: <project_id>
|
||||
branch: <branch>
|
||||
imported: <N> files
|
||||
categories: <installed or skipped>
|
||||
|
||||
Memory is now active for this project. Start working — mem0 will
|
||||
automatically search relevant context and capture learnings.
|
||||
|
||||
Run /mem0:tour to see what mem0 already knows about this project.
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: mem0-switch-project
|
||||
description: >
|
||||
Manually override project_id for the current directory.
|
||||
Useful for monorepos, nested git dirs, or non-git directories.
|
||||
TRIGGER: user runs /mem0:switch-project <name>, or asks "switch mem0 project",
|
||||
"change project scope", "override project_id".
|
||||
---
|
||||
|
||||
# Mem0 Switch Project
|
||||
|
||||
Override the automatic project_id detection for the current directory.
|
||||
|
||||
## Usage
|
||||
|
||||
The user provides a project name as an argument: `/mem0:switch-project <project-name>`
|
||||
|
||||
## Execution
|
||||
|
||||
1. If no project name was given, ask: "What project_id should this directory use?"
|
||||
|
||||
2. Write the mapping to `~/.mem0/project_map.json` using the Bash tool:
|
||||
|
||||
```bash
|
||||
python3 -c "
|
||||
import json, os
|
||||
map_file = os.path.expanduser('~/.mem0/project_map.json')
|
||||
mapping = {}
|
||||
if os.path.isfile(map_file):
|
||||
with open(map_file) as f:
|
||||
mapping = json.load(f)
|
||||
mapping[os.getcwd()] = '<PROJECT_NAME>'
|
||||
os.makedirs(os.path.dirname(map_file), exist_ok=True)
|
||||
with open(map_file, 'w') as f:
|
||||
json.dump(mapping, f, indent=2)
|
||||
print(f'Mapped {os.getcwd()} -> <PROJECT_NAME>')
|
||||
"
|
||||
```
|
||||
|
||||
(Replace `<PROJECT_NAME>` with the user's chosen project name.)
|
||||
|
||||
3. Verify by searching for existing memories:
|
||||
- Call `search_memories` with `query="project"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<PROJECT_NAME>"}}]}`, `limit=1`
|
||||
|
||||
4. Print:
|
||||
```
|
||||
Switched to project <PROJECT_NAME>.
|
||||
<N> memories found for this project.
|
||||
Note: This override persists across sessions for this directory.
|
||||
```
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: mem0-tour
|
||||
description: >
|
||||
Show what mem0 knows about the current project. Dumps top memories
|
||||
grouped by category. Power-user-friendly proof of value.
|
||||
TRIGGER: user runs /mem0:tour, or asks "what do you know about this project",
|
||||
"show me my memories", "what has mem0 stored".
|
||||
---
|
||||
|
||||
# Mem0 Project Tour
|
||||
|
||||
Show the user what mem0 has stored for the current project.
|
||||
|
||||
## Execution
|
||||
|
||||
1. Run the following `search_memories` calls in parallel (all with the active `user_id` and `metadata.project_id`):
|
||||
|
||||
- `query="architecture decisions"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "decision"}}]}`, `limit=5`
|
||||
- `query="anti patterns failures"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "anti_pattern"}}]}`, `limit=5`
|
||||
- `query="task learnings strategies"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "task_learning"}}]}`, `limit=5`
|
||||
- `query="coding conventions"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "convention"}}]}`, `limit=5`
|
||||
- `query="user preferences"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "user_preference"}}]}`, `limit=5`
|
||||
- `query="project profile"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "project_profile"}}]}`, `limit=5`
|
||||
- `query="tooling setup environment"`, `filters={"AND": [{"user_id": "<id>"}, {"metadata": {"project_id": "<pid>"}}, {"metadata": {"type": "environmental"}}]}`, `limit=5`
|
||||
|
||||
2. Group results by category. For each category with results, print:
|
||||
|
||||
```
|
||||
## <category_name> (<count> memories)
|
||||
- <memory_content_truncated_to_100_chars> (score: <similarity_score>)
|
||||
- ...
|
||||
```
|
||||
|
||||
3. For categories with zero results, print: `<category_name>: (empty)`
|
||||
|
||||
4. Print totals at the end:
|
||||
```
|
||||
---
|
||||
Total: <N> memories across <M> categories for project <project_id>
|
||||
```
|
||||
|
||||
5. If ALL categories are empty, print:
|
||||
```
|
||||
No memories stored yet for project <project_id>.
|
||||
Run /mem0:onboard to import project files, or start working — mem0 captures learnings automatically.
|
||||
```
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Shared fixtures for mem0-plugin tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scripts_on_path():
|
||||
"""Ensure scripts/ is on sys.path so we can import _project, session_stats, etc."""
|
||||
abs_scripts = os.path.abspath(SCRIPTS_DIR)
|
||||
if abs_scripts not in sys.path:
|
||||
sys.path.insert(0, abs_scripts)
|
||||
yield
|
||||
if abs_scripts in sys.path:
|
||||
sys.path.remove(abs_scripts)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_git_repo(tmp_path):
|
||||
"""Create a temp dir with a git repo and HTTPS remote."""
|
||||
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", "https://github.com/mem0ai/mem0.git"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_git_repo_ssh(tmp_path):
|
||||
"""Create a temp dir with a git repo and SSH remote."""
|
||||
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
||||
subprocess.run(
|
||||
["git", "remote", "add", "origin", "git@github.com:acme/cool-project.git"],
|
||||
cwd=tmp_path,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_no_git(tmp_path):
|
||||
"""Temp dir with no git repo."""
|
||||
return tmp_path
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests for _project.py — project_id + branch resolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_resolve_project_id_from_https_remote(tmp_git_repo):
|
||||
from _project import resolve_project_id
|
||||
|
||||
pid = resolve_project_id(str(tmp_git_repo))
|
||||
assert pid == "mem0ai-mem0"
|
||||
|
||||
|
||||
def test_resolve_project_id_from_ssh_remote(tmp_git_repo_ssh):
|
||||
from _project import resolve_project_id
|
||||
|
||||
pid = resolve_project_id(str(tmp_git_repo_ssh))
|
||||
assert pid == "acme-cool-project"
|
||||
|
||||
|
||||
def test_resolve_project_id_fallback_basename(tmp_no_git):
|
||||
from _project import resolve_project_id
|
||||
|
||||
pid = resolve_project_id(str(tmp_no_git))
|
||||
assert pid == os.path.basename(str(tmp_no_git))
|
||||
|
||||
|
||||
def test_resolve_project_id_from_env(tmp_no_git, monkeypatch):
|
||||
from _project import resolve_project_id
|
||||
|
||||
monkeypatch.setenv("MEM0_PROJECT_ID", "my-override")
|
||||
pid = resolve_project_id(str(tmp_no_git))
|
||||
assert pid == "my-override"
|
||||
|
||||
|
||||
def test_resolve_project_id_from_project_map(tmp_no_git):
|
||||
from _project import resolve_project_id, save_project_mapping
|
||||
|
||||
save_project_mapping(str(tmp_no_git), "custom-project")
|
||||
pid = resolve_project_id(str(tmp_no_git))
|
||||
assert pid == "custom-project"
|
||||
|
||||
|
||||
def test_save_project_mapping_creates_file(tmp_no_git):
|
||||
from _project import save_project_mapping
|
||||
|
||||
save_project_mapping(str(tmp_no_git), "test-proj")
|
||||
map_path = os.path.expanduser("~/.mem0/project_map.json")
|
||||
assert os.path.isfile(map_path)
|
||||
with open(map_path) as f:
|
||||
data = json.load(f)
|
||||
assert data[str(tmp_no_git)] == "test-proj"
|
||||
|
||||
|
||||
def test_resolve_branch_in_git_repo(tmp_git_repo):
|
||||
from _project import resolve_branch
|
||||
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "feat/test-branch"],
|
||||
cwd=tmp_git_repo,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
branch = resolve_branch(str(tmp_git_repo))
|
||||
assert branch == "feat/test-branch"
|
||||
|
||||
|
||||
def test_resolve_branch_no_git(tmp_no_git):
|
||||
from _project import resolve_branch
|
||||
|
||||
branch = resolve_branch(str(tmp_no_git))
|
||||
assert branch == "unknown"
|
||||
|
||||
|
||||
def test_remote_url_to_slug_various_formats():
|
||||
from _project import _remote_url_to_slug
|
||||
|
||||
assert _remote_url_to_slug("https://github.com/mem0ai/mem0.git") == "mem0ai-mem0"
|
||||
assert _remote_url_to_slug("git@github.com:mem0ai/mem0.git") == "mem0ai-mem0"
|
||||
assert _remote_url_to_slug("ssh://git@github.com/acme/app.git") == "acme-app"
|
||||
assert _remote_url_to_slug("https://gitlab.com/org/sub/repo.git") == "sub-repo"
|
||||
assert _remote_url_to_slug("git@bitbucket.org:team/project.git") == "team-project"
|
||||
|
||||
|
||||
def test_remote_url_to_slug_no_git_suffix():
|
||||
from _project import _remote_url_to_slug
|
||||
|
||||
assert _remote_url_to_slug("https://github.com/foo/bar") == "foo-bar"
|
||||
|
||||
|
||||
def test_resolve_project_id_priority_order(tmp_git_repo, monkeypatch):
|
||||
"""Env var > project_map > git remote > basename."""
|
||||
from _project import resolve_project_id, save_project_mapping
|
||||
|
||||
# Git remote gives "mem0ai-mem0"
|
||||
assert resolve_project_id(str(tmp_git_repo)) == "mem0ai-mem0"
|
||||
|
||||
# project_map overrides git remote
|
||||
save_project_mapping(str(tmp_git_repo), "from-map")
|
||||
assert resolve_project_id(str(tmp_git_repo)) == "from-map"
|
||||
|
||||
# Env var overrides everything
|
||||
monkeypatch.setenv("MEM0_PROJECT_ID", "from-env")
|
||||
assert resolve_project_id(str(tmp_git_repo)) == "from-env"
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Tests for session_stats.py — session-level memory operation tracker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_stats_file(tmp_path, monkeypatch):
|
||||
"""Point STATS_FILE to a temp location so tests don't interfere."""
|
||||
stats_file = str(tmp_path / "test_stats.json")
|
||||
monkeypatch.setattr("session_stats.STATS_FILE", stats_file)
|
||||
yield stats_file
|
||||
|
||||
|
||||
def test_init_creates_file(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
assert os.path.isfile(_isolate_stats_file)
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["adds"] == 0
|
||||
assert data["searches"] == 0
|
||||
assert data["categories"] == []
|
||||
|
||||
|
||||
def test_record_add_increments(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("decisions")
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["adds"] == 3
|
||||
assert set(data["categories"]) == {"bug_fixes", "decisions"}
|
||||
|
||||
|
||||
def test_record_search_increments(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_search()
|
||||
session_stats.record_search()
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["searches"] == 2
|
||||
|
||||
|
||||
def test_report_returns_summary(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("architecture_decisions")
|
||||
session_stats.record_add("task_learnings")
|
||||
session_stats.record_search()
|
||||
session_stats.record_search()
|
||||
session_stats.record_search()
|
||||
|
||||
result = session_stats.report()
|
||||
assert "wrote 2 memories" in result
|
||||
assert "retrieved 3" in result
|
||||
assert "architecture_decisions" in result
|
||||
assert "task_learnings" in result
|
||||
|
||||
|
||||
def test_report_empty_session(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
result = session_stats.report()
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_report_cleans_up_file(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add()
|
||||
session_stats.report()
|
||||
assert not os.path.isfile(_isolate_stats_file)
|
||||
|
||||
|
||||
def test_record_add_no_category(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("")
|
||||
session_stats.record_add()
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["adds"] == 2
|
||||
assert data["categories"] == []
|
||||
|
||||
|
||||
def test_duplicate_categories_not_added(_isolate_stats_file):
|
||||
import session_stats
|
||||
|
||||
session_stats.init()
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("bug_fixes")
|
||||
session_stats.record_add("bug_fixes")
|
||||
|
||||
with open(_isolate_stats_file) as f:
|
||||
data = json.load(f)
|
||||
assert data["adds"] == 3
|
||||
assert data["categories"] == ["bug_fixes"]
|
||||
|
||||
|
||||
def test_cli_init(tmp_path):
|
||||
"""Test CLI invocation: session_stats.py init."""
|
||||
env = {**os.environ, "USER": "test"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "init"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
def test_cli_report_no_data(tmp_path):
|
||||
"""Test CLI invocation: report with no prior init prints fallback."""
|
||||
env = {**os.environ, "USER": f"test_{os.getpid()}"}
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "report"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
Reference in New Issue
Block a user