Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f026caab69 | ||
|
|
f5f98e2be7 | ||
|
|
a439ad18a9 | ||
|
|
629f0ab06d | ||
|
|
606ede7c0a |
+15
-61
@@ -3,21 +3,7 @@ name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'mem0/**'
|
||||
- 'tests/**'
|
||||
- 'embedchain/**'
|
||||
- '.github/workflows/**'
|
||||
- 'pyproject.toml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'mem0/**'
|
||||
- 'tests/**'
|
||||
- 'embedchain/**'
|
||||
- 'pyproject.toml'
|
||||
- 'cli/**'
|
||||
- 'docs/**'
|
||||
- '.github/workflows/**'
|
||||
|
||||
jobs:
|
||||
changelog_check:
|
||||
@@ -63,9 +49,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
mem0_changed: ${{ steps.filter.outputs.mem0 }}
|
||||
embedchain_changed: ${{ steps.filter.outputs.embedchain }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
@@ -73,25 +58,28 @@ jobs:
|
||||
mem0:
|
||||
- 'mem0/**'
|
||||
- 'tests/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'pyproject.toml'
|
||||
embedchain:
|
||||
- 'embedchain/**'
|
||||
|
||||
build_mem0:
|
||||
needs: check_changes
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Skip — no relevant changes
|
||||
if: needs.check_changes.outputs.mem0_changed != 'true'
|
||||
run: echo "No changes in mem0/, tests/, pyproject.toml, or ci.yml — skipping"
|
||||
- uses: actions/checkout@v4
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Clean up disk space
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: |
|
||||
df -h
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
@@ -99,61 +87,27 @@ jobs:
|
||||
sudo docker builder prune -a
|
||||
df -h
|
||||
- name: Install Hatch
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: pip install hatch
|
||||
- name: Load cached venv
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
id: cached-hatch-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-mem0-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
||||
- name: Install GEOS Libraries
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y libgeos-dev
|
||||
- name: Install dependencies
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true' && steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pip install --upgrade pip
|
||||
pip install -e ".[test,graph,vector_stores,llms,extras]"
|
||||
pip install ruff
|
||||
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Run Linting
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: make lint
|
||||
- name: Run tests and generate coverage report
|
||||
if: needs.check_changes.outputs.mem0_changed == 'true'
|
||||
run: make test
|
||||
|
||||
build_embedchain:
|
||||
needs: check_changes
|
||||
if: needs.check_changes.outputs.embedchain_changed == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Hatch
|
||||
run: pip install hatch
|
||||
- name: Load cached venv
|
||||
id: cached-hatch-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-embedchain-${{ runner.os }}-${{ hashFiles('**/pyproject.toml') }}
|
||||
- name: Install dependencies
|
||||
run: cd embedchain && make install_all
|
||||
if: steps.cached-hatch-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Run Formatting
|
||||
run: |
|
||||
mkdir -p embedchain/.ruff_cache && chmod -R 777 embedchain/.ruff_cache
|
||||
cd embedchain && hatch run format
|
||||
- name: Lint with ruff
|
||||
run: cd embedchain && make lint
|
||||
- name: Run tests and generate coverage report
|
||||
run: cd embedchain && make coverage
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: coverage.xml
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,9 +6,21 @@ All notable changes to the Mem0 plugin will be documented in this file.
|
||||
|
||||
### Added
|
||||
|
||||
- Holistic import: a one-shot `scripts/import_claude_state.py` script backfills existing CLAUDE.md hierarchy (+ `@imports`), `.claude/rules`, `~/.claude/projects/*/memory`, and `~/.claude/agent-memory` into mem0. Heading-based markdown chunking (~50–500 tokens per chunk), metadata tagging using the `mem0-mcp` skill vocabulary, content-hash-tracked idempotent re-runs via a marker file at `~/.mem0/imports/claude-state.json`. `infer=True` by default; `--no-infer` opt-out, `--dry-run`, `--reset`, and `--source <type>` flags supported.
|
||||
- `SessionStart` hook nudges the agent to run the importer on first session after install. The nudge stays silent once the marker file exists. Propagates to Cursor and Codex automatically (shared `on_session_start.sh` entry point).
|
||||
- Research and design context for the importer is documented in a Notion page (link in PR description).
|
||||
- **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
|
||||
|
||||
|
||||
@@ -195,36 +195,6 @@ python mem0-plugin/scripts/setup_coding_categories.py --apply
|
||||
|
||||
Requires the `mem0ai` Python SDK (`pip install mem0ai`) and `MEM0_API_KEY` set. New memories will then auto-tag against `architecture_decisions`, `anti_patterns`, `task_learnings`, `tooling_setup`, `bug_fixes`, `coding_conventions`, `user_preferences`. Re-run with a different list any time; `project.update(custom_categories=[...])` always replaces.
|
||||
|
||||
## Optional: holistic import of existing Claude state
|
||||
|
||||
If you've been using Claude Code before installing mem0, you have on-disk state (CLAUDE.md files, `~/.claude/projects/*/memory/`, `~/.claude/agent-memory/*`) that won't appear in mem0 until you backfill it. A one-shot importer ships with the plugin:
|
||||
|
||||
```bash
|
||||
# Preview what would be imported:
|
||||
python3 mem0-plugin/scripts/import_claude_state.py --dry-run
|
||||
|
||||
# Run the import:
|
||||
python3 mem0-plugin/scripts/import_claude_state.py
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
- Discovers every `CLAUDE.md` (user / project / nested), `@import` target, `.claude/rules/*.md`, `~/.claude/projects/*/memory/*.md`, and `~/.claude/agent-memory/*/*.md` on your machine.
|
||||
- Heading-chunks each file (H2 default, descending to H3 for oversize sections, merging tiny siblings). Code-fenced blocks are preserved.
|
||||
- POSTs each chunk to mem0 with metadata: `source_file`, `source_type`, `section_heading`, `project`, `subagent`, `content_hash`, and a `type` matching the `mem0-mcp` skill vocabulary (`convention`, `user_preference`, `task_learning`, `anti_pattern`, `decision`).
|
||||
- Writes `~/.mem0/imports/claude-state.json` to remember what's been uploaded. Re-running is idempotent: only new chunks are sent.
|
||||
|
||||
Flags:
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `--dry-run` | Print the import plan; don't upload. |
|
||||
| `--reset` | Wipe the marker before running (forces full re-import). |
|
||||
| `--no-infer` | Upload chunks raw (`infer=False`). Default is `infer=True` so mem0 extracts atomic facts. |
|
||||
| `--source <type>` | Restrict to one source type (e.g., `--source memory_md`). |
|
||||
|
||||
The `SessionStart` hook nudges you about this importer the first time you open a session after installing. Once you run it (or once the marker file exists), the nudge disappears.
|
||||
|
||||
## MCP Tools
|
||||
|
||||
Once installed, the following tools are available:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -1,745 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Holistic import of on-disk Claude state into mem0.
|
||||
|
||||
Backfills the user's existing CLAUDE.md hierarchy (+ @imports),
|
||||
`.claude/rules`, `~/.claude/projects/*/memory`, and `~/.claude/agent-memory`
|
||||
into mem0 as searchable memories. One-shot. Idempotent across re-runs via a
|
||||
marker file at ~/.mem0/imports/claude-state.json.
|
||||
|
||||
Patterned on on_pre_compact.py: urllib-based POSTs, stderr logging,
|
||||
_identity.resolve_user_id() for user_id, and an optional ~/.mem0/hooks.log
|
||||
when MEM0_DEBUG is set.
|
||||
|
||||
Invocation:
|
||||
python3 import_claude_state.py [--dry-run] [--reset] [--no-infer]
|
||||
[--source TYPE]
|
||||
|
||||
Exit codes:
|
||||
0 = success (including dry-run and partial failures)
|
||||
1 = fatal (missing MEM0_API_KEY, auth failure)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────────────────
|
||||
|
||||
log = logging.getLogger("mem0-import-claude-state")
|
||||
log.setLevel(logging.DEBUG)
|
||||
_handler = logging.StreamHandler(sys.stderr)
|
||||
_handler.setFormatter(logging.Formatter("[mem0-import-claude-state] %(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-import-claude-state] %(asctime)s %(message)s")
|
||||
)
|
||||
log.addHandler(_file_handler)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
REQUEST_TIMEOUT_SECS = 15
|
||||
RETRY_SLEEP_SECS = 2.0
|
||||
|
||||
MARKER_PATH = Path.home() / ".mem0" / "imports" / "claude-state.json"
|
||||
EMPTY_MARKER: dict[str, Any] = {"schema_version": 1, "imports": []}
|
||||
|
||||
MAX_CHARS_PER_CHUNK = 2000
|
||||
MIN_CHARS_PER_CHUNK = 200
|
||||
MAX_IMPORT_DEPTH = 5
|
||||
|
||||
H1_LINE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
|
||||
H2_LINE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
|
||||
H3_LINE = re.compile(r"^###\s+(.+?)\s*$", re.MULTILINE)
|
||||
FENCE_LINE = re.compile(r"^\s*(```+|~~~+)")
|
||||
IMPORT_LINE_RE = re.compile(r"(?<![A-Za-z0-9_])@([^\s,;)]+)")
|
||||
PROJECT_DIR_DECODE_RE = re.compile(r"^-Users-[^-]+-(.+)$")
|
||||
|
||||
SOURCE_TYPES = {
|
||||
"claude_md_managed",
|
||||
"claude_md_user",
|
||||
"claude_md_project",
|
||||
"claude_md_import",
|
||||
"claude_local",
|
||||
"rule",
|
||||
"memory_md",
|
||||
"memory_topic",
|
||||
"agent_memory",
|
||||
"agent_memory_topic",
|
||||
}
|
||||
|
||||
DEFAULT_TYPE_BY_SOURCE = {
|
||||
"claude_md_managed": "convention",
|
||||
"claude_md_user": "convention",
|
||||
"claude_md_project": "convention",
|
||||
"claude_md_import": "convention",
|
||||
"claude_local": "user_preference",
|
||||
"rule": "convention",
|
||||
"memory_md": "task_learning",
|
||||
"memory_topic": "task_learning",
|
||||
"agent_memory": "task_learning",
|
||||
"agent_memory_topic": "task_learning",
|
||||
}
|
||||
|
||||
# Override priority: anti_pattern > decision > user_preference > default.
|
||||
_ANTI_PATTERN_RE = re.compile(r"\b(bug|fix|debug|never|always|critical)\b", re.IGNORECASE)
|
||||
_DECISION_RE = re.compile(r"\b(decision|decided|chose|picked|chosen)\b", re.IGNORECASE)
|
||||
_PREFERENCE_RE = re.compile(r"\b(prefer|preference|style)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
# ── Data classes ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class Chunk:
|
||||
heading: str
|
||||
body: str
|
||||
content_hash: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Source:
|
||||
path: Path
|
||||
source_type: str
|
||||
project_name: str | None = None
|
||||
subagent_name: str | None = None
|
||||
|
||||
|
||||
class AuthError(RuntimeError):
|
||||
"""Raised on 401 to abort the import run immediately."""
|
||||
|
||||
|
||||
# ── Marker I/O ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def read_marker(path: Path) -> dict[str, Any]:
|
||||
"""Read the import marker, tolerating missing files and corrupted JSON."""
|
||||
if not path.exists():
|
||||
return {"schema_version": 1, "imports": []}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict) or "imports" not in data:
|
||||
log.warning("Marker at %s has unexpected shape, treating as empty", path)
|
||||
return {"schema_version": 1, "imports": []}
|
||||
return data
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
log.warning("Marker at %s is corrupted (%s), treating as empty", path, e)
|
||||
return {"schema_version": 1, "imports": []}
|
||||
|
||||
|
||||
def write_marker(path: Path, marker: dict[str, Any]) -> None:
|
||||
"""Atomically write the marker file (temp + os.replace)."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=path.name + ".",
|
||||
suffix=".tmp",
|
||||
dir=str(path.parent),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(marker, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp_name, path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(tmp_name)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# ── Chunker ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sha256(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _make_chunk(heading: str, body: str) -> Chunk:
|
||||
body = body.strip("\n")
|
||||
return Chunk(heading=heading, body=body, content_hash=_sha256(body))
|
||||
|
||||
|
||||
def _mask_fences(text: str) -> str:
|
||||
"""Replace leading `#` of heading-like lines inside fenced code blocks with
|
||||
a space so the heading regex won't split them. Length-preserving so chunk
|
||||
offsets stay aligned with the original text.
|
||||
"""
|
||||
out: list[str] = []
|
||||
open_marker: str | None = None
|
||||
for line in text.splitlines(keepends=True):
|
||||
m = FENCE_LINE.match(line)
|
||||
if m:
|
||||
marker = m.group(1)[:3]
|
||||
if open_marker is None:
|
||||
open_marker = marker
|
||||
elif open_marker == marker:
|
||||
open_marker = None
|
||||
out.append(line)
|
||||
elif open_marker is not None:
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith("#"):
|
||||
idx = len(line) - len(stripped)
|
||||
out.append(line[:idx] + " " + line[idx + 1 :])
|
||||
else:
|
||||
out.append(line)
|
||||
else:
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _split_on_pattern(text: str, pattern: re.Pattern[str]) -> list[tuple[str, str]]:
|
||||
"""Split text into (heading, body) sections at each match of `pattern`.
|
||||
|
||||
Content before the first match becomes one section with the H1 (if any) as
|
||||
its heading. Bodies preserve heading lines verbatim.
|
||||
"""
|
||||
matches = list(pattern.finditer(text))
|
||||
if not matches:
|
||||
h1 = H1_LINE.search(text)
|
||||
heading = h1.group(1).strip() if h1 else ""
|
||||
return [(heading, text)]
|
||||
|
||||
sections: list[tuple[str, str]] = []
|
||||
first = matches[0]
|
||||
preamble = text[: first.start()]
|
||||
if preamble.strip():
|
||||
h1 = H1_LINE.search(preamble)
|
||||
heading = h1.group(1).strip() if h1 else ""
|
||||
sections.append((heading, preamble))
|
||||
|
||||
for i, m in enumerate(matches):
|
||||
heading = m.group(1).strip()
|
||||
body_start = m.start()
|
||||
body_end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
sections.append((heading, text[body_start:body_end]))
|
||||
return sections
|
||||
|
||||
|
||||
def _descend_oversized(sections: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
"""For each section whose body exceeds MAX_CHARS_PER_CHUNK, re-split at H3."""
|
||||
result: list[tuple[str, str]] = []
|
||||
for heading, body in sections:
|
||||
if len(body) <= MAX_CHARS_PER_CHUNK:
|
||||
result.append((heading, body))
|
||||
continue
|
||||
h3_subs = _split_on_pattern(body, H3_LINE)
|
||||
if len(h3_subs) <= 1:
|
||||
# No H3s present — emit oversized chunk rather than break paragraphs.
|
||||
result.append((heading, body))
|
||||
else:
|
||||
result.extend(h3_subs)
|
||||
return result
|
||||
|
||||
|
||||
def _merge_small_siblings(sections: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
||||
"""Merge adjacent chunks where the *first* sibling's stripped body is under
|
||||
MIN_CHARS_PER_CHUNK and the combined size still fits in MAX_CHARS_PER_CHUNK.
|
||||
The merged chunk keeps the first sibling's heading.
|
||||
"""
|
||||
if not sections:
|
||||
return sections
|
||||
merged: list[tuple[str, str]] = []
|
||||
buf_heading, buf_body = sections[0]
|
||||
for heading, body in sections[1:]:
|
||||
if (
|
||||
len(buf_body.strip()) < MIN_CHARS_PER_CHUNK
|
||||
and len(buf_body) + len(body) <= MAX_CHARS_PER_CHUNK
|
||||
):
|
||||
buf_body = buf_body + "\n\n" + body
|
||||
else:
|
||||
merged.append((buf_heading, buf_body))
|
||||
buf_heading, buf_body = heading, body
|
||||
merged.append((buf_heading, buf_body))
|
||||
return merged
|
||||
|
||||
|
||||
def chunk_markdown(text: str) -> list[Chunk]:
|
||||
"""Split markdown into Chunks for upload to mem0.
|
||||
|
||||
1. Mask heading-like lines inside fenced code blocks.
|
||||
2. Split the masked text at H2.
|
||||
3. For sections over MAX_CHARS_PER_CHUNK, re-split at H3.
|
||||
4. Merge adjacent sections whose first body is under MIN_CHARS_PER_CHUNK.
|
||||
5. Slice bodies from the ORIGINAL text using offsets so user wording is
|
||||
preserved exactly.
|
||||
"""
|
||||
masked = _mask_fences(text)
|
||||
if len(masked) != len(text):
|
||||
log.warning("fence mask changed text length (%d -> %d); falling back to whole-file chunk", len(text), len(masked))
|
||||
return [_make_chunk("", text)]
|
||||
|
||||
sections = _split_on_pattern(masked, H2_LINE)
|
||||
sections = _descend_oversized(sections)
|
||||
sections = _merge_small_siblings(sections)
|
||||
|
||||
result: list[Chunk] = []
|
||||
cursor = 0
|
||||
for heading, masked_body in sections:
|
||||
end = cursor + len(masked_body)
|
||||
original_body = text[cursor:end]
|
||||
result.append(_make_chunk(heading, original_body))
|
||||
cursor = end
|
||||
return result
|
||||
|
||||
|
||||
# ── Tagger ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _override_type(default: str, search_text: str) -> str:
|
||||
if _ANTI_PATTERN_RE.search(search_text):
|
||||
return "anti_pattern"
|
||||
if _DECISION_RE.search(search_text):
|
||||
return "decision"
|
||||
if _PREFERENCE_RE.search(search_text):
|
||||
return "user_preference"
|
||||
return default
|
||||
|
||||
|
||||
def tag_chunk(source: Source, chunk: Chunk) -> dict[str, Any]:
|
||||
"""Build the metadata dict attached to the chunk's add_memory POST."""
|
||||
default = DEFAULT_TYPE_BY_SOURCE.get(source.source_type, "task_learning")
|
||||
type_value = _override_type(default, chunk.heading + "\n" + chunk.body)
|
||||
return {
|
||||
"source_file": str(source.path),
|
||||
"source_type": source.source_type,
|
||||
"section_heading": chunk.heading,
|
||||
"project": source.project_name,
|
||||
"subagent": source.subagent_name,
|
||||
"content_hash": chunk.content_hash,
|
||||
"type": type_value,
|
||||
}
|
||||
|
||||
|
||||
# ── @import resolver ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_import_paths(text: str, base: Path) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
for m in IMPORT_LINE_RE.finditer(text):
|
||||
raw = m.group(1)
|
||||
if raw.startswith("~"):
|
||||
resolved = Path(os.path.expanduser(raw))
|
||||
elif raw.startswith("/"):
|
||||
resolved = Path(raw)
|
||||
else:
|
||||
resolved = (base.parent / raw).resolve()
|
||||
paths.append(resolved)
|
||||
return paths
|
||||
|
||||
|
||||
def resolve_imports(
|
||||
path: Path,
|
||||
*,
|
||||
depth: int = 0,
|
||||
visited: set[Path] | None = None,
|
||||
) -> list[Path]:
|
||||
"""Return the transitive @-import closure starting at `path`.
|
||||
|
||||
The starting `path` is NOT included. Cycles, missing targets, and depth
|
||||
overflows are logged and skipped (matches Claude Code's loader).
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if depth >= MAX_IMPORT_DEPTH:
|
||||
log.warning("import depth limit reached at %s (max=%d)", path, MAX_IMPORT_DEPTH)
|
||||
return []
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
out: list[Path] = []
|
||||
for target in _parse_import_paths(text, path):
|
||||
try:
|
||||
target_resolved = target.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
if target_resolved in visited:
|
||||
log.warning("import cycle: skipping %s (re-entered from %s)", target_resolved, path)
|
||||
continue
|
||||
if not target_resolved.exists():
|
||||
log.warning("missing import target %s referenced from %s", target_resolved, path)
|
||||
continue
|
||||
visited.add(target_resolved)
|
||||
out.append(target_resolved)
|
||||
out.extend(resolve_imports(target_resolved, depth=depth + 1, visited=visited))
|
||||
return out
|
||||
|
||||
|
||||
# ── Discovery ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _decode_project_name(encoded: str) -> str:
|
||||
"""`-Users-alice-mem0-platform` → `mem0-platform`."""
|
||||
m = PROJECT_DIR_DECODE_RE.match(encoded)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return encoded.lstrip("-")
|
||||
|
||||
|
||||
def _collect_claude_md_chain(cwd: Path) -> list[Path]:
|
||||
"""Walk up from cwd collecting CLAUDE.md / .claude/CLAUDE.md at each level."""
|
||||
chain: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
current = cwd
|
||||
while True:
|
||||
for candidate in (current / "CLAUDE.md", current / ".claude" / "CLAUDE.md"):
|
||||
try:
|
||||
if candidate.exists():
|
||||
resolved = candidate.resolve()
|
||||
if resolved not in seen:
|
||||
chain.append(resolved)
|
||||
seen.add(resolved)
|
||||
except OSError:
|
||||
continue
|
||||
parent = current.parent
|
||||
if parent == current:
|
||||
break
|
||||
current = parent
|
||||
return chain
|
||||
|
||||
|
||||
def discover(*, home: Path | None = None, cwd: Path | None = None) -> list[Source]:
|
||||
"""Enumerate every Claude state file we know how to import."""
|
||||
home = (home or Path.home()).resolve()
|
||||
cwd = (cwd or Path.cwd()).resolve()
|
||||
sources: list[Source] = []
|
||||
seen_paths: set[Path] = set()
|
||||
|
||||
def add(path: Path, source_type: str, **kw: Any) -> None:
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except OSError:
|
||||
return
|
||||
if resolved in seen_paths or not resolved.exists():
|
||||
return
|
||||
seen_paths.add(resolved)
|
||||
sources.append(Source(path=resolved, source_type=source_type, **kw))
|
||||
|
||||
# User-global CLAUDE.md
|
||||
add(home / ".claude" / "CLAUDE.md", "claude_md_user")
|
||||
|
||||
# User-level .claude/rules
|
||||
user_rules_dir = home / ".claude" / "rules"
|
||||
if user_rules_dir.is_dir():
|
||||
for rule in sorted(user_rules_dir.rglob("*.md")):
|
||||
add(rule, "rule")
|
||||
|
||||
# Project CLAUDE.md chain (walking up from cwd)
|
||||
for path in _collect_claude_md_chain(cwd):
|
||||
add(path, "claude_md_project")
|
||||
|
||||
# Project CLAUDE.local.md
|
||||
add(cwd / "CLAUDE.local.md", "claude_local")
|
||||
|
||||
# Project-level .claude/rules
|
||||
project_rules_dir = cwd / ".claude" / "rules"
|
||||
if project_rules_dir.is_dir():
|
||||
for rule in sorted(project_rules_dir.rglob("*.md")):
|
||||
add(rule, "rule")
|
||||
|
||||
# Per-project auto-memory
|
||||
projects_root = home / ".claude" / "projects"
|
||||
if projects_root.is_dir():
|
||||
for proj_dir in sorted(projects_root.iterdir()):
|
||||
mem_dir = proj_dir / "memory"
|
||||
if not mem_dir.is_dir():
|
||||
continue
|
||||
proj_name = _decode_project_name(proj_dir.name)
|
||||
for md in sorted(mem_dir.glob("*.md")):
|
||||
source_type = "memory_md" if md.name == "MEMORY.md" else "memory_topic"
|
||||
add(md, source_type, project_name=proj_name)
|
||||
|
||||
# Subagent auto-memory
|
||||
agent_root = home / ".claude" / "agent-memory"
|
||||
if agent_root.is_dir():
|
||||
for sub_dir in sorted(agent_root.iterdir()):
|
||||
if not sub_dir.is_dir():
|
||||
continue
|
||||
for md in sorted(sub_dir.glob("*.md")):
|
||||
source_type = "agent_memory" if md.name == "MEMORY.md" else "agent_memory_topic"
|
||||
add(md, source_type, subagent_name=sub_dir.name)
|
||||
|
||||
# Follow @imports from every CLAUDE.md / local / rule found above.
|
||||
snapshot = list(sources)
|
||||
for s in snapshot:
|
||||
if s.source_type in {"claude_md_user", "claude_md_project", "claude_local", "rule"}:
|
||||
for imp in resolve_imports(s.path):
|
||||
add(imp, "claude_md_import")
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
# ── Dispatcher ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def post_memory(
|
||||
*,
|
||||
content: str,
|
||||
user_id: str,
|
||||
metadata: dict[str, Any],
|
||||
infer: bool,
|
||||
api_key: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""POST one chunk to /v1/memories/. Returns the parsed response or None on
|
||||
recoverable failure. Raises AuthError on 401."""
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"user_id": user_id,
|
||||
"metadata": metadata,
|
||||
"infer": infer,
|
||||
}
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Token {api_key}",
|
||||
}
|
||||
|
||||
for attempt in (1, 2):
|
||||
req = urllib.request.Request(
|
||||
f"{API_URL}/v1/memories/",
|
||||
data=data,
|
||||
headers=headers,
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECS) as resp:
|
||||
if resp.status in (200, 201):
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
log.warning("API returned status %d", resp.status)
|
||||
return None
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise AuthError("Auth failed — check MEM0_API_KEY") from e
|
||||
if e.code == 429 and attempt == 1:
|
||||
log.warning("rate limited; sleeping %.1fs and retrying once", RETRY_SLEEP_SECS)
|
||||
time.sleep(RETRY_SLEEP_SECS)
|
||||
continue
|
||||
log.warning("API error %d: %s", e.code, e.reason)
|
||||
return None
|
||||
except urllib.error.URLError as e:
|
||||
log.warning("network error: %s", e)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ── Orchestrator ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _hash_set_for_file(marker: dict[str, Any], file_path: str) -> set[str]:
|
||||
for entry in marker.get("imports", []):
|
||||
if entry.get("file") == file_path:
|
||||
return {c["content_hash"] for c in entry.get("chunks", []) if "content_hash" in c}
|
||||
return set()
|
||||
|
||||
|
||||
def _get_or_create_file_entry(
|
||||
marker: dict[str, Any], file_path: str, source_type: str
|
||||
) -> dict[str, Any]:
|
||||
for entry in marker["imports"]:
|
||||
if entry.get("file") == file_path:
|
||||
return entry
|
||||
entry = {
|
||||
"file": file_path,
|
||||
"source_type": source_type,
|
||||
"imported_at": _now_iso(),
|
||||
"chunks": [],
|
||||
}
|
||||
marker["imports"].append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def run_import(
|
||||
*,
|
||||
home: Path,
|
||||
cwd: Path,
|
||||
marker_path: Path,
|
||||
user_id: str,
|
||||
api_key: str,
|
||||
infer: bool,
|
||||
dry_run: bool,
|
||||
source_filter: str | None,
|
||||
) -> dict[str, int]:
|
||||
"""Orchestrate the full import. Returns stats: planned, uploaded, skipped,
|
||||
failed, files.
|
||||
"""
|
||||
marker = read_marker(marker_path)
|
||||
sources = discover(home=home, cwd=cwd)
|
||||
if source_filter:
|
||||
sources = [s for s in sources if s.source_type == source_filter]
|
||||
|
||||
stats = {
|
||||
"planned": 0,
|
||||
"uploaded": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0,
|
||||
"files": len(sources),
|
||||
}
|
||||
|
||||
for source in sources:
|
||||
try:
|
||||
text = source.path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
log.warning("cannot read %s: %s", source.path, e)
|
||||
continue
|
||||
if not text.strip():
|
||||
continue
|
||||
chunks = chunk_markdown(text)
|
||||
already = _hash_set_for_file(marker, str(source.path))
|
||||
|
||||
for chunk in chunks:
|
||||
stats["planned"] += 1
|
||||
if chunk.content_hash in already:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
if dry_run:
|
||||
continue
|
||||
metadata = tag_chunk(source, chunk)
|
||||
try:
|
||||
resp = post_memory(
|
||||
content=chunk.body,
|
||||
user_id=user_id,
|
||||
metadata=metadata,
|
||||
infer=infer,
|
||||
api_key=api_key,
|
||||
)
|
||||
except AuthError:
|
||||
log.error("auth failed — aborting import")
|
||||
raise
|
||||
if resp is None:
|
||||
stats["failed"] += 1
|
||||
continue
|
||||
memory_ids = [item.get("id", "") for item in resp.get("results", [])]
|
||||
entry = _get_or_create_file_entry(marker, str(source.path), source.source_type)
|
||||
entry["chunks"].append(
|
||||
{
|
||||
"heading": chunk.heading,
|
||||
"content_hash": chunk.content_hash,
|
||||
"memory_ids": memory_ids,
|
||||
}
|
||||
)
|
||||
marker["last_run_at"] = _now_iso()
|
||||
marker["user_id"] = user_id
|
||||
write_marker(marker_path, marker)
|
||||
stats["uploaded"] += 1
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_home() -> Path:
|
||||
return Path.home()
|
||||
|
||||
|
||||
def _get_cwd() -> Path:
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="import_claude_state.py",
|
||||
description="Holistic import of on-disk Claude state into mem0.",
|
||||
)
|
||||
p.add_argument("--dry-run", action="store_true", help="Print plan, don't upload.")
|
||||
p.add_argument("--reset", action="store_true", help="Delete marker before running (forces full re-import).")
|
||||
p.add_argument("--no-infer", action="store_true", help="Upload chunks raw (infer=False) instead of letting mem0 extract facts.")
|
||||
p.add_argument(
|
||||
"--source",
|
||||
choices=sorted(SOURCE_TYPES),
|
||||
default=None,
|
||||
help="Restrict to one source_type.",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def _print_summary(stats: dict[str, int], *, dry_run: bool) -> None:
|
||||
if dry_run:
|
||||
print(
|
||||
f"[dry-run] Would upload {stats['planned'] - stats['skipped']} chunks "
|
||||
f"from {stats['files']} files ({stats['skipped']} already imported, "
|
||||
f"planned={stats['planned']})."
|
||||
)
|
||||
return
|
||||
print(
|
||||
f"Imported {stats['uploaded']} chunks from {stats['files']} files "
|
||||
f"(skipped {stats['skipped']}, failed {stats['failed']})."
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = _build_arg_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
api_key = os.environ.get("MEM0_API_KEY", "")
|
||||
if not api_key:
|
||||
print("MEM0_API_KEY not set — see plugin README.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from _identity import resolve_user_id # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
def resolve_user_id() -> str:
|
||||
return os.environ.get("MEM0_USER_ID") or os.environ.get("USER") or "default"
|
||||
|
||||
user_id = resolve_user_id()
|
||||
|
||||
if args.reset:
|
||||
try:
|
||||
MARKER_PATH.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
stats = run_import(
|
||||
home=_get_home(),
|
||||
cwd=_get_cwd(),
|
||||
marker_path=MARKER_PATH,
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
infer=not args.no_infer,
|
||||
dry_run=args.dry_run,
|
||||
source_filter=args.source,
|
||||
)
|
||||
except AuthError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
_print_summary(stats, dry_run=args.dry_run)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(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
|
||||
@@ -84,23 +127,4 @@ is being captured to mem0 in the background as `metadata.type=compact_summary`.
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── Holistic import nudge ────────────────────────────────────────────────
|
||||
# One-time nudge to backfill existing CLAUDE.md / MEMORY.md / agent-memory
|
||||
# into mem0. Silent after the marker file appears (i.e., after first run).
|
||||
# Only emit on startup or resume -- during compact the user is mid-conversation.
|
||||
if [ "$SOURCE" != "compact" ] && [ ! -f "$HOME/.mem0/imports/claude-state.json" ]; then
|
||||
cat <<EOF
|
||||
|
||||
## Holistic import available
|
||||
|
||||
On-disk Claude state (CLAUDE.md, ~/.claude/projects/*/memory, ~/.claude/agent-memory)
|
||||
has never been imported into mem0. To preview what would be imported, run:
|
||||
|
||||
python3 "$SCRIPT_DIR/import_claude_state.py" --dry-run
|
||||
|
||||
Then drop \`--dry-run\` to import. This nudge disappears after the first
|
||||
successful run. Pass \`--reset\` to re-import from scratch.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
+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