fix(mem0-plugin): v0.2.2 (#5234)
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
"""Shared content-chunking utilities for mem0-plugin import scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MIN_CHUNK_CHARS = 50
|
||||
MAX_CHUNK_CHARS = 10_000
|
||||
|
||||
|
||||
def split_by_headers(content: str, header_prefix: str = "## ") -> list[str]:
|
||||
"""Split content by Markdown header lines (e.g. '## ').
|
||||
|
||||
The header line is included at the start of each chunk.
|
||||
Returns a list of non-empty chunk strings.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in content.splitlines(keepends=True):
|
||||
if line.startswith(header_prefix) and current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
current_lines = [line]
|
||||
else:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def split_by_hr_or_headers(content: str) -> list[str]:
|
||||
"""Split content by '---' horizontal rules or '## ' headers.
|
||||
|
||||
Used for .continue/rules.md which may use either convention.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Split on lines that are exactly "---" or start with "## "
|
||||
chunks: list[str] = []
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in content.splitlines(keepends=True):
|
||||
is_hr = re.match(r"^---\s*$", line)
|
||||
is_h2 = line.startswith("## ")
|
||||
|
||||
if (is_hr or is_h2) and current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
current_lines = [] if is_hr else [line]
|
||||
else:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def filter_and_truncate(chunks: list[str]) -> list[str]:
|
||||
"""Filter out chunks shorter than MIN_CHUNK_CHARS, truncate long chunks."""
|
||||
result: list[str] = []
|
||||
for chunk in chunks:
|
||||
if len(chunk) < MIN_CHUNK_CHARS:
|
||||
continue
|
||||
if len(chunk) > MAX_CHUNK_CHARS:
|
||||
chunk = chunk[:MAX_CHUNK_CHARS]
|
||||
result.append(chunk)
|
||||
return result
|
||||
@@ -3,6 +3,7 @@
|
||||
Resolution priority (project_id):
|
||||
1. MEM0_PROJECT_ID env var (explicit override)
|
||||
2. ~/.mem0/project_map.json lookup by cwd
|
||||
2b. ~/.mem0/project_map.json lookup by remote hash (self-healing fallback)
|
||||
3. Git remote slug: strip protocol/prefix, strip .git, replace / and : with -
|
||||
e.g. git@github.com:mem0ai/mem0.git -> mem0ai-mem0
|
||||
4. Fallback: basename of cwd
|
||||
@@ -10,6 +11,7 @@ Resolution priority (project_id):
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -34,6 +36,19 @@ def resolve_project_id(cwd: str | None = None) -> str:
|
||||
mapped = project_map.get(cwd, "").strip()
|
||||
if mapped:
|
||||
return mapped
|
||||
# 2b. Remote hash fallback (self-healing when folder is moved/renamed)
|
||||
remote_key = _remote_hash_key(cwd)
|
||||
if remote_key:
|
||||
mapped = project_map.get(remote_key, "").strip()
|
||||
if mapped:
|
||||
# Self-heal: write the new CWD key so future lookups are fast
|
||||
project_map[cwd] = mapped
|
||||
try:
|
||||
with open(map_path, "w") as f:
|
||||
json.dump(project_map, f, indent=2)
|
||||
except OSError:
|
||||
pass
|
||||
return mapped
|
||||
except (OSError, json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
@@ -76,7 +91,7 @@ def resolve_branch(cwd: str | None = None) -> str:
|
||||
|
||||
|
||||
def save_project_mapping(cwd: str, project_id: str) -> None:
|
||||
"""Write cwd -> project_id into ~/.mem0/project_map.json."""
|
||||
"""Write cwd -> project_id (and remote hash key -> project_id) into ~/.mem0/project_map.json."""
|
||||
mem0_dir = os.path.expanduser("~/.mem0")
|
||||
os.makedirs(mem0_dir, exist_ok=True)
|
||||
map_path = os.path.join(mem0_dir, "project_map.json")
|
||||
@@ -88,10 +103,40 @@ def save_project_mapping(cwd: str, project_id: str) -> None:
|
||||
except (OSError, json.JSONDecodeError):
|
||||
project_map = {}
|
||||
project_map[cwd] = project_id
|
||||
# Also write the remote hash key so the mapping survives folder moves/renames
|
||||
remote_key = _remote_hash_key(cwd)
|
||||
if remote_key:
|
||||
project_map[remote_key] = project_id
|
||||
with open(map_path, "w") as f:
|
||||
json.dump(project_map, f, indent=2)
|
||||
|
||||
|
||||
def _remote_hash_key(cwd: str | None = None) -> str:
|
||||
"""Return a stable key derived from the git remote URL.
|
||||
|
||||
Runs ``git config --get remote.origin.url`` in *cwd* and returns a string
|
||||
of the form ``remote:<sha256(url)[:16]>``. Returns an empty string when
|
||||
the directory is not a git repo or has no remote configured.
|
||||
"""
|
||||
if cwd is None:
|
||||
cwd = os.getcwd()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "config", "--get", "remote.origin.url"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=cwd,
|
||||
)
|
||||
url = result.stdout.strip()
|
||||
if not url:
|
||||
return ""
|
||||
digest = hashlib.sha256(url.encode()).hexdigest()[:16]
|
||||
return f"remote:{digest}"
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return ""
|
||||
|
||||
|
||||
def _remote_url_to_slug(url: str) -> str:
|
||||
"""Convert a git remote URL to a deterministic slug.
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _chunking import filter_and_truncate, split_by_headers
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
@@ -198,10 +199,24 @@ def main() -> None:
|
||||
log.debug("Cannot read %s: %s", filename, e)
|
||||
continue
|
||||
|
||||
if post_memory(api_key, content, user_id, filename, project_id, branch):
|
||||
is_markdown = filename.endswith(".md")
|
||||
if is_markdown:
|
||||
chunks = filter_and_truncate(split_by_headers(content))
|
||||
else:
|
||||
chunks = filter_and_truncate([content])
|
||||
|
||||
if not chunks:
|
||||
chunks = [content[:10000]]
|
||||
|
||||
success = True
|
||||
for i, chunk in enumerate(chunks):
|
||||
chunk_name = f"{filename}[{i+1}/{len(chunks)}]" if len(chunks) > 1 else filename
|
||||
if not post_memory(api_key, chunk, user_id, chunk_name, project_id, branch):
|
||||
success = False
|
||||
|
||||
if success:
|
||||
hashes[hash_key] = current_hash
|
||||
updated = True
|
||||
# on API failure we don't update the hash — retry next session
|
||||
|
||||
if updated:
|
||||
save_hashes(hashes)
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook for mcp__mem0__add_memory.
|
||||
# Injects default metadata fields (confidence, files, source, type) when the
|
||||
# agent omits them. Uses the hookSpecificOutput.updatedInput contract to
|
||||
# actually modify the tool call parameters.
|
||||
#
|
||||
# Hook contract:
|
||||
# exit 0 = allow. If stdout contains {"hookSpecificOutput": {"updatedInput": ...}},
|
||||
# the updatedInput replaces the tool's input parameters.
|
||||
# exit 2 = block (stderr shown as rejection reason).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
|
||||
case "$TOOL_NAME" in
|
||||
mcp__mem0__add_memory|mcp__plugin_mem0_mem0__add_memory) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input // "{}"' 2>/dev/null)
|
||||
|
||||
PATCHED=$(python3 -c "
|
||||
import json, sys
|
||||
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
inp = json.loads(raw)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
|
||||
meta = inp.get('metadata') or {}
|
||||
changed = False
|
||||
|
||||
if 'confidence' not in meta:
|
||||
meta['confidence'] = 0.7
|
||||
changed = True
|
||||
if 'files' not in meta:
|
||||
meta['files'] = ['*']
|
||||
changed = True
|
||||
if 'source' not in meta:
|
||||
meta['source'] = 'auto_capture'
|
||||
changed = True
|
||||
if 'type' not in meta:
|
||||
meta['type'] = 'task_learning'
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
inp['metadata'] = meta
|
||||
print(json.dumps(inp))
|
||||
" <<< "$TOOL_INPUT" 2>/dev/null || true)
|
||||
|
||||
if [ -n "$PATCHED" ]; then
|
||||
# Use hookSpecificOutput.updatedInput to actually modify the tool call
|
||||
jq -n --argjson updated "$PATCHED" '{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "allow",
|
||||
"updatedInput": $updated
|
||||
}
|
||||
}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -11,6 +11,8 @@ REQ_STAMP="${DATA_DIR}/requirements.txt"
|
||||
|
||||
mkdir -p "${DATA_DIR}"
|
||||
|
||||
LOCKDIR="${DATA_DIR}/.install-lock"
|
||||
|
||||
needs_install=false
|
||||
|
||||
if [ ! -f "${VENV_DIR}/bin/python3" ]; then
|
||||
@@ -20,13 +22,29 @@ elif ! diff -q "${REQ_SRC}" "${REQ_STAMP}" >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
if [ "${needs_install}" = "true" ]; then
|
||||
python3 -m venv "${VENV_DIR}" 2>/dev/null || python -m venv "${VENV_DIR}"
|
||||
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
|
||||
if "${VENV_DIR}/bin/pip" install --quiet -r "${REQ_SRC}" 2>/dev/null; then
|
||||
cp "${REQ_SRC}" "${REQ_STAMP}"
|
||||
if mkdir "${LOCKDIR}" 2>/dev/null; then
|
||||
# We acquired the lock — proceed with installation
|
||||
trap 'rmdir "${LOCKDIR}" 2>/dev/null || true' EXIT
|
||||
python3 -m venv "${VENV_DIR}" 2>/dev/null || python -m venv "${VENV_DIR}"
|
||||
"${VENV_DIR}/bin/pip" install --quiet --upgrade pip >/dev/null 2>&1 || true
|
||||
if "${VENV_DIR}/bin/pip" install --quiet -r "${REQ_SRC}" 2>/dev/null; then
|
||||
cp "${REQ_SRC}" "${REQ_STAMP}"
|
||||
rm -f "${DATA_DIR}/.install-failed"
|
||||
else
|
||||
rm -f "${REQ_STAMP}"
|
||||
touch "${DATA_DIR}/.install-failed"
|
||||
echo "mem0 plugin: failed to install Python dependencies" >&2
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
rm -f "${REQ_STAMP}"
|
||||
echo "mem0 plugin: failed to install Python dependencies" >&2
|
||||
exit 0
|
||||
# Another process holds the lock — wait up to 60s for it to finish
|
||||
for i in $(seq 1 60); do
|
||||
[ ! -d "${LOCKDIR}" ] && break
|
||||
sleep 1
|
||||
done
|
||||
# Check if the other process's install failed
|
||||
if [ -f "${DATA_DIR}/.install-failed" ]; then
|
||||
echo "mem0 plugin: dependency installation failed (by another session)" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -24,87 +24,15 @@ import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from _chunking import (
|
||||
filter_and_truncate,
|
||||
split_by_headers,
|
||||
split_by_hr_or_headers,
|
||||
)
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
|
||||
API_URL = "https://api.mem0.ai"
|
||||
MIN_CHUNK_CHARS = 50
|
||||
MAX_CHUNK_CHARS = 10_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content splitting utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def split_by_headers(content: str, header_prefix: str = "## ") -> list[str]:
|
||||
"""Split content by Markdown header lines (e.g. '## ').
|
||||
|
||||
The header line is included at the start of each chunk.
|
||||
Returns a list of non-empty chunk strings.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in content.splitlines(keepends=True):
|
||||
if line.startswith(header_prefix) and current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
current_lines = [line]
|
||||
else:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def split_by_hr_or_headers(content: str) -> list[str]:
|
||||
"""Split content by '---' horizontal rules or '## ' headers.
|
||||
|
||||
Used for .continue/rules.md which may use either convention.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Split on lines that are exactly "---" or start with "## "
|
||||
chunks: list[str] = []
|
||||
current_lines: list[str] = []
|
||||
|
||||
for line in content.splitlines(keepends=True):
|
||||
is_hr = re.match(r"^---\s*$", line)
|
||||
is_h2 = line.startswith("## ")
|
||||
|
||||
if (is_hr or is_h2) and current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
current_lines = [] if is_hr else [line]
|
||||
else:
|
||||
current_lines.append(line)
|
||||
|
||||
if current_lines:
|
||||
chunk = "".join(current_lines).strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def filter_and_truncate(chunks: list[str]) -> list[str]:
|
||||
"""Filter out chunks shorter than MIN_CHUNK_CHARS, truncate long chunks."""
|
||||
result: list[str] = []
|
||||
for chunk in chunks:
|
||||
if len(chunk) < MIN_CHUNK_CHARS:
|
||||
continue
|
||||
if len(chunk) > MAX_CHUNK_CHARS:
|
||||
chunk = chunk[:MAX_CHUNK_CHARS]
|
||||
result.append(chunk)
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API helpers
|
||||
|
||||
@@ -33,7 +33,9 @@ esac
|
||||
|
||||
# Detect stack traces and error patterns in command output
|
||||
HAS_ERROR=""
|
||||
if echo "$TOOL_RESULT" | grep -qiE '(Traceback \(most recent|Error:|Exception:|panic:|FAILED|fatal:|FAIL:| at .+\.[a-z]+:[0-9]+|error\[E[0-9]+\])'; then
|
||||
if echo "$TOOL_RESULT" | grep -qE '(Traceback \(most recent call last\)|panic: |FATAL:|error\[E[0-9]+\])'; then
|
||||
HAS_ERROR="true"
|
||||
elif [ "$(echo "$TOOL_RESULT" | grep -cE '(Error:|Exception:)')" -ge 2 ]; then
|
||||
HAS_ERROR="true"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Hook: PreToolUse (matcher: Bash)
|
||||
#
|
||||
# Detects `git commit` commands and:
|
||||
# 1. Fires on_pre_commit.py in the background to capture staged changes as memory
|
||||
# 2. Searches for relevant memories about the changed files and surfaces them
|
||||
# Detects `git commit` commands and searches for relevant memories
|
||||
# about the changed files, surfacing them as pre-commit context.
|
||||
#
|
||||
# Input: JSON on stdin with tool_name, tool_input
|
||||
# Output: JSON with additionalContext (relevant memories for the commit)
|
||||
|
||||
@@ -44,8 +44,15 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# C5: commit prompts default OFF — only prompt if explicitly enabled in mem0.md
|
||||
MEM0_CWD=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
|
||||
COMMIT_PROMPTS=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.commit_prompts "$MEM0_CWD" 2>/dev/null || echo "")
|
||||
if [ "$COMMIT_PROMPTS" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract changed files from commit
|
||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | head -10 || echo "")
|
||||
CHANGED_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | head -10 || echo "")
|
||||
if [ -z "$CHANGED_FILES" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-commit memory capture.
|
||||
|
||||
Captures a summary of staged changes as a mem0 memory before each commit.
|
||||
Runs as a background fire-and-forget call — never blocks the commit.
|
||||
|
||||
Usage:
|
||||
git diff --cached --stat | python3 on_pre_commit.py
|
||||
# or with full diff:
|
||||
git diff --cached | python3 on_pre_commit.py --full
|
||||
|
||||
Env vars required: MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY)
|
||||
Env vars optional: MEM0_RESOLVED_USER_ID, MEM0_PROJECT_ID, MEM0_BRANCH
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from _identity import resolve_api_key, resolve_user_id
|
||||
|
||||
try:
|
||||
from _project import resolve_branch, resolve_project_id
|
||||
except ImportError:
|
||||
def resolve_project_id() -> str:
|
||||
return os.path.basename(os.getcwd())
|
||||
|
||||
def resolve_branch() -> str:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_commit_message() -> str:
|
||||
"""Read the current commit message from COMMIT_EDITMSG (pre-commit context).
|
||||
|
||||
Falls back to HEAD's message if COMMIT_EDITMSG doesn't exist yet
|
||||
(e.g., when invoked outside the git hook context).
|
||||
"""
|
||||
try:
|
||||
git_dir = subprocess.run(
|
||||
["git", "rev-parse", "--git-dir"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if git_dir.returncode == 0:
|
||||
editmsg = os.path.join(git_dir.stdout.strip(), "COMMIT_EDITMSG")
|
||||
if os.path.isfile(editmsg):
|
||||
with open(editmsg) as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line and not first_line.startswith("#"):
|
||||
return first_line
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%s", "HEAD"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def get_staged_summary() -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--cached", "--stat"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
api_key = resolve_api_key()
|
||||
if not api_key:
|
||||
return 0
|
||||
|
||||
diff_input = sys.stdin.read().strip() if not sys.stdin.isatty() else ""
|
||||
staged = diff_input or get_staged_summary()
|
||||
if not staged or len(staged) < 10:
|
||||
return 0
|
||||
|
||||
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or resolve_user_id()
|
||||
project_id = os.environ.get("MEM0_PROJECT_ID") or resolve_project_id()
|
||||
branch = os.environ.get("MEM0_BRANCH") or resolve_branch()
|
||||
commit_msg = get_commit_message()
|
||||
|
||||
lines = staged.splitlines()
|
||||
if len(lines) > 30:
|
||||
staged = "\n".join(lines[:30]) + f"\n... ({len(lines) - 30} more lines)"
|
||||
|
||||
content = f"## Commit Context\n\nBranch: {branch}\n"
|
||||
if commit_msg:
|
||||
content += f"Message: {commit_msg}\n"
|
||||
content += f"\n### Staged Changes\n```\n{staged}\n```"
|
||||
|
||||
body = json.dumps({
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": {
|
||||
"type": "commit_context",
|
||||
"branch": branch,
|
||||
"source": "pre-commit",
|
||||
},
|
||||
"infer": False,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
"https://api.mem0.ai/v3/memories/add/",
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Token {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10):
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -41,6 +41,15 @@ if [ -z "${MEM0_API_KEY:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for failed dependency installation and warn the user
|
||||
_DATA_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.mem0/plugin-data}"
|
||||
if [ -f "${_DATA_DIR}/.install-failed" ]; then
|
||||
echo ""
|
||||
echo "⚠️ mem0 SDK installation failed. Some features may not work."
|
||||
echo "Run: ${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}/scripts/ensure_deps.sh"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Fetch project-scoped memory count (best-effort, don't block on failure, 5s timeout)
|
||||
MEM0_COUNT="?"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
@@ -96,10 +105,14 @@ if command -v python3 >/dev/null 2>&1; then
|
||||
MEM0_PROJECT_CONFIG=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --full "$MEM0_CWD_RESOLVED" 2>/dev/null || echo "{}")
|
||||
fi
|
||||
if [ -n "$MEM0_PROJECT_CONFIG" ] && [ "$MEM0_PROJECT_CONFIG" != "{}" ]; then
|
||||
_CONFIG_KEYS=$(echo "$MEM0_PROJECT_CONFIG" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d))" 2>/dev/null || echo "?")
|
||||
echo "### Project Config (mem0.md)"
|
||||
echo "\`\`\`json"
|
||||
echo "$MEM0_PROJECT_CONFIG"
|
||||
echo "\`\`\`"
|
||||
echo "\`mem0.md loaded (${_CONFIG_KEYS} sections configured)\`"
|
||||
if [ -n "${MEM0_DEBUG:-}" ]; then
|
||||
echo "\`\`\`json"
|
||||
echo "$MEM0_PROJECT_CONFIG"
|
||||
echo "\`\`\`"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
@@ -108,6 +121,8 @@ if [ "$SOURCE" = "startup" ]; then
|
||||
_SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--')
|
||||
_ONBOARD_MARKER="$HOME/.mem0/.onboarded_${_SAFE_PID}"
|
||||
if [ ! -f "$_ONBOARD_MARKER" ]; then
|
||||
mkdir -p "$HOME/.mem0" 2>/dev/null || true
|
||||
touch "$_ONBOARD_MARKER"
|
||||
cat <<'EOF'
|
||||
## Mem0 First Run — Automatic Onboarding
|
||||
|
||||
@@ -135,6 +150,22 @@ IMPORTANT: Do NOT skip this step. Always bootstrap context first.
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Detect native Claude Code auto-memory for THIS project
|
||||
_PROJ_KEY=$(printf '%s' "$MEM0_CWD_RESOLVED" | tr '/' '-')
|
||||
_MEMORY_MD="$HOME/.claude/projects/${_PROJ_KEY}/memory/MEMORY.md"
|
||||
if [ -f "$_MEMORY_MD" ] && [ -s "$_MEMORY_MD" ]; then
|
||||
cat <<MEMEOF
|
||||
|
||||
### Native auto-memory detected
|
||||
|
||||
Found \`$_MEMORY_MD\`. The mem0 plugin handles all memory storage.
|
||||
To avoid two parallel memory systems:
|
||||
- Add \`"autoMemoryEnabled": false\` to \`~/.claude/settings.json\`
|
||||
- Or run \`/mem0:import\` to migrate existing MEMORY.md content into mem0
|
||||
|
||||
MEMEOF
|
||||
fi
|
||||
|
||||
# Auto-import declarative project files in background
|
||||
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
|
||||
python3 "$SCRIPT_DIR/auto_import.py" 2>/dev/null &
|
||||
|
||||
@@ -24,9 +24,17 @@ if [ ${#RESULT_SUMMARY} -lt 50 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read subagent skip list from config, default to Explore|Plan
|
||||
_SKIP_CSV=$(python3 "$SCRIPT_DIR/parse_mem0_config.py" --key settings.subagent_skip "$(git rev-parse --show-toplevel 2>/dev/null || echo ".")" 2>/dev/null || echo "")
|
||||
if [ -n "$_SKIP_CSV" ]; then
|
||||
_SKIP_PATTERN=$(echo "$_SKIP_CSV" | tr -d ' ' | tr ',' '|')
|
||||
else
|
||||
_SKIP_PATTERN="Explore|Plan"
|
||||
fi
|
||||
|
||||
# Skip explorer/plan agents — read-only, rarely produce storable learnings
|
||||
case "$AGENT_TYPE" in
|
||||
Explore|Plan)
|
||||
$_SKIP_PATTERN)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -31,7 +31,11 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Detect stack traces and error patterns in the prompt (no API needed)
|
||||
HAS_ERROR=""
|
||||
if echo "$PROMPT" | grep -qiE '(Traceback|Error:|Exception:|panic:|FAILED|fatal:| at .+\.[a-z]+:[0-9]+)'; then
|
||||
if echo "$PROMPT" | grep -qE '(Traceback|panic:)'; then
|
||||
HAS_ERROR="true"
|
||||
elif echo "$PROMPT" | grep -qE '^\s*fatal: '; then
|
||||
HAS_ERROR="true"
|
||||
elif [ "$(echo "$PROMPT" | grep -cE '(Error:|Exception:|FAIL:)')" -ge 2 ]; then
|
||||
HAS_ERROR="true"
|
||||
fi
|
||||
|
||||
|
||||
@@ -199,6 +199,10 @@ def load_full_config(cwd: str | None = None) -> dict:
|
||||
if ignore:
|
||||
config["ignore"] = ignore
|
||||
|
||||
settings = parse_section_kv(content, "Settings")
|
||||
if settings:
|
||||
config["settings"] = settings
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -229,10 +233,44 @@ def main() -> int:
|
||||
|
||||
With ``--full``, prints the complete config. Without it, prints only
|
||||
retention policies (backward-compatible).
|
||||
|
||||
With ``--key <dotted.path>``, prints the scalar value at that path in the
|
||||
full config (e.g. ``--key settings.commit_prompts``). Prints an empty
|
||||
string when the key is absent. Exits 1 only on unexpected errors.
|
||||
"""
|
||||
full_mode = "--full" in sys.argv
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
cwd = args[0] if args else os.getcwd()
|
||||
|
||||
# Extract --key <value>
|
||||
key_path: str | None = None
|
||||
raw_args = sys.argv[1:]
|
||||
filtered_args: list[str] = []
|
||||
i = 0
|
||||
while i < len(raw_args):
|
||||
if raw_args[i] == "--key" and i + 1 < len(raw_args):
|
||||
key_path = raw_args[i + 1]
|
||||
i += 2
|
||||
elif raw_args[i].startswith("--key="):
|
||||
key_path = raw_args[i][len("--key="):]
|
||||
i += 1
|
||||
elif raw_args[i].startswith("--"):
|
||||
i += 1 # skip other flags like --full
|
||||
else:
|
||||
filtered_args.append(raw_args[i])
|
||||
i += 1
|
||||
|
||||
cwd = filtered_args[0] if filtered_args else os.getcwd()
|
||||
|
||||
if key_path is not None:
|
||||
config = load_full_config(cwd)
|
||||
# Traverse dotted path
|
||||
value: object = config
|
||||
for part in key_path.split("."):
|
||||
if not isinstance(value, dict):
|
||||
value = None
|
||||
break
|
||||
value = value.get(part)
|
||||
print(value if value is not None else "")
|
||||
return 0
|
||||
|
||||
if full_mode:
|
||||
config = load_full_config(cwd)
|
||||
|
||||
@@ -27,7 +27,16 @@ import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
PLUGIN_VERSION = "0.2.1"
|
||||
|
||||
def _load_plugin_version() -> str:
|
||||
try:
|
||||
plugin_json = os.path.join(os.path.dirname(__file__), "..", ".claude-plugin", "plugin.json")
|
||||
with open(plugin_json) as f:
|
||||
return json.load(f).get("version", "unknown")
|
||||
except (OSError, json.JSONDecodeError, KeyError):
|
||||
return "unknown"
|
||||
|
||||
PLUGIN_VERSION = _load_plugin_version()
|
||||
|
||||
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
|
||||
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
|
||||
@@ -42,10 +51,10 @@ def _sha256(value: str) -> str:
|
||||
|
||||
|
||||
def _distinct_id() -> str:
|
||||
"""Stable anonymous ID: MD5 of API key if available, else SHA-256 of username."""
|
||||
"""Stable anonymous ID: SHA-256 of API key if available, else SHA-256 of username."""
|
||||
api_key = os.environ.get("MEM0_API_KEY") or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") or ""
|
||||
if api_key:
|
||||
return hashlib.md5(api_key.encode()).hexdigest()
|
||||
return hashlib.sha256(api_key.encode()).hexdigest()[:32]
|
||||
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
|
||||
return _sha256(user_id)
|
||||
|
||||
@@ -75,6 +84,7 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di
|
||||
"distinct_id": _distinct_id(),
|
||||
"event": event_name,
|
||||
"properties": {
|
||||
**(properties or {}),
|
||||
"source": "plugin",
|
||||
"platform": detect_platform(),
|
||||
"plugin_version": PLUGIN_VERSION,
|
||||
@@ -84,7 +94,6 @@ def build_posthog_payload(event_name: str, properties: dict | None = None) -> di
|
||||
"sample_rate": SAMPLE_RATE,
|
||||
"$process_person_profile": False,
|
||||
"$lib": "posthog-python",
|
||||
**(properties or {}),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user