feat(mem0-plugin): onboarding, project scoping, identity banner (#5207)
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0",
|
"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.",
|
"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.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.",
|
"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.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.",
|
"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",
|
||||||
|
|||||||
@@ -2,6 +2,26 @@
|
|||||||
|
|
||||||
All notable changes to the Mem0 plugin will be documented in this file.
|
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
|
## 0.1.3
|
||||||
|
|
||||||
### Fixed
|
### 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": [
|
"Stop": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
@@ -2,30 +2,37 @@
|
|||||||
"hooks": {
|
"hooks": {
|
||||||
"sessionStart": [
|
"sessionStart": [
|
||||||
{
|
{
|
||||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_session_start.sh",
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_session_start_cursor.sh",
|
||||||
"matcher": "startup|resume|compact"
|
"matcher": "startup|resume|compact"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"preToolUse": [
|
"preToolUse": [
|
||||||
{
|
{
|
||||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write.sh",
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/block_memory_write_cursor.sh",
|
||||||
"matcher": "Write|Edit"
|
"matcher": "Write|Edit"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"postToolUse": [
|
||||||
|
{
|
||||||
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh",
|
||||||
|
"matcher": "mcp__mem0__",
|
||||||
|
"timeout": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
"preCompact": [
|
"preCompact": [
|
||||||
{
|
{
|
||||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact.sh"
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_pre_compact_cursor.sh"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"stop": [
|
"stop": [
|
||||||
{
|
{
|
||||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop.sh",
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_stop_cursor.sh",
|
||||||
"timeout": 10
|
"timeout": 10
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"beforeSubmitPrompt": [
|
"beforeSubmitPrompt": [
|
||||||
{
|
{
|
||||||
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt.sh",
|
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_user_prompt_cursor.sh",
|
||||||
"timeout": 5
|
"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": [
|
"PreCompact": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
@@ -15,3 +15,16 @@ def resolve_user_id() -> str:
|
|||||||
if explicit:
|
if explicit:
|
||||||
return explicit
|
return explicit
|
||||||
return os.environ.get("USER") or "default"
|
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)"
|
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
|
||||||
export MEM0_RESOLVED_USER_ID
|
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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
from _identity import resolve_user_id
|
from _identity import resolve_user_id
|
||||||
|
from _project import resolve_branch, resolve_project_id
|
||||||
|
|
||||||
log = logging.getLogger("mem0-compact-summary")
|
log = logging.getLogger("mem0-compact-summary")
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
@@ -94,7 +95,7 @@ def find_compact_summary(lines: list[str]) -> str:
|
|||||||
return ""
|
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()
|
expires = (date.today() + timedelta(days=COMPACT_SUMMARY_EXPIRY_DAYS)).isoformat()
|
||||||
body = {
|
body = {
|
||||||
"messages": [{"role": "user", "content": summary}],
|
"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",
|
"type": "compact_summary",
|
||||||
"source": "session-start-compact",
|
"source": "session-start-compact",
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"branch": branch,
|
||||||
},
|
},
|
||||||
"infer": False,
|
"infer": False,
|
||||||
"expiration_date": expires,
|
"expiration_date": expires,
|
||||||
@@ -149,6 +152,8 @@ def main():
|
|||||||
|
|
||||||
session_id = hook_input.get("session_id", "")
|
session_id = hook_input.get("session_id", "")
|
||||||
user_id = resolve_user_id()
|
user_id = resolve_user_id()
|
||||||
|
project_id = resolve_project_id()
|
||||||
|
branch = resolve_branch()
|
||||||
|
|
||||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||||
if not lines:
|
if not lines:
|
||||||
@@ -161,7 +166,7 @@ def main():
|
|||||||
return
|
return
|
||||||
|
|
||||||
log.info("Capturing compact summary (%d chars)", len(summary))
|
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__":
|
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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
from _identity import resolve_user_id
|
from _identity import resolve_user_id
|
||||||
|
from _project import resolve_branch, resolve_project_id
|
||||||
|
|
||||||
log = logging.getLogger("mem0-capture")
|
log = logging.getLogger("mem0-capture")
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
@@ -167,7 +168,7 @@ def build_content(state: dict, source: str) -> str:
|
|||||||
return "\n".join(parts)
|
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."""
|
"""Store session state as a memory via the Mem0 REST API."""
|
||||||
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
|
expires = (date.today() + timedelta(days=SESSION_STATE_EXPIRY_DAYS)).isoformat()
|
||||||
body = {
|
body = {
|
||||||
@@ -179,6 +180,8 @@ def store_memory(api_key: str, content: str, user_id: str, source: str, session_
|
|||||||
"type": "session_state",
|
"type": "session_state",
|
||||||
"source": source,
|
"source": source,
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"branch": branch,
|
||||||
},
|
},
|
||||||
"expiration_date": expires,
|
"expiration_date": expires,
|
||||||
}
|
}
|
||||||
@@ -230,6 +233,8 @@ def main():
|
|||||||
|
|
||||||
session_id = hook_input.get("session_id", "")
|
session_id = hook_input.get("session_id", "")
|
||||||
user_id = resolve_user_id()
|
user_id = resolve_user_id()
|
||||||
|
project_id = resolve_project_id()
|
||||||
|
branch = resolve_branch()
|
||||||
|
|
||||||
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
|
||||||
if not lines:
|
if not lines:
|
||||||
@@ -250,7 +255,7 @@ def main():
|
|||||||
len(state["bash_commands"]),
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Tool call shape:
|
|||||||
add_memory(
|
add_memory(
|
||||||
messages=[{"role":"user","content":"<the summary above>"}],
|
messages=[{"role":"user","content":"<the summary above>"}],
|
||||||
user_id="<the active user_id from the SessionStart bootstrap>",
|
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,
|
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
|
# shellcheck source=_identity.sh
|
||||||
. "$SCRIPT_DIR/_identity.sh"
|
. "$SCRIPT_DIR/_identity.sh"
|
||||||
|
|
||||||
|
# Initialize session stats tracker
|
||||||
|
python3 "$SCRIPT_DIR/session_stats.py" init 2>/dev/null || true
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
SOURCE=$(echo "$INPUT" | jq -r '.source // "startup"' 2>/dev/null || echo "startup")
|
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
|
# Identity line is emitted before every bootstrap variant so the agent
|
||||||
# uses the same user_id the hooks resolved. Without this, the agent's
|
# 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
|
# search_memories/add_memory MCP calls may bind to a different bucket
|
||||||
# than what the hooks write to.
|
# than what the hooks write to.
|
||||||
echo "## Mem0 Identity"
|
echo "## Mem0 Active"
|
||||||
echo ""
|
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 ""
|
||||||
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 ""
|
echo ""
|
||||||
|
|
||||||
if [ "$SOURCE" = "startup" ]; then
|
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.
|
IMPORTANT: Do NOT skip this step. Always bootstrap context first.
|
||||||
EOF
|
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
|
elif [ "$SOURCE" = "resume" ]; then
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
## Mem0 Session Resumed
|
## 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
|
exit 0
|
||||||
fi
|
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'
|
cat <<'EOF'
|
||||||
Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool:
|
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.
|
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.
|
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
|
EOF
|
||||||
|
|
||||||
# Capture transcript state in the background via Mem0 REST API
|
# 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"
|
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
REASON=$(cat <<'EOF'
|
# Session-end report (best-effort, must not break JSON output)
|
||||||
Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 `add_memory` tool:
|
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"}`
|
REASON=$(cat <<EOF
|
||||||
2. Were any new patterns or strategies discovered? -> Store with metadata `{"type": "task_learning"}`
|
${REPORT_BLOCK}Before finishing, check if there are important learnings from this interaction that should be persisted using the mem0 \`add_memory\` tool:
|
||||||
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"}`
|
1. Were any significant decisions made? -> Store with metadata \`{"type": "decision"}\`
|
||||||
5. Were there environment/setup discoveries? -> Store with metadata `{"type": "environmental"}`
|
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.
|
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.
|
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
|
||||||
EOF
|
EOF
|
||||||
)
|
)
|
||||||
|
|
||||||
jq -cn --arg reason "$REASON" '{decision:"block", reason:$reason}'
|
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
|
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"
|
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
. "$SCRIPT_DIR/_identity.sh" || true
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
|
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.
|
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.
|
Only store genuinely useful learnings — skip if the task was trivial.
|
||||||
|
Include \`"project_id": "$MEM0_PROJECT_ID"\` in metadata for all memories.
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
exit 0
|
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\`)
|
- Filter shape: the root must be a logical operator (\`AND\` / \`OR\` / \`NOT\`)
|
||||||
with an array, and metadata uses a **nested** object (not dotted keys).
|
with an array, and metadata uses a **nested** object (not dotted keys).
|
||||||
Combine \`user_id\` with one \`metadata.type\` clause per call:
|
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": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
|
||||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
|
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"project_id": "$MEM0_PROJECT_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": {"project_id": "$MEM0_PROJECT_ID"}}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
|
||||||
- \`{"AND": [{"user_id": "$USER_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
|
- \`{"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"}]}\` when no metadata filter fits.
|
- 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.
|
- Empty results are normal -- proceed without context.
|
||||||
EOF
|
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.
|
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?
|
### Decide: search or skip?
|
||||||
|
|
||||||
**Search WHEN** the user:
|
**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": "user_preference"}}` | tooling, stack, style — always include for code work |
|
||||||
| `{"metadata": {"type": "convention"}}` | established patterns in this project |
|
| `{"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
|
```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
|
### 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.
|
# 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
|
```python
|
||||||
search_memories(query="auth module decisions",
|
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",
|
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",
|
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",
|
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
|
## After completing significant work
|
||||||
@@ -116,7 +133,7 @@ When the user is asking about *current* state ("where were we", "what's the acti
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Last 90 days only
|
# 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.
|
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