Compare commits

...

31 Commits

Author SHA1 Message Date
kartik-mem0 271c7c23c5 fix(mem0-plugin): remove noisy session-state dumps, match openclaw pattern
- Remove all background REST API transcript dumps (on_pre_compact.py,
  capture_compact_summary.py, on_pre_commit.py calls) that were creating
  noisy session_state and commit_context memories automatically
- Rewrite pre-compaction prompt to extract individual durable facts
  (decisions, learnings, anti-patterns) instead of session summary blobs
- Agent now decides what to store, matching openclaw's triage pattern:
  "Most sessions produce zero memory operations. That is correct."
- Fix auto_import.py to search git repo root for CLAUDE.md/AGENTS.md
  (was only searching plugin cwd, missing files in parent directory)
- Fix health check session tracker to check stats file directly instead
  of depending on CLAUDE_PLUGIN_ROOT env var
- Fix session-end report to print in SessionEnd hook (Stop hook output
  doesn't render on /exit)
- Update v2 API endpoint to v3 in git commit capture search
- Keep auto_import.py as only direct REST write path (project profiles,
  idempotent, hash-gated)
2026-05-22 16:20:31 +05:30
kartik-mem0 c2290299cf docs: add session stats file check and explanation to mem0-health skill 2026-05-22 15:33:59 +05:30
kartik-mem0 1b3a57e8ec refactor: update API endpoint to v3 in git commit capture script 2026-05-22 15:28:50 +05:30
kartik-mem0 245a70d90f refactor: update mem0-plugin scripts to use tool_output instead of tool_result 2026-05-22 14:53:56 +05:30
kartik-mem0 71a6696c87 refactor(mem0-plugin): improve hook scripts, config parsing, and skill definitions
Update git commit capture with better error handling, enhance user prompt hook,
extend config parser capabilities, and refine skill docs for dream, health,
mcp, pin, and stats skills.
2026-05-22 14:23:06 +05:30
kartik-mem0 6ab6ed58a8 refactor: update session start script and mem0-tour skill to display full memory content and use 2026-05-22 13:54:04 +05:30
kartik-mem0 1853543ceb Merge remote-tracking branch 'origin/main' into feat/mem0-plugin 2026-05-22 13:35:12 +05:30
kartik-mem0 93bb3b69c4 feat(mem0-plugin): add PostToolUseFailure, PostCompact, SessionEnd, SubagentStop hooks
Close 4 lifecycle coverage gaps:
- PostToolUseFailure: catch failed mem0 MCP calls, classify errors, inject retry guidance
- PostCompact: inject parallel search commands for context recovery after compaction
- SessionEnd: last-chance transcript capture with dedup against Stop hook
- SubagentStop: remind parent agent to persist subagent learnings

Also patch on_stop.sh to write capture marker for SessionEnd dedup.
2026-05-22 01:09:22 +05:30
kartik-mem0 97a3a2cf8d feat(mem0-plugin): complete all Tier 1-8 spec items, add scheduled dream and bash error detection
- Add /mem0:dream --schedule support via Claude Code /schedule command
  with local launchd/cron fallback for non-cloud users
- Add /mem0:digest --schedule and file output to ~/.mem0/weekly-digest.md
  with digest-history.log for trend tracking
- Add on_post_commit.sh PostToolUse hook: interactive prompt after git
  commit asking user to save change as memory with category suggestion
- Add on_bash_output.sh PostToolUse hook: detects stack traces and error
  patterns in bash command output, injects mem0 search rubric for prior
  occurrences (complements existing user prompt error detection)
- Fix session-end report: now shows per-category breakdown
  "wrote 3 memories (decision 2, anti_pattern 1)" matching spec
- Wire new hooks into all 3 platforms (Claude Code, Cursor, Codex)
- Add post_commit and bash_error telemetry events
- Add CTO demo testing guide with tier-by-tier manual test steps
2026-05-21 23:55:06 +05:30
kartik-mem0 b49626ab11 fix(mem0-plugin): wire pre-commit capture hook, add Setup hook, fix access counters
- Add PreToolUse(Bash) hook: detects `git commit` commands, fires
  on_pre_commit.py in background to capture staged changes as memory
- Add Setup(init|maintenance) hook: installs mem0ai SDK on first plugin
  install via ensure_deps.sh (120s timeout for slow networks)
- Add on_git_commit_capture.sh wrapper: reads tool input JSON, matches
  git commit/merge/rebase, runs capture script fire-and-forget
- Fix #24: add metadata.last_accessed ISO timestamp to access counter
  instructions in mem0-mcp/SKILL.md alongside existing access_count
2026-05-21 23:02:32 +05:30
kartik-mem0 eb1cdabe61 fix(mem0-plugin): remove userConfig from plugin.json, add auto-deps install
- Remove userConfig from .claude-plugin/plugin.json — OAuth flow handles
  auth via mcp.mem0.ai, userConfig prompt was redundant and confusing
- Add ensure_deps.sh to install mem0ai SDK into persistent plugin venv
- Add requirements.txt (mem0ai) for dependency tracking
- Wire SessionStart hook to auto-install deps on first run
- Update setup_coding_categories.py to find venv site-packages
- Update onboarding SKILL.md with venv-aware install steps
2026-05-21 22:52:27 +05:30
kartik-mem0 141782a230 fix(mem0-plugin): use env var in .mcp.json, fix onboarding MCP check
- .mcp.json: use ${MEM0_API_KEY} instead of ${user_config.MEM0_API_KEY}
  so MCP connects for --plugin-dir users with the key in their shell
- mem0-onboard: check MCP tools availability first, stop early with
  clear instructions if MCP server not connected
2026-05-21 21:29:19 +05:30
kartik-mem0 d5b15cebb7 feat(mem0-plugin): add PostHog telemetry across all hooks
Wire fire-and-forget PostHog telemetry into every lifecycle hook
(session_start, stop, pre_compact, task_completed, user_prompt,
post_tool_use) matching the same project key, endpoint, and payload
structure used by the Python SDK, CLI, and OpenClaw.

- 10% sampling on ALL events to keep PostHog costs predictable
- Anonymous distinct_id: MD5(api_key) or SHA-256(username)
- Opt-out via MEM0_TELEMETRY=false
- Platform detection: Claude Code / Cursor / Codex
- Never sends user content, API keys, or raw identifiers
- 16 new tests (110 total passing)
2026-05-21 21:08:21 +05:30
kartik-mem0 c5bb11b8b1 fix(mem0-plugin): graceful degradation without API key, stdin support
- SessionStart shows "Mem0 Inactive" banner with identity info when no
  API key set, instead of silently exiting with zero output
- on_user_prompt.sh fires error/file-path detection even without API key
  (local pattern matching, no API needed). Search rubric still gated.
- parse_export_file.py supports '-' argument for stdin piping
2026-05-21 19:13:13 +05:30
kartik-mem0 1daa7ece66 fix(mem0-plugin): fix 3 critical + 5 important issues from review
Critical fixes:
- C1: Pin/dream update_memory now reads content first via get_memory
  before updating, preventing metadata-only calls that wipe content
- C2: SessionStart memory count uses GET /v3/memories/?page_size=1 for
  real count from pagination envelope instead of search limit=100
- C3: on_task_completed.sh uses ${MEM0_PROJECT_ID:-unknown} to avoid
  crash when identity resolution fails under set -u

Important fixes:
- I1: session_stats.py tracks recent_ids (memory IDs + timestamps) for
  /mem0:forget undo support. Capped at 50 entries.
- I2: /mem0:health --deep mode adds memory quality analysis: duplicate
  detection, stale entry scan, contradiction flags, orphan tagging
- I3: /mem0:stats ASCII histogram for category distribution with bar
  chart, sorted by count descending
- I4: mem0-mcp skill documents access counter pattern: get_memory then
  update_memory to track which memories are actually used
- I5: on_pre_commit.py reads COMMIT_EDITMSG first (current commit),
  falls back to HEAD only if EDITMSG unavailable

Tests: 91 -> 94 (3 new for recent_ids tracking)
2026-05-21 18:46:08 +05:30
kartik-mem0 70f2f60727 chore(mem0-plugin): remove TESTING.md from tracking
Manual testing guide will be local-only, not committed to repo.
2026-05-21 18:42:02 +05:30
kartik-mem0 35da92ba88 docs(mem0-plugin): comprehensive TESTING.md covering all Tiers 1-8
Rewrote TESTING.md with behavioral tests for every tier item:
- v3 endpoint verification for all 5 write scripts + search
- app_id scoping and branch tagging checks
- Skill content validation (confidence, citations, recency, infer=False)
- Robustness checks (loop guard, set flags, fallback stubs)
- 28-item checklist covering all 34 roadmap items
- Fixed stale comment in on_stop.sh
2026-05-21 18:40:48 +05:30
kartik-mem0 4b94902446 fix(mem0-plugin): review fixes — loop guard, v3 response format, robustness
- on_stop_cursor.sh: add loop_count guard to prevent infinite re-entry
- on_stop.sh: drop set -e so stats failure doesn't kill the reminder
- _identity.py: fix fallback stub signatures to match production (accept cwd arg)
- on_pre_compact.py: add missing infer=False on session_state writes
- on_session_start.sh: handle v3 search response format (dict with results key)
- Align all plugin versions to 0.2.1 (Claude Code, Codex, Cursor)
2026-05-21 18:27:47 +05:30
kartik-mem0 470cef6ba6 fix(mem0-plugin): upgrade REST endpoints to v3, fix dream scheduling docs
- All add_memory REST calls now use /v3/memories/add/ (was /v1/memories/)
- Session start search uses /v3/memories/search/ (was /v2/memories/search/)
- Dream skill: removed reference to nonexistent /schedule skill
  (Claude Code has no native scheduling — recommend external cron instead)
2026-05-21 18:14:19 +05:30
kartik-mem0 930f6ec51d feat(mem0-plugin): power-user skills + expanded mem0.md config (Tier 8)
Power-user tier:
- /mem0:remember: quick-add with auto-classification, infer=False, confidence=1.0
- /mem0:forget: search-confirm-delete flow with numbered list
- /mem0:pin: mark memories as pinned via metadata update
- /mem0:peek: compact one-liner search results
- parse_mem0_config.py: expanded to parse ## Search, ## Categories, ## Identity
- on_session_start.sh: inject mem0.md project config into session context
2026-05-21 18:14:09 +05:30
kartik-mem0 cd66050748 feat(mem0-plugin): smart retrieval, citations, pre-commit capture (Tier 7)
Smart retrieval tier:
- on_user_prompt.sh: stack-trace detection (auto-searches anti_patterns on errors)
- on_user_prompt.sh: file-aware retrieval (extracts file paths, suggests scoped search)
- on_user_prompt.sh: recency boost guidance for state-related queries
- mem0-mcp/SKILL.md: inline citation format [mem0:<8char_id>]
- on_pre_commit.py: captures staged diff as commit_context memory via REST API
2026-05-21 18:13:59 +05:30
kartik-mem0 81211be473 feat(mem0-plugin): add /mem0:stats, /mem0:health, /mem0:digest skills (Tier 6)
Observability tier:
- /mem0:stats: session + lifetime memory dashboard with category breakdown
- /mem0:health: 5-check diagnostic (API key, identity, MCP, write/read, tracker)
- /mem0:digest: weekly memory activity summary with time-scoped searches
- session_stats.py: add `peek` command (JSON without clearing), per-category counters
2026-05-21 18:13:44 +05:30
kartik-mem0 82ab253f2c fix(mem0-plugin): sanitize marker path, add pre-compact REST backup, fix cross-tool gaps
- Sanitize project_id in onboard marker path (tr '/:' '--') to handle
  slashes from env var overrides
- Fire on_pre_compact.py REST backup from PreCompact hook (was only
  called from Stop hooks — crash before Stop = data loss)
- Source _identity.sh in on_stop_codex.sh and on_stop_cursor.sh so
  MEM0_PROJECT_ID + API key fallback are available
- Note dream --schedule is Claude Code-only (Codex/Cursor lack schedule)
2026-05-21 17:43:40 +05:30
kartik-mem0 63641f5573 feat(mem0-plugin): add export/import skills + competing tool importers
Tier 3 implementation:
- #10: Unified project_id resolver already complete (shared scripts/)
- #11: /mem0:export dumps all project memories as YAML-frontmatter
  markdown; /mem0:import restores from same format. Round-trip capable.
  parse_export_file.py handles parsing (stdlib-only).
- #12: import_competing_tools.py imports from .cursorrules, copilot
  instructions, cline memory-bank, continue.dev rules. Splits by
  section headers, POSTs with app_id + infer=False.
2026-05-21 17:43:30 +05:30
kartik-mem0 b79be63e30 feat(mem0-plugin): add /mem0:dream consolidation + retention policies
Tier 4 implementation:
- #13+15: /mem0:dream skill — fetches all memories, finds near-dupes,
  proposes merges, flags contradictions, prunes stale entries per
  retention policy, outputs terminal diff for user approval
- #14: --schedule weekly registers cron via Claude Code schedule skill
  (Claude Code only, noted in docs)
- #16: parse_mem0_config.py parses ## Retention section from mem0.md
  (e.g. session_state: 90d, architecture_decisions: forever)
2026-05-21 17:43:14 +05:30
kartik-mem0 6ed5fcf76e feat(mem0-plugin): expand categories to 17, add recall rubric + confidence + files tagging
Tier 5 implementation:
- #17: Expand coding categories from 7 to 17 (dependency_decisions,
  performance_findings, security_constraints, testing_patterns, data_model,
  api_contracts, deployment_runbook, team_norms, domain_glossary,
  experiment_results)
- #18: Category-recall rubric table in mem0-mcp/SKILL.md mapping user
  intent to 2-3 categories for parallel search fan-out
- #19: metadata.confidence scoring rules (1.0/0.8/0.5/0.3 scale)
- #20: metadata.files array tagging for affected file paths
2026-05-21 17:43:01 +05:30
kartik-mem0 f6c3432e19 fix(mem0-plugin): fix userConfig schema and restore ${user_config.*} in .mcp.json
userConfig was missing required `type` and `title` fields per Claude Code
plugin docs — value was never collected, so ${user_config.MEM0_API_KEY}
could not resolve. Added both fields. Restored .mcp.json to use
${user_config.MEM0_API_KEY} for the MCP server Authorization header.

This completes the fix for #4876: plugin now prompts for API key at
install, stores in keychain, and MCP server reads it via user_config
interpolation — no shell env var needed.
2026-05-21 17:15:09 +05:30
kartik-mem0 15916a78e0 fix(mem0-plugin): revert .mcp.json to ${MEM0_API_KEY} env var
${user_config.*} interpolation not supported in HTTP-type MCP server
headers — only works for subprocess-based servers. Revert to shell
env var reference. The userConfig + resolve_api_key() fallback chain
still fixes hook scripts for issue #4876.
2026-05-21 17:13:11 +05:30
kartik-mem0 ff2f6c281a fix(mem0-plugin): resolve API key from userConfig, fix #4876
Root cause: .mcp.json required ${MEM0_API_KEY} shell env var which
OAuth flow never populates — MCP server never started, only auth
stubs were exposed.

Fix: switch .mcp.json to ${user_config.MEM0_API_KEY} so Claude Code
reads the key from plugin userConfig (system keychain). Add
resolve_api_key() fallback chain (MEM0_API_KEY -> CLAUDE_PLUGIN_OPTION_MEM0_API_KEY)
to all hook scripts. Update onboard skill and setup_coding_categories.py
to handle keychain-only users. Add 3 tests for key resolution.
2026-05-21 17:10:17 +05:30
kartik-mem0 66600565ed fix(mem0-plugin): thread cwd to resolvers, add write-path tests, userConfig for API key
- on_pre_compact.py and capture_compact_summary.py now read cwd from
  hook stdin and pass it to resolve_project_id/resolve_branch (monorepo safe)
- 5 new tests verify all write functions use app_id top-level and never
  put project_id in metadata
- plugin.json declares userConfig.MEM0_API_KEY so Claude Code prompts
  for the key at install time (sensitive, stored in system keychain)
2026-05-21 16:50:11 +05:30
kartik-mem0 cf6db6ef88 fix(mem0-plugin): use app_id entity scoping, fix tour, add branch tagging
Tour was filtering on metadata.type which missed platform-categorized
memories. Rewritten to use get_memories (list all) + group by platform
categories field. All write hooks now use app_id as top-level entity
param instead of metadata.project_id. Branch stamped on every write.
2026-05-21 16:38:48 +05:30
58 changed files with 5352 additions and 255 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.0",
"version": "0.2.1",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search to Claude workflows using the Mem0 Platform MCP server.",
"author": {
"name": "Mem0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.0",
"version": "0.2.1",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mem0",
"version": "0.2.0",
"version": "0.2.1",
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
"author": {
"name": "Mem0",
+20
View File
@@ -34,6 +34,26 @@
"timeout": 3
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_post_commit.sh",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CODEX_PLUGIN_ROOT}/scripts/on_bash_output.sh",
"timeout": 5
}
]
}
],
"Stop": [
+10
View File
@@ -17,6 +17,16 @@
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_tool_use_cursor.sh",
"matcher": "mcp__mem0__",
"timeout": 3
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_post_commit.sh",
"matcher": "Bash",
"timeout": 5
},
{
"command": "${CURSOR_PLUGIN_ROOT}/scripts/on_bash_output.sh",
"matcher": "Bash",
"timeout": 5
}
],
"preCompact": [
+99
View File
@@ -1,6 +1,29 @@
{
"hooks": {
"Setup": [
{
"matcher": "init|maintenance",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh",
"statusMessage": "Installing mem0 SDK...",
"timeout": 120
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/requirements.txt\" \"${CLAUDE_PLUGIN_DATA}/requirements.txt\" >/dev/null 2>&1 || \"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh\"",
"statusMessage": "Installing mem0 SDK...",
"timeout": 60
}
]
},
{
"matcher": "startup|resume|compact",
"hooks": [
@@ -21,6 +44,16 @@
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/block_memory_write.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_git_commit_capture.sh",
"timeout": 5
}
]
}
],
"PostToolUse": [
@@ -33,6 +66,26 @@
"timeout": 3
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_commit.sh",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_bash_output.sh",
"timeout": 5
}
]
}
],
"PreCompact": [
@@ -79,6 +132,52 @@
}
]
}
],
"PostToolUseFailure": [
{
"matcher": "mcp__mem0__",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_tool_failure.sh",
"timeout": 5
}
]
}
],
"PostCompact": [
{
"matcher": "manual|auto",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_post_compact.sh",
"timeout": 10
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_session_end.sh",
"timeout": 15
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/on_subagent_stop.sh",
"timeout": 5
}
]
}
]
}
}
+1
View File
@@ -0,0 +1 @@
mem0ai
+16 -5
View File
@@ -1,6 +1,10 @@
"""Resolve mem0 user_id.
"""Resolve mem0 identity: API key and user_id.
Resolution priority:
API key resolution (first non-empty wins):
1. MEM0_API_KEY env var (explicit / shell profile)
2. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (set by Claude Code userConfig)
User ID resolution:
1. MEM0_USER_ID env var (explicit override)
2. $USER, else "default"
"""
@@ -10,6 +14,13 @@ from __future__ import annotations
import os
def resolve_api_key() -> str:
key = os.environ.get("MEM0_API_KEY", "").strip()
if key:
return key
return os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "").strip()
def resolve_user_id() -> str:
explicit = os.environ.get("MEM0_USER_ID", "").strip()
if explicit:
@@ -20,10 +31,10 @@ def resolve_user_id() -> str:
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_project_id(cwd: str | None = None) -> str:
return os.path.basename(cwd or os.getcwd())
def resolve_branch() -> str:
def resolve_branch(cwd: str | None = None) -> str:
return "unknown"
def save_project_mapping(cwd: str, project_id: str) -> None:
+12 -2
View File
@@ -1,9 +1,19 @@
# Source this file. Sets MEM0_RESOLVED_USER_ID.
# Source this file. Sets MEM0_API_KEY and MEM0_RESOLVED_USER_ID.
#
# Resolution priority:
# API key resolution (first non-empty wins):
# 1. MEM0_API_KEY env var (explicit / shell profile)
# 2. CLAUDE_PLUGIN_OPTION_MEM0_API_KEY (set by Claude Code userConfig)
#
# User ID resolution:
# 1. MEM0_USER_ID env var (explicit override)
# 2. $USER, else "default"
# Resolve API key from userConfig fallback
if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}" ]; then
MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_MEM0_API_KEY"
export MEM0_API_KEY
fi
_mem0_resolve_identity() {
if [ -n "${MEM0_USER_ID:-}" ]; then
printf '%s' "$MEM0_USER_ID"
+44 -15
View File
@@ -21,8 +21,8 @@ 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
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id
log = logging.getLogger("mem0-auto-import")
log.setLevel(logging.DEBUG)
@@ -46,6 +46,21 @@ TARGET_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", "mem
HASH_STORE = os.path.expanduser("~/.mem0/file_hashes.json")
def _git_root(cwd: str) -> str:
"""Return the git repo root, or empty string if not in a repo."""
import subprocess
try:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=cwd, capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.TimeoutExpired):
pass
return ""
def sha256_file(path: str) -> str:
"""Return the hex SHA-256 digest of a file."""
h = hashlib.sha256()
@@ -77,8 +92,15 @@ def save_hashes(hashes: dict[str, str]) -> None:
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:
def post_memory(api_key: str, content: str, user_id: str, filename: str, project_id: str, branch: str = "") -> bool:
"""POST a project profile memory to the Mem0 REST API."""
metadata = {
"type": "project_profile",
"file": filename,
"source": "auto-import",
}
if branch:
metadata["branch"] = branch
body = {
"messages": [
{
@@ -87,18 +109,14 @@ def post_memory(api_key: str, content: str, user_id: str, filename: str, project
}
],
"user_id": user_id,
"metadata": {
"type": "project_profile",
"file": filename,
"project_id": project_id,
"source": "auto-import",
},
"app_id": project_id,
"metadata": metadata,
"infer": False,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{API_URL}/v1/memories/",
f"{API_URL}/v3/memories/add/",
data=data,
headers={
"Content-Type": "application/json",
@@ -120,7 +138,7 @@ def post_memory(api_key: str, content: str, user_id: str, filename: str, project
def main() -> None:
api_key = os.environ.get("MEM0_API_KEY", "")
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping auto-import")
return
@@ -128,16 +146,27 @@ def main() -> None:
cwd = os.environ.get("MEM0_CWD", "").strip() or os.getcwd()
user_id = resolve_user_id()
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
log.debug("Auto-import started: cwd=%s project=%s user=%s", cwd, project_id, user_id)
git_root = _git_root(cwd)
search_dirs = [cwd]
if git_root and os.path.realpath(git_root) != os.path.realpath(cwd):
search_dirs.append(git_root)
log.debug("Auto-import started: cwd=%s git_root=%s project=%s user=%s branch=%s", cwd, git_root or "(none)", project_id, user_id, branch)
hashes = load_hashes()
updated = False
for filename in TARGET_FILES:
filepath = os.path.join(cwd, filename)
filepath = ""
for search_dir in search_dirs:
candidate = os.path.join(search_dir, filename)
if os.path.isfile(candidate):
filepath = candidate
break
if not os.path.isfile(filepath):
if not filepath:
log.debug("Not found, skipping: %s", filename)
continue
@@ -169,7 +198,7 @@ def main() -> None:
log.debug("Cannot read %s: %s", filename, e)
continue
if post_memory(api_key, content, user_id, filename, project_id):
if post_memory(api_key, content, user_id, filename, project_id, branch):
hashes[hash_key] = current_hash
updated = True
# on API failure we don't update the hash — retry next session
+16 -13
View File
@@ -25,7 +25,7 @@ import urllib.request
from datetime import date, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id
log = logging.getLogger("mem0-compact-summary")
@@ -97,23 +97,25 @@ def find_compact_summary(lines: list[str]) -> str:
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()
metadata = {
"type": "compact_summary",
"source": "session-start-compact",
"session_id": session_id,
}
if branch:
metadata["branch"] = branch
body = {
"messages": [{"role": "user", "content": summary}],
"user_id": user_id,
"metadata": {
"type": "compact_summary",
"source": "session-start-compact",
"session_id": session_id,
"project_id": project_id,
"branch": branch,
},
"infer": False,
"app_id": project_id,
"metadata": metadata,
"infer": True,
"expiration_date": expires,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{API_URL}/v1/memories/",
f"{API_URL}/v3/memories/add/",
data=data,
headers={
"Content-Type": "application/json",
@@ -134,7 +136,7 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro
def main():
api_key = os.environ.get("MEM0_API_KEY", "")
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping capture")
return
@@ -151,9 +153,10 @@ def main():
return
session_id = hook_input.get("session_id", "")
cwd = hook_input.get("cwd") or None
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Install mem0ai SDK into a persistent venv inside CLAUDE_PLUGIN_DATA.
# Runs on SessionStart; skips if requirements.txt hasn't changed.
set -euo pipefail
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
DATA_DIR="${CLAUDE_PLUGIN_DATA:-${HOME}/.mem0/plugin-data}"
VENV_DIR="${DATA_DIR}/venv"
REQ_SRC="${PLUGIN_ROOT}/requirements.txt"
REQ_STAMP="${DATA_DIR}/requirements.txt"
mkdir -p "${DATA_DIR}"
needs_install=false
if [ ! -f "${VENV_DIR}/bin/python3" ]; then
needs_install=true
elif ! diff -q "${REQ_SRC}" "${REQ_STAMP}" >/dev/null 2>&1; then
needs_install=true
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}"
else
rm -f "${REQ_STAMP}"
echo "mem0 plugin: failed to install Python dependencies" >&2
exit 0
fi
fi
@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Import memories from competing AI tool configuration files into mem0.
Sub-commands (via sys.argv[1]):
cursorrules [--path .cursorrules]
copilot [--path .github/copilot-instructions.md]
cline [--path memory-bank/]
continue [--path .continue/rules.md]
Each sub-command reads configuration files from competing tools,
splits them into chunks, and POSTs each chunk to the mem0 API as a
project_profile memory.
Output: progress messages to stdout, errors to stderr
Exit: 0 always
"""
from __future__ import annotations
import json
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_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
# ---------------------------------------------------------------------------
def post_memory(api_key: str, content: str, user_id: str, project_id: str, branch: str, source: str) -> bool:
"""POST a single memory chunk to the mem0 API."""
metadata: dict = {
"type": "project_profile",
"source": source,
}
if branch:
metadata["branch"] = branch
body = {
"messages": [{"role": "user", "content": content}],
"user_id": user_id,
"app_id": project_id,
"metadata": metadata,
"infer": False,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{API_URL}/v3/memories/add/",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Token {api_key}",
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.status in (200, 201)
except urllib.error.URLError as e:
print(f" [warn] API call failed: {e}", file=sys.stderr)
return False
def import_chunks(chunks: list[str], api_key: str, user_id: str, project_id: str, branch: str, source: str) -> int:
"""Import a list of content chunks; return number of successful imports."""
success = 0
for chunk in chunks:
if post_memory(api_key, chunk, user_id, project_id, branch, source):
success += 1
return success
# ---------------------------------------------------------------------------
# Sub-command implementations
# ---------------------------------------------------------------------------
def _parse_path_arg(args: list[str], flag: str, default: str) -> str:
"""Extract --path <value> from args list, falling back to default."""
for i, arg in enumerate(args):
if arg == flag and i + 1 < len(args):
return args[i + 1]
if arg.startswith(f"{flag}="):
return arg[len(flag) + 1:]
return default
def cmd_cursorrules(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".cursorrules")
source = "cursor-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_headers(content, "## ")
# Fall back to treating the whole file as one chunk if no headers found
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
print(f"Imported {n} memories from {source} ({path})")
def cmd_copilot(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".github/copilot-instructions.md")
source = "copilot-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_headers(content, "## ")
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
print(f"Imported {n} memories from {source} ({path})")
def cmd_cline(args: list[str]) -> None:
dir_path = _parse_path_arg(args, "--path", "memory-bank/")
source = "cline-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isdir(dir_path):
print(f"Directory not found: {dir_path}", file=sys.stderr)
return
md_files = sorted(
f for f in os.listdir(dir_path) if f.endswith(".md")
)
if not md_files:
print(f"No .md files found in {dir_path}", file=sys.stderr)
return
total = 0
for filename in md_files:
filepath = os.path.join(dir_path, filename)
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read().strip()
if not content:
continue
chunks = filter_and_truncate([content])
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
total += n
print(f"Imported {total} memories from {source} ({dir_path})")
def cmd_continue(args: list[str]) -> None:
path = _parse_path_arg(args, "--path", ".continue/rules.md")
source = "continue-import"
api_key = resolve_api_key()
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
if not api_key:
print("Error: MEM0_API_KEY not set", file=sys.stderr)
return
if not os.path.isfile(path):
print(f"File not found: {path}", file=sys.stderr)
return
with open(path, encoding="utf-8", errors="replace") as f:
content = f.read()
raw_chunks = split_by_hr_or_headers(content)
if not raw_chunks:
raw_chunks = [content.strip()] if content.strip() else []
chunks = filter_and_truncate(raw_chunks)
n = import_chunks(chunks, api_key, user_id, project_id, branch, source)
print(f"Imported {n} memories from {source} ({path})")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
COMMANDS = {
"cursorrules": cmd_cursorrules,
"copilot": cmd_copilot,
"cline": cmd_cline,
"continue": cmd_continue,
}
def main() -> None:
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
available = ", ".join(COMMANDS.keys())
print("Usage: import_competing_tools.py <subcommand> [--path <path>]", file=sys.stderr)
print(f"Subcommands: {available}", file=sys.stderr)
sys.exit(0)
subcommand = sys.argv[1]
remaining_args = sys.argv[2:]
COMMANDS[subcommand](remaining_args)
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
sys.exit(0)
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Hook: PostToolUse (matcher: Bash)
#
# Scans bash command output for stack traces and error patterns.
# When found, injects a search rubric telling the agent to check mem0
# for prior occurrences of the same error.
#
# This complements on_user_prompt.sh (which catches errors in the user's
# typed message). This hook catches errors in COMMAND OUTPUT — e.g.,
# when `npm test` or `python script.py` fails with a traceback.
#
# Input: JSON on stdin with tool_name, tool_input, tool_output
# Output: Context injected into Claude's next response (exit 0)
set -uo pipefail
INPUT=$(cat)
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 2>/dev/null || echo "")
# Skip short output (< 50 chars unlikely to contain a real stack trace)
if [ ${#TOOL_RESULT} -lt 50 ]; then
exit 0
fi
# Skip if this is a git commit (handled by on_post_commit.sh)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
exit 0
;;
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
HAS_ERROR="true"
fi
if [ -z "$HAS_ERROR" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Extract error class/message (first matching line)
ERROR_LINE=$(echo "$TOOL_RESULT" | grep -iE '(Error:|Exception:|panic:|FAIL:|fatal:)' | head -1 | sed 's/^[[:space:]]*//' | cut -c1-120)
# Extract file paths from stack trace frames
TRACE_FILES=$(echo "$TOOL_RESULT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh))(:[0-9]+)?' | head -5 | sort -u)
# Build file list for display
FILE_DISPLAY=""
if [ -n "$TRACE_FILES" ]; then
FILE_DISPLAY=$(echo "$TRACE_FILES" | sed 's/^/ - /')
fi
USER_ID="$MEM0_RESOLVED_USER_ID"
cat <<EOF
## Error detected in command output
\`$COMMAND\` produced an error:
> $ERROR_LINE
EOF
if [ -n "$FILE_DISPLAY" ]; then
cat <<EOF
**Files in stack trace:**
$FILE_DISPLAY
EOF
fi
cat <<EOF
Search mem0 for prior occurrences — this error may have been seen before:
- \`search_memories(query="$(echo "$ERROR_LINE" | cut -c1-60)", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]})\`
- \`search_memories(query="$(echo "$ERROR_LINE" | cut -c1-60)", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "bug_fix"}}]})\`
EOF
if [ -n "$TRACE_FILES" ]; then
FIRST_FILE=$(echo "$TRACE_FILES" | head -1 | sed 's/:[0-9]*//')
cat <<EOF
- \`search_memories(query="$FIRST_FILE", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]})\`
EOF
fi
cat <<EOF
If mem0 returns relevant context, use it to debug faster.
If you solve this, store the fix as an \`anti_pattern\` or \`bug_fix\` memory for next time.
EOF
# Telemetry
python3 "$SCRIPT_DIR/telemetry.py" bash_error --error_detected 2>/dev/null &
exit 0
+85
View File
@@ -0,0 +1,85 @@
#!/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
#
# Input: JSON on stdin with tool_name, tool_input
# Output: JSON with additionalContext (relevant memories for the commit)
set -uo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
if [ -z "$COMMAND" ]; then
exit 0
fi
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
;;
*)
exit 0
;;
esac
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
API_KEY="${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-}}"
if [ -z "$API_KEY" ]; then
exit 0
fi
# Foreground: search for relevant memories about changed files
CHANGED_FILES=$(git diff --cached --name-only 2>/dev/null | head -10 | tr '\n' ', ' | sed 's/,$//')
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
CONTEXT=$(python3 -c "
import json, urllib.request, os
api_key = os.environ.get('MEM0_API_KEY', os.environ.get('CLAUDE_PLUGIN_OPTION_MEM0_API_KEY', ''))
user_id = '$USER_ID'
app_id = '$PROJECT_ID'
files = '$CHANGED_FILES'
first_file = files.split(',')[0].strip()
body = json.dumps({
'query': f'changes to {files}',
'filters': {'AND': [{'user_id': user_id}, {'app_id': app_id}]},
'top_k': 3,
}).encode()
req = urllib.request.Request(
'https://api.mem0.ai/v3/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())
memories = results if isinstance(results, list) else results.get('results', [])
if memories:
lines = ['## Pre-Commit Memory Check', '', 'Relevant memories for files being committed (' + files + '):', '']
for m in memories[:3]:
mid = m.get('id', '?')[:8]
text = m.get('memory', '')[:200]
cat = (m.get('metadata') or {}).get('type', 'unknown')
lines.append(f'- [{cat}] {text} [mem0:{mid}]')
lines.append('')
lines.append('Consider: does this commit introduce a learning worth saving? If so, suggest storing it after the commit completes.')
print('\\n'.join(lines))
except Exception:
pass
" 2>/dev/null || true)
if [ -n "$CONTEXT" ]; then
jq -nc --arg ctx "$CONTEXT" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
fi
exit 0
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Hook: PostToolUse (matcher: Bash)
#
# Fires AFTER a Bash tool call completes. When a git commit/merge/rebase
# just succeeded, surfaces 1-3 relevant memories from the changed files
# and prompts Claude to ask the user if this change should be stored as
# a learning.
#
# Input: JSON on stdin with tool_name, tool_input, tool_output
# Output: Context injected into Claude's next response (exit 0)
#
# This implements Spec #28 — interactive pre-commit memory check.
set -uo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null || echo "")
if [ -z "$COMMAND" ]; then
exit 0
fi
# Only trigger on git commit/merge/rebase commands
case "$COMMAND" in
*"git commit"*|*"git merge"*|*"git rebase"*)
;;
*)
exit 0
;;
esac
# Check if the command actually succeeded (look for commit hash in output)
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_output // ""' 2>/dev/null || echo "")
if echo "$TOOL_RESULT" | grep -qiE '(error|fatal|abort|nothing to commit|no changes)'; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
# No API key — skip interactive prompt
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
# Extract changed files from commit
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null | head -10 || echo "")
if [ -z "$CHANGED_FILES" ]; then
exit 0
fi
# Get the commit message
COMMIT_MSG=$(git log -1 --format='%s' HEAD 2>/dev/null || echo "")
# Build file list for display
FILE_LIST=$(echo "$CHANGED_FILES" | sed 's/^/ - /' | head -5)
FILE_COUNT=$(echo "$CHANGED_FILES" | wc -l | tr -d ' ')
cat <<EOF
## Commit captured — save as memory?
**Commit:** $COMMIT_MSG
**Files changed ($FILE_COUNT):**
$FILE_LIST
This commit may contain learnings worth preserving. Ask the user:
> **Save this change as a memory?** I can store it as one of:
> - \`decision\` — if an architectural or design choice was made
> - \`task_learning\` — if a strategy or approach worked well
> - \`anti_pattern\` — if this fixes a bug or avoids a known pitfall
> - \`convention\` — if this establishes a new pattern
>
> Type the category, or **skip** to move on.
If the user provides a category (or says yes/sure/go ahead):
1. Summarize the commit: what changed and why (from the diff + commit message)
2. Call \`add_memory\` with:
- \`messages=[{"role": "user", "content": "<your summary>"}]\`
- \`user_id="$MEM0_RESOLVED_USER_ID"\`
- \`app_id="$MEM0_PROJECT_ID"\`
- \`metadata={"type": "<chosen_category>", "branch": "$MEM0_BRANCH", "confidence": 0.8, "files": [<changed files>], "source": "post-commit"}\`
- \`infer=False\`
If the user says skip/no/nothing: proceed normally. Do NOT ask again for the same commit.
EOF
# Telemetry
python3 "$SCRIPT_DIR/telemetry.py" post_commit --files_count="$FILE_COUNT" 2>/dev/null &
exit 0
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Hook: PostCompact (matcher: manual|auto)
#
# Fires after context compaction completes. Injects a recovery prompt
# telling the agent to reload context from mem0.
#
# Input: JSON on stdin with trigger, messages_retained, messages_removed
# Output: Context injected into Claude's post-compaction context (exit 0)
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)"
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
TRIGGER=$(echo "$INPUT" | jq -r '.trigger // "auto"' 2>/dev/null || echo "auto")
RETAINED=$(echo "$INPUT" | jq -r '.messages_retained // "?"' 2>/dev/null || echo "?")
REMOVED=$(echo "$INPUT" | jq -r '.messages_removed // "?"' 2>/dev/null || echo "?")
# Telemetry (background)
python3 "$SCRIPT_DIR/telemetry.py" post_compact --trigger="$TRIGGER" --retained="$RETAINED" --removed="$REMOVED" 2>/dev/null &
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
USER_ID="${MEM0_RESOLVED_USER_ID:-$USER}"
PROJECT_ID="${MEM0_PROJECT_ID:-unknown}"
cat <<EOF
## Mem0 Post-Compaction Recovery
Compaction complete ($TRIGGER). $REMOVED messages removed, $RETAINED retained.
You lost most conversation history. Recover context NOW:
1. \`search_memories(query="session state current task", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "session_state"}}]})\`
2. \`search_memories(query="recent decisions and learnings", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$PROJECT_ID"}, {"metadata": {"type": "decision"}}]})\`
Run both in parallel. Use results to resume work without asking user to repeat context.
EOF
exit 0
+9 -1
View File
@@ -5,7 +5,7 @@
# 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
# Input: JSON on stdin with tool_name, tool_input, tool_output
# Output: none (exit 0, non-blocking)
set -uo pipefail
@@ -19,9 +19,17 @@ 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
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=add_memory 2>/dev/null &
;;
mcp__mem0__search_memories|mcp__mem0__get_memories)
python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null || true
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=search_memories 2>/dev/null &
;;
mcp__mem0__delete_memory)
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=delete_memory 2>/dev/null &
;;
mcp__mem0__update_memory)
python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=update_memory 2>/dev/null &
;;
esac
+135
View File
@@ -0,0 +1,135 @@
#!/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())
+43 -41
View File
@@ -23,7 +23,7 @@ import urllib.request
from datetime import date, timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _identity import resolve_user_id
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id
log = logging.getLogger("mem0-capture")
@@ -137,33 +137,21 @@ def parse_transcript(lines: list[str]) -> dict:
def build_content(state: dict, source: str) -> str:
"""Build structured markdown from parsed state."""
parts = [f"## Session State ({source})\n"]
"""Build minimal context — only what's needed to resume work.
This is a FALLBACK safety net, not the primary capture path.
The agent handles rich memory storage via on_pre_compact.sh prompts.
This script only fires when the agent didn't store enough on its own.
Keep it short — mem0 infer=True will extract structured facts.
"""
parts = []
if state["user_messages"]:
parts.append("### What the user was working on")
for msg in state["user_messages"]:
truncated = msg[:5000] + "..." if len(msg) > 5000 else msg
parts.append(f"- {truncated}")
parts.append("")
parts.append(f"Working on: {state['user_messages'][-1][:300]}")
if state["files_modified"]:
parts.append("### Files modified this session")
for fp in state["files_modified"]:
parts.append(f"- `{fp}`")
parts.append("")
if state["bash_commands"]:
parts.append("### Recent commands")
for cmd in state["bash_commands"]:
truncated = cmd[:1000] + "..." if len(cmd) > 1000 else cmd
parts.append(f"- `{truncated}`")
parts.append("")
if state["last_assistant_text"]:
parts.append("### Last context")
parts.append(state["last_assistant_text"])
parts.append("")
parts.append(f"Files touched: {', '.join(state['files_modified'][:15])}")
return "\n".join(parts)
@@ -171,24 +159,27 @@ def build_content(state: dict, source: str) -> str:
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()
metadata = {
"type": "session_state",
"source": source,
"session_id": session_id,
}
if branch:
metadata["branch"] = branch
body = {
"messages": [
{"role": "user", "content": content}
],
"user_id": user_id,
"metadata": {
"type": "session_state",
"source": source,
"session_id": session_id,
"project_id": project_id,
"branch": branch,
},
"app_id": project_id,
"metadata": metadata,
"expiration_date": expires,
"infer": True,
}
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
f"{API_URL}/v1/memories/",
f"{API_URL}/v3/memories/add/",
data=data,
headers={
"Content-Type": "application/json",
@@ -215,7 +206,7 @@ def main():
if arg.startswith("--source="):
source = arg.split("=", 1)[1]
api_key = os.environ.get("MEM0_API_KEY", "")
api_key = resolve_api_key()
if not api_key:
log.debug("MEM0_API_KEY not set, skipping capture")
return
@@ -232,9 +223,22 @@ def main():
return
session_id = hook_input.get("session_id", "")
cwd = hook_input.get("cwd") or None
user_id = resolve_user_id()
project_id = resolve_project_id()
branch = resolve_branch()
project_id = resolve_project_id(cwd)
branch = resolve_branch(cwd)
# Skip if agent already stored memories this session — avoid duplicate writes.
# This script is a fallback, not the primary capture path.
stats_file = f"/tmp/mem0_session_stats_{os.environ.get('USER', 'default')}.json"
try:
with open(stats_file) as f:
stats = json.load(f)
if stats.get("adds", 0) >= 2:
log.info("Agent stored %d memories this session — skipping fallback capture", stats["adds"])
return
except (OSError, json.JSONDecodeError):
pass # no stats = agent didn't store anything, proceed with fallback
lines = tail_lines(transcript_path, MAX_TAIL_LINES)
if not lines:
@@ -247,13 +251,11 @@ def main():
return
content = build_content(state, source)
if not content.strip():
log.debug("No content to store")
return
log.info(
"Capturing session state: %d user msgs, %d files, %d commands",
len(state["user_messages"]),
len(state["files_modified"]),
len(state["bash_commands"]),
)
log.info("Fallback capture: %d files modified", len(state["files_modified"]))
store_memory(api_key, content, user_id, source, session_id, project_id, branch)
+28 -41
View File
@@ -15,63 +15,50 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INPUT=$(cat)
python3 "$SCRIPT_DIR/telemetry.py" pre_compact 2>/dev/null &
cat <<'EOF'
## CRITICAL: Pre-Compaction Session Summary
## Pre-Compaction: Extract and store durable facts
Context compaction is about to happen. You are about to lose most of your conversation history. You MUST store a comprehensive session summary NOW using the mem0 `add_memory` tool.
Context compaction is about to happen. Review the conversation and store only facts that would help a future agent with ZERO context.
### Step 1: Store session summary
### What to store
Call `add_memory` with `infer=False` and a thorough summary covering ALL of the following.
For each fact, ask: "Would a new agent — with no prior context — benefit from knowing this?" If no, skip it. Most sessions produce 0-3 facts worth storing.
`infer=False` is critical here: you've already done the extraction work yourself using full context. Without it, the platform runs a second LLM pass that loses your structure and pulls fragmented facts. With it, your summary is preserved verbatim.
Store each fact as a SEPARATE `add_memory` call. One fact per call. 15-50 words each. Third person. Include file paths when relevant.
```
## Session Summary (Pre-Compaction)
Categories and when to use them:
- `decision` — architectural choices, trade-offs made ("Chose PostgreSQL over MongoDB for auth because of ACID requirements")
- `task_learning` — patterns that worked ("Running migrations before seed in this repo avoids FK violations")
- `anti_pattern` — approaches that failed ("Don't use batch insert for users table — triggers deadlock with audit log")
- `convention` — coding standards discovered ("This repo uses snake_case for all Python files, camelCase for TS")
- `user_preference` — how the user likes to work ("User prefers short PRs, one feature per branch")
### User's Goal
[What the user originally asked for and their intent]
### What NOT to store
### What Was Accomplished
[Numbered list of tasks completed, features built, bugs fixed]
- Session summaries or "what we did today" blobs
- Raw file lists or command histories
- Anything already stored in a prior `add_memory` this session
- One-time information that won't recur
- Transient state ("currently debugging X")
### Key Decisions Made
[Architectural choices, design decisions, trade-offs discussed]
### How to store
### Files Created or Modified
[List of important file paths with what changed in each]
### Current State
[What is in progress RIGHT NOW — the task you were in the middle of]
[Any pending items, blockers, or next steps]
### Important Context
[User preferences observed, coding patterns, anything that would help
the post-compaction agent continue without asking redundant questions]
```
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","project_id":"<the active project_id>","branch":"<the active branch>"},
messages=[{"role":"user","content":"<one fact, 15-50 words>"}],
user_id="<active user_id>",
app_id="<active project_id>",
metadata={"type":"<category>","branch":"<active branch>","confidence":0.8},
infer=False,
)
```
### Step 2: Store any unstored learnings
If there are learnings from this session that you haven't stored yet, store them as separate memories with `infer=False` (same reasoning -- you've already extracted the fact, don't re-extract):
- Failed approaches -> metadata `{"type": "anti_pattern"}`
- Successful strategies -> metadata `{"type": "task_learning"}`
- Architecture decisions -> metadata `{"type": "decision"}`
### Step 3: Acknowledge
After storing, briefly tell the user that session state has been saved and you're ready for compaction.
Do this NOW. Do not skip any section. The quality of this summary directly determines whether you can continue the user's task after compaction.
If nothing durable happened this session, store nothing. That is correct.
EOF
exit 0
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Hook: SessionEnd
#
# Fires when session actually terminates (after Stop).
# Last-chance capture: if on_stop.sh background REST call didn't complete,
# this fires a synchronous capture attempt.
#
# Input: JSON on stdin with session_id, transcript_path, cwd, reason
# Output: ignored (session is ending)
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)
REASON=$(echo "$INPUT" | jq -r '.reason // "other"' 2>/dev/null || echo "other")
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
# Print session-end report (last chance — Stop hook output may not render on /exit)
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
if [ -n "$REPORT" ] && [ "$REPORT" != "Session: no memory operations." ]; then
echo ""
echo "---"
echo "mem0 $REPORT"
echo "---"
# Append to persistent session log
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
# Telemetry (fire-and-forget — session dying, best-effort)
python3 "$SCRIPT_DIR/telemetry.py" session_end --reason="$REASON" 2>/dev/null &
# Clean up old capture markers (> 7 days)
find "$HOME/.mem0" -name ".captured_*" -mtime +7 -delete 2>/dev/null || true
exit 0
+72 -32
View File
@@ -15,12 +15,6 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
fi
# Skip the bootstrap entirely if no API key is configured -- the agent
# would otherwise be told to call mem0 MCP tools that will all fail.
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
@@ -31,6 +25,22 @@ 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")
# Skip bootstrap if no API key — still show banner but with "no key" status.
if [ -z "${MEM0_API_KEY:-}" ]; then
echo "## Mem0 Inactive"
echo ""
echo "\`user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | api_key=NOT_SET\`"
echo ""
echo "IMPORTANT: In your FIRST response to the user, you MUST display the status banner exactly as shown below (copy-paste it as your opening line before any other output):"
echo ""
echo '```'
echo "Mem0 Inactive | user=${MEM0_RESOLVED_USER_ID:-$USER} | project=${MEM0_PROJECT_ID:-unknown} | branch=${MEM0_BRANCH:-unknown} | api_key=NOT_SET"
echo '```'
echo ""
echo "Set MEM0_API_KEY to enable persistent memory. Get a key at https://app.mem0.ai/dashboard/api-keys"
exit 0
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
@@ -38,25 +48,21 @@ if command -v python3 >/dev/null 2>&1; then
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()
app_id = os.environ.get('MEM0_PROJECT_ID', '')
body = json.dumps({'filters': {'user_id': user_id, 'app_id': app_id}}).encode()
req = urllib.request.Request(
'https://api.mem0.ai/v2/memories/search/',
data=body,
'https://api.mem0.ai/v3/memories/?page=1&page_size=1',
headers={'Authorization': f'Token {api_key}', 'Content-Type': 'application/json'},
data=body,
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))
data = json.loads(r.read())
if isinstance(data, dict) and 'count' in data:
print(data['count'])
elif isinstance(data, list):
print(len(data))
else:
print('?')
except Exception:
@@ -72,14 +78,51 @@ echo "## Mem0 Active"
echo ""
echo "\`user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT\`"
echo ""
echo "Always include \`user_id\` + \`metadata.project_id\` in every \`search_memories\` filter and \`add_memory\` call:"
echo "IMPORTANT: In your FIRST response to the user, you MUST display the identity banner exactly as shown below (copy-paste it as your opening line before any other output):"
echo ""
echo '```'
echo "Mem0 Active | user=$MEM0_RESOLVED_USER_ID | project=$MEM0_PROJECT_ID | branch=$MEM0_BRANCH | memories=$MEM0_COUNT"
echo '```'
echo ""
echo "Always include \`user_id\` + \`app_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 "- app_id: \`$MEM0_PROJECT_ID\` (project scope — passed as top-level \`app_id\`, NOT in metadata)"
echo ""
# Load mem0.md project config if present (best-effort, non-blocking)
MEM0_PROJECT_CONFIG=""
MEM0_CWD_RESOLVED=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
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
echo "### Project Config (mem0.md)"
echo "\`\`\`json"
echo "$MEM0_PROJECT_CONFIG"
echo "\`\`\`"
echo ""
fi
if [ "$SOURCE" = "startup" ]; then
cat <<'EOF'
# First-run detection: auto-trigger onboarding for new projects
_SAFE_PID=$(printf '%s' "$MEM0_PROJECT_ID" | tr '/:' '--')
_ONBOARD_MARKER="$HOME/.mem0/.onboarded_${_SAFE_PID}"
if [ ! -f "$_ONBOARD_MARKER" ]; then
cat <<'EOF'
## Mem0 First Run — Automatic Onboarding
This is the first time mem0 is running for this project. You MUST run the onboarding wizard NOW before doing anything else.
Execute the `/mem0:onboard` skill immediately. This will:
1. Verify the API key
2. Detect and import project files (CLAUDE.md, AGENTS.md, .cursorrules, etc.)
3. Install coding categories optimized for development
4. Show a summary of what was set up
Do NOT skip this. Do NOT ask the user if they want to onboard. Just run `/mem0:onboard` now.
EOF
else
cat <<'EOF'
## Mem0 Session Bootstrap
You have access to persistent memory via the mem0 MCP tools. Before doing anything else:
@@ -90,6 +133,7 @@ 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
fi
# Auto-import declarative project files in background
MEM0_CWD="$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")" \
@@ -108,23 +152,19 @@ Continue where you left off.
EOF
elif [ "$SOURCE" = "compact" ]; then
# Capture the just-generated compact summary in the background.
# PreCompact fires too early to see this entry; SessionStart-compact
# is the first place isCompactSummary=true is in the transcript.
echo "$INPUT" | python3 "$SCRIPT_DIR/capture_compact_summary.py" 2>/dev/null &
cat <<'EOF'
## Mem0 Post-Compaction Recovery
Context was just compacted. The Claude Code-generated compact summary
is being captured to mem0 in the background as `metadata.type=compact_summary`.
Context was just compacted. Reload your context from mem0.
1. Call `search_memories` to reload context, layering up to three angles:
- `metadata.type=session_state` -- the rich pre-compaction summary you wrote
- `metadata.type=compact_summary` -- the platform-generated condensed summary just now
- `metadata.type=session_state` -- the pre-compaction summary you wrote before compaction
- `metadata.type=decision` / `anti_pattern` -- specific facts you stored during the session
2. Continue working from the recovered context.
EOF
fi
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null &
exit 0
+9 -5
View File
@@ -10,7 +10,8 @@
#
# IMPORTANT: Check stop_hook_active to avoid infinite loops.
set -euo pipefail
# Intentionally omit -e so the reminder always emits even if session_stats fails.
set -uo pipefail
if [ -n "${MEM0_DEBUG:-}" ]; then
mkdir -p "$HOME/.mem0" && exec 2>>"$HOME/.mem0/hooks.log"
@@ -25,6 +26,10 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi
# Telemetry: fire before report() deletes stats file
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Print session-end report
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
if [ -n "$REPORT" ]; then
@@ -35,7 +40,7 @@ if [ -n "$REPORT" ]; then
echo ""
fi
# Append to persistent session log (guarded — on_stop.sh uses set -euo pipefail)
# Append to persistent session log
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
@@ -54,10 +59,9 @@ Memories can be as detailed as needed — include full context, reasoning, code
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.
Always include `app_id` (the active project_id from SessionStart) as a top-level parameter in every `add_memory` call.
EOF
# 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 &
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null || echo "")
exit 0
+7 -1
View File
@@ -22,6 +22,8 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
INPUT=$(cat)
STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
@@ -31,6 +33,10 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
exit 0
fi
# Telemetry: fire before report() deletes stats file
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Session-end report (best-effort, must not break JSON output)
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
REPORT_BLOCK=""
@@ -51,7 +57,7 @@ ${REPORT_BLOCK}Before finishing, check if there are important learnings from thi
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.
Always include \`app_id\` (the active project_id from SessionStart) as a top-level parameter in every \`add_memory\` call.
If nothing notable happened in this interaction, it's fine to skip. Only store genuinely useful learnings.
EOF
+15 -1
View File
@@ -14,9 +14,23 @@ if [ -n "${MEM0_DEBUG:-}" ]; then
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
INPUT=$(cat)
# Guard against infinite loops: if this is a re-entry after a prior followup,
# let the turn end. Cursor exposes loop_count in the input JSON.
LOOP_COUNT=$(echo "$INPUT" | jq -r '.loop_count // 0' 2>/dev/null || echo "0")
if [ "$LOOP_COUNT" -gt 1 ]; then
echo '{}'
exit 0
fi
# Telemetry: fire before report() deletes stats file
_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0")
python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null &
# Session-end report (best-effort)
REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "")
REPORT_BLOCK=""
@@ -35,7 +49,7 @@ ${REPORT_BLOCK}Before finishing, check if there are important learnings from thi
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.
Always include \`app_id\` (the active project_id from session start) as a top-level parameter in every \`add_memory\` call.
If nothing notable happened, it's fine to skip. Only store genuinely useful learnings.
EOF
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Hook: SubagentStop
#
# Fires when a subagent finishes. Injects a reminder to capture any
# learnings the subagent produced that the parent agent should store.
#
# Input: JSON on stdin with agent_type, result_summary
# Output: Context injected into parent agent's context (exit 0)
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)
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // ""' 2>/dev/null || echo "")
RESULT_SUMMARY=$(echo "$INPUT" | jq -r '.result_summary // ""' 2>/dev/null || echo "")
# Skip short/empty results — nothing worth capturing
if [ ${#RESULT_SUMMARY} -lt 50 ]; then
exit 0
fi
# Skip explorer/plan agents — read-only, rarely produce storable learnings
case "$AGENT_TYPE" in
Explore|Plan)
exit 0
;;
esac
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
cat <<EOF
## Subagent completed: $AGENT_TYPE
Review the subagent result for learnings worth persisting to mem0.
If the subagent discovered something reusable (a fix, pattern, decision, or anti-pattern),
store it via \`add_memory\` with appropriate metadata type.
Only store if genuinely valuable — skip trivial subagent results.
EOF
exit 0
+9 -3
View File
@@ -7,18 +7,21 @@
# Input: JSON on stdin with task_id, task_subject, task_description
# Output: Text that becomes feedback to the model (exit 0)
set -euo pipefail
# Intentionally omit -e so the reminder always emits even if identity resolution fails.
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)"
. "$SCRIPT_DIR/_identity.sh" || true
. "$SCRIPT_DIR/_identity.sh" 2>/dev/null || true
INPUT=$(cat)
TASK_SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // "unknown task"' 2>/dev/null || echo "unknown task")
_PROJECT="${MEM0_PROJECT_ID:-unknown}"
cat <<EOF
Task completed: "$TASK_SUBJECT"
@@ -31,7 +34,10 @@ 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.
Include \`app_id\` = \`"$_PROJECT"\` as a top-level parameter in every \`add_memory\` call (not in metadata).
EOF
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" task_completed 2>/dev/null &
exit 0
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Hook: PostToolUseFailure (matcher: mcp__mem0__)
#
# Fires when a mem0 MCP tool call fails. Logs the failure, bumps telemetry,
# and injects a retry hint so Claude can recover.
#
# Input: JSON on stdin with tool_name, tool_input, tool_error
# Output: Context injected into Claude's next response (exit 0)
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)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null || echo "")
TOOL_RESULT=$(echo "$INPUT" | jq -r '.tool_error // ""' 2>/dev/null || echo "")
TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}' 2>/dev/null || echo "{}")
# Extract the short tool name (strip mcp__mem0__ prefix)
SHORT_NAME="${TOOL_NAME#mcp__mem0__}"
# Log failure to persistent file for debugging
mkdir -p "$HOME/.mem0" 2>/dev/null || true
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAIL $TOOL_NAME: $TOOL_RESULT" >> "$HOME/.mem0/tool-failures.log" 2>/dev/null || true
# Telemetry (background, fire-and-forget)
python3 "$SCRIPT_DIR/telemetry.py" tool_failure --tool="$SHORT_NAME" 2>/dev/null &
# Classify the error
IS_AUTH_ERROR=""
IS_RATE_LIMIT=""
IS_NETWORK_ERROR=""
if echo "$TOOL_RESULT" | grep -qiE '(401|403|unauthorized|forbidden|invalid.*token|invalid.*key)'; then
IS_AUTH_ERROR="true"
elif echo "$TOOL_RESULT" | grep -qiE '(429|rate.?limit|too many requests|quota)'; then
IS_RATE_LIMIT="true"
elif echo "$TOOL_RESULT" | grep -qiE '(timeout|connect|ECONNREFUSED|network|DNS|resolve)'; then
IS_NETWORK_ERROR="true"
fi
cat <<EOF
## mem0 tool failure: \`$SHORT_NAME\`
**Error:** $TOOL_RESULT
**Input:** \`$TOOL_INPUT\`
EOF
if [ -n "$IS_AUTH_ERROR" ]; then
cat <<'EOF'
**Cause:** Authentication failure. MEM0_API_KEY may be invalid or expired.
**Action:** Tell the user their API key needs to be checked. Get a new key at https://app.mem0.ai/dashboard/api-keys
Do NOT retry — it will fail again with the same key.
EOF
elif [ -n "$IS_RATE_LIMIT" ]; then
cat <<'EOF'
**Cause:** Rate limit hit.
**Action:** Wait a few seconds, then retry the same call. If it fails again, reduce the number of parallel mem0 calls.
EOF
elif [ -n "$IS_NETWORK_ERROR" ]; then
cat <<'EOF'
**Cause:** Network connectivity issue reaching mem0 API.
**Action:** Retry once. If it fails again, inform the user and continue without memory context.
EOF
else
cat <<EOF
**Action:** Retry the \`$SHORT_NAME\` call once. If it fails again, continue without memory context and inform the user.
EOF
fi
exit 0
+57 -11
View File
@@ -25,14 +25,35 @@ if [ ${#PROMPT} -lt 20 ]; then
exit 0
fi
# No API key means the agent can't search anyway
if [ -z "${MEM0_API_KEY:-}" ]; then
exit 0
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=_identity.sh
. "$SCRIPT_DIR/_identity.sh"
# 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
HAS_ERROR="true"
fi
# Detect file paths in the prompt (no API needed)
FILE_PATHS=$(echo "$PROMPT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh|yaml|yml|json|toml|md|sql|css|html))\b' 2>/dev/null | head -5 || echo "")
# Telemetry (background, fire-and-forget)
_TELEM_ARGS=""
[ -n "$HAS_ERROR" ] && _TELEM_ARGS="$_TELEM_ARGS --error_detected"
[ -n "$FILE_PATHS" ] && _TELEM_ARGS="$_TELEM_ARGS --file_paths_detected"
python3 "$SCRIPT_DIR/telemetry.py" user_prompt $_TELEM_ARGS 2>/dev/null &
# No API key — emit detections only, skip search rubric
if [ -z "${MEM0_API_KEY:-}" ]; then
if [ -n "$HAS_ERROR" ]; then
echo "**ERROR DETECTED in prompt.** Set MEM0_API_KEY to search past debugging context."
fi
if [ -n "$FILE_PATHS" ]; then
echo "**FILE PATHS detected:** \`$FILE_PATHS\`"
fi
exit 0
fi
USER_ID="$MEM0_RESOLVED_USER_ID"
cat <<EOF
@@ -53,6 +74,30 @@ improve your answer. The agent -- not this hook -- owns this decision.
- the user is *stating* new info -- that's a write trigger (\`add_memory\`), not a search
- it's a pure syntax / factual question answerable from general knowledge
- you already searched this scope earlier in the turn
EOF
if [ -n "$HAS_ERROR" ]; then
cat <<EOF
**ERROR DETECTED in prompt.** You SHOULD search mem0 for prior occurrences:
- \`search_memories(query="<error class or message>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]})\`
- \`search_memories(query="<module or file from stack trace>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "task_learning"}}]})\`
This surfaces past debugging context and known failure modes.
EOF
fi
if [ -n "$FILE_PATHS" ]; then
cat <<EOF
**FILE PATHS detected:** \`$FILE_PATHS\`
Search mem0 for context about these files using the \`contains\` operator on \`metadata.files\`:
- \`search_memories(query="<filename>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata.files": {"contains": "<filename>"}}]})\`
- Also run a broader text search without the files filter as fallback:
- \`search_memories(query="<filename without extension>", filters={"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]})\`
EOF
fi
cat <<EOF
**If searching, do it well:**
- Run **2-4 parallel** \`search_memories\` calls with different angles, not one
@@ -60,12 +105,13 @@ improve your answer. The agent -- not this hook -- owns this decision.
- Phrase queries as **nouns** ("auth module decisions"), not full sentences.
- 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": {"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.
Combine \`user_id\` + \`app_id\` with one \`metadata.type\` clause per call:
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "decision"}}]}\` -- design / architecture
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "anti_pattern"}}]}\` -- debugging, error handling
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "user_preference"}}]}\` -- tooling, stack, style
- \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}, {"metadata": {"type": "convention"}}]}\` -- established patterns
- Or scope with just \`{"AND": [{"user_id": "$USER_ID"}, {"app_id": "$MEM0_PROJECT_ID"}]}\` when no metadata filter fits.
- **Recency boost:** For state-related queries ("where were we", "current task", "latest"), add a \`created_at\` filter: \`{"created_at": {"gte": "<90 days ago YYYY-MM-DD>"}}\`. Skip recency for durable facts (conventions, decisions).
- Empty results are normal -- proceed without context.
EOF
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Parse a mem0 export file and output JSON.
Input: path to a mem0-export-*.md file (sys.argv[1])
Output: JSON array of memory records to stdout
Exit: 0 always
Each block in the file is delimited by lines containing exactly "---".
Blocks have a YAML-like frontmatter section (key: value lines) followed
by a blank line and the memory content text.
Example block format:
---
id: abc123
created_at: 2024-01-01T00:00:00Z
type: task_learnings
confidence: 0.9
branch: main
files: src/foo.py, src/bar.py
categories: coding_conventions, task_learnings
---
The actual memory content text goes here.
"""
from __future__ import annotations
import json
import re
import sys
def parse_blocks(content: str) -> list[dict]:
"""Split content on '---' boundaries and parse each block.
Returns a list of dicts with keys:
id, type, confidence, branch, files (list), categories (list), content (str)
Blocks with empty content are skipped.
Missing optional fields default to "" (scalar) or [] (list fields).
"""
# Normalise line endings
content = content.replace("\r\n", "\n").replace("\r", "\n")
# Split on lines that are exactly "---"
raw_blocks = re.split(r"(?m)^---\s*$", content)
# After splitting on "---", the structure for each memory is:
# raw_blocks[0] = preamble (before first ---, typically empty)
# raw_blocks[1] = frontmatter for block 1
# raw_blocks[2] = content for block 1
# raw_blocks[3] = frontmatter for block 2
# raw_blocks[4] = content for block 2
# ...
# So frontmatter blocks are at odd indices (1, 3, 5, ...) and
# content blocks at even indices (2, 4, 6, ...).
results: list[dict] = []
# Pair up frontmatter + content starting at index 1
i = 1
while i < len(raw_blocks):
frontmatter_raw = raw_blocks[i]
content_raw = raw_blocks[i + 1] if i + 1 < len(raw_blocks) else ""
# Parse the frontmatter key-value pairs
fm = _parse_frontmatter(frontmatter_raw)
# Strip leading/trailing whitespace from content
memory_content = content_raw.strip()
# Skip blocks with empty content
if not memory_content:
i += 2
continue
record = {
"id": fm.get("id", ""),
"type": fm.get("type", ""),
"confidence": fm.get("confidence", ""),
"branch": fm.get("branch", ""),
"files": _parse_list_field(fm.get("files", "")),
"categories": _parse_list_field(fm.get("categories", "")),
"content": memory_content,
}
# Include created_at if present
if "created_at" in fm:
record["created_at"] = fm["created_at"]
results.append(record)
i += 2
return results
def _parse_frontmatter(text: str) -> dict[str, str]:
"""Parse simple 'key: value' lines from frontmatter text.
Only the first colon is used as the delimiter — values may contain colons.
Lines not matching 'key: value' are ignored.
"""
result: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line:
continue
match = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$", line)
if match:
key = match.group(1).strip()
value = match.group(2).strip()
result[key] = value
return result
def _parse_list_field(value: str) -> list[str]:
"""Split a comma-separated value into a list, stripping whitespace.
Returns [] for empty/whitespace-only input.
"""
if not value or not value.strip():
return []
return [item.strip() for item in value.split(",") if item.strip()]
def main() -> None:
if len(sys.argv) < 2:
print("Usage: parse_export_file.py <path-to-export-file>", file=sys.stderr)
print("[]")
sys.exit(0)
filepath = sys.argv[1]
try:
if filepath == "-":
content = sys.stdin.read()
else:
with open(filepath, encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
print(f"Error reading file: {e}", file=sys.stderr)
print("[]")
sys.exit(0)
records = parse_blocks(content)
print(json.dumps(records, ensure_ascii=False, indent=2))
sys.exit(0)
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""Parse mem0.md project configuration file.
Reads the optional ``mem0.md`` file in a project directory and extracts
retention policies from a ``## Retention`` section.
Retention format (inside the section):
<category>: <N>d — keep for N days
<category>: forever — never prune (returned as None)
Usage (CLI):
python3 parse_mem0_config.py [<cwd>]
Prints a JSON object mapping category names to day counts (int) or null
(forever) on stdout. Prints ``{}`` when no mem0.md or no ## Retention
section is found.
"""
from __future__ import annotations
import json
import os
import re
import sys
def find_mem0_config(cwd: str) -> str | None:
"""Look for ``mem0.md`` in *cwd*.
Returns the absolute path to ``mem0.md`` if found, else ``None``.
"""
candidate = os.path.join(cwd, "mem0.md")
return candidate if os.path.isfile(candidate) else None
def parse_retention(content: str) -> dict[str, int | None]:
"""Parse the ``## Retention`` section of *content*.
Scans for a heading that matches ``## Retention`` (case-insensitive),
then reads lines until the next ``##``-level heading or end of string.
Each non-blank, non-comment line inside the section is expected to be::
<category>: <N>d → days=N (int)
<category>: forever → days=None
Malformed lines are silently skipped.
Args:
content: Full text of a mem0.md file.
Returns:
Dict mapping category name (str) to day count (int) or ``None``
(forever). Empty dict when no ``## Retention`` section is found.
"""
# Find the ## Retention section (allow any amount of trailing whitespace /
# extra words, but the heading must start with "## Retention").
section_match = re.search(
r"^##\s+Retention[^\n]*\n(.*?)(?=^##\s|\Z)",
content,
flags=re.MULTILINE | re.DOTALL | re.IGNORECASE,
)
if not section_match:
return {}
section_text = section_match.group(1)
policies: dict[str, int | None] = {}
for line in section_text.splitlines():
# Strip comments and whitespace
line = re.sub(r"#.*$", "", line).strip()
if not line:
continue
# Match "<category>: <value>"
line_match = re.match(r"^([^:]+):\s*(.+)$", line)
if not line_match:
continue
category = line_match.group(1).strip()
value = line_match.group(2).strip().lower()
if value == "forever":
policies[category] = None
else:
days_match = re.match(r"^(\d+)d$", value)
if days_match:
policies[category] = int(days_match.group(1))
# else: malformed value — skip silently
return policies
def parse_section_kv(content: str, heading: str) -> dict[str, str]:
"""Parse a key-value section from mem0.md.
Looks for ``## <heading>`` (case-insensitive) and reads ``key: value``
lines until the next ``##``-level heading or end of string.
"""
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return {}
result: dict[str, str] = {}
for line in match.group(1).splitlines():
line = re.sub(r"#.*$", "", line).strip()
if not line:
continue
m = re.match(r"^([^:]+):\s*(.+)$", line)
if m:
result[m.group(1).strip()] = m.group(2).strip()
return result
def parse_section_list(content: str, heading: str) -> list[str]:
"""Parse a list section from mem0.md.
Looks for ``## <heading>`` and reads ``- item`` or bare lines.
"""
pattern = rf"^##\s+{re.escape(heading)}[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return []
items: list[str] = []
for line in match.group(1).splitlines():
line = re.sub(r"#.*$", "", line).strip()
line = re.sub(r"^[-*]\s+", "", line).strip()
if line:
items.append(line)
return items
def parse_ignore_patterns(content: str) -> list[str]:
"""Parse the ``## Ignore`` section of *content*.
Each non-blank line is a glob pattern (e.g., ``node_modules``, ``*.lock``).
Lines starting with ``#`` are comments and skipped.
"""
pattern = r"^##\s+Ignore[^\n]*\n(.*?)(?=^##\s|\Z)"
match = re.search(pattern, content, flags=re.MULTILINE | re.DOTALL | re.IGNORECASE)
if not match:
return []
patterns: list[str] = []
for line in match.group(1).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
line = re.sub(r"^[-*]\s+", "", line).strip()
if line:
patterns.append(line)
return patterns
def load_full_config(cwd: str | None = None) -> dict:
"""Load all config sections from mem0.md.
Returns a dict with keys: retention, search, categories, identity,
ignore, project_id.
Each is populated only if the corresponding ``##`` section exists.
"""
if cwd is None:
cwd = os.getcwd()
config_path = find_mem0_config(cwd)
if config_path is None:
return {}
try:
with open(config_path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return {}
config: dict = {}
retention = parse_retention(content)
if retention:
config["retention"] = retention
search = parse_section_kv(content, "Search")
if search:
config["search"] = search
categories = parse_section_list(content, "Categories")
if categories:
config["categories"] = categories
config["default_categories"] = categories
identity = parse_section_kv(content, "Identity")
if identity:
config["identity"] = identity
if "project_id" in identity:
config["project_id"] = identity["project_id"]
ignore = parse_ignore_patterns(content)
if ignore:
config["ignore"] = ignore
return config
def load_retention_policies(cwd: str | None = None) -> dict[str, int | None]:
"""Load retention policies from the mem0.md in *cwd*.
Combines :func:`find_mem0_config` and :func:`parse_retention` into a
single convenience function.
"""
if cwd is None:
cwd = os.getcwd()
config_path = find_mem0_config(cwd)
if config_path is None:
return {}
try:
with open(config_path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
return {}
return parse_retention(content)
def main() -> int:
"""CLI entry point.
With ``--full``, prints the complete config. Without it, prints only
retention policies (backward-compatible).
"""
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()
if full_mode:
config = load_full_config(cwd)
else:
config = load_retention_policies(cwd)
print(json.dumps(config))
return 0
if __name__ == "__main__":
sys.exit(main())
+45 -7
View File
@@ -28,7 +28,13 @@ def _load() -> dict:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()}
return {
"adds": 0,
"searches": 0,
"categories": [],
"category_counts": {},
"started": datetime.now().isoformat(),
}
def _save(stats: dict) -> None:
@@ -36,15 +42,33 @@ def _save(stats: dict) -> None:
json.dump(stats, f)
MAX_RECENT_IDS = 50
def init() -> None:
_save({"adds": 0, "searches": 0, "categories": [], "started": datetime.now().isoformat()})
_save({
"adds": 0,
"searches": 0,
"categories": [],
"category_counts": {},
"recent_ids": [],
"started": datetime.now().isoformat(),
})
def record_add(category: str = "") -> None:
def record_add(category: str = "", memory_id: 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)
if category:
if category not in stats.get("categories", []):
stats.setdefault("categories", []).append(category)
counts = stats.setdefault("category_counts", {})
counts[category] = counts.get(category, 0) + 1
if memory_id:
recent = stats.setdefault("recent_ids", [])
recent.append({"id": memory_id, "category": category, "ts": datetime.now().isoformat()})
if len(recent) > MAX_RECENT_IDS:
stats["recent_ids"] = recent[-MAX_RECENT_IDS:]
_save(stats)
@@ -54,6 +78,12 @@ def record_search() -> None:
_save(stats)
def peek() -> str:
"""Return current stats as JSON without clearing the file."""
stats = _load()
return json.dumps(stats)
def report() -> str:
stats = _load()
adds = stats.get("adds", 0)
@@ -70,7 +100,12 @@ def report() -> str:
return ""
parts = []
parts.append(f"Session: wrote {adds} memories, retrieved {searches}")
category_counts = stats.get("category_counts", {})
if category_counts:
breakdown = ", ".join(f"{c} {n}" for c, n in sorted(category_counts.items(), key=lambda x: -x[1]))
parts.append(f"Session: wrote {adds} memories ({breakdown}), retrieved {searches}")
else:
parts.append(f"Session: wrote {adds} memories, retrieved {searches}")
if categories:
parts.append(f"Categories touched: {', '.join(categories)}")
@@ -87,9 +122,12 @@ def main() -> int:
init()
elif cmd == "add":
category = sys.argv[2] if len(sys.argv) > 2 else ""
record_add(category)
memory_id = sys.argv[3] if len(sys.argv) > 3 else ""
record_add(category, memory_id)
elif cmd == "search":
record_search()
elif cmd == "peek":
print(peek())
elif cmd == "report":
result = report()
if result:
+84 -10
View File
@@ -5,15 +5,14 @@ mem0 auto-tags every memory with one or more `categories`. By default the list
is consumer-oriented (food, hobbies, music, ...), which is meaningless for code.
This script replaces the project's category list with a coding-focused one.
The change is project-level (per the platform docs, per-request overrides are
not supported on the managed API). Run once per project; future memories will
be tagged using the new list automatically.
Uses the mem0ai SDK (client.project.update). The SDK is installed into a
persistent venv at ${CLAUDE_PLUGIN_DATA}/venv by the ensure_deps.sh hook.
Usage:
python setup_coding_categories.py # dry-run: show current vs proposed, no changes
python setup_coding_categories.py # dry-run: show current vs proposed
python setup_coding_categories.py --apply # actually call project.update()
Requires the mem0ai Python SDK and MEM0_API_KEY to be set.
Requires MEM0_API_KEY (or CLAUDE_PLUGIN_OPTION_MEM0_API_KEY).
"""
from __future__ import annotations
@@ -23,6 +22,19 @@ import json
import os
import sys
_script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _script_dir)
from _identity import resolve_api_key # noqa: E402
_plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", os.path.join(_script_dir, ".."))
_data_dir = os.environ.get("CLAUDE_PLUGIN_DATA", os.path.join(os.path.expanduser("~"), ".mem0", "plugin-data"))
_venv_site = os.path.join(_data_dir, "venv", "lib")
if os.path.isdir(_venv_site):
for d in sorted(os.listdir(_venv_site)):
sp = os.path.join(_venv_site, d, "site-packages")
if os.path.isdir(sp) and sp not in sys.path:
sys.path.insert(1, sp)
CODING_CATEGORIES = [
{
"architecture_decisions": (
@@ -66,6 +78,66 @@ CODING_CATEGORIES = [
"and ways of working."
)
},
{
"dependency_decisions": (
"Why specific libraries, frameworks, or package versions were chosen or replaced, "
"including the alternatives considered and the reasoning behind the selection."
)
},
{
"performance_findings": (
"Profiling results, bottlenecks identified, optimisations applied, and measurable "
"improvements achieved -- useful for avoiding regressions and guiding future work."
)
},
{
"security_constraints": (
"Security requirements, authentication and authorisation rules, data-handling "
"constraints, compliance obligations, and known threat mitigations in effect."
)
},
{
"testing_patterns": (
"Test strategies, frameworks chosen, coverage targets, fixture patterns, mocking "
"approaches, and how the test suite is structured for this project."
)
},
{
"data_model": (
"Schema definitions, database column semantics, domain object relationships, "
"field constraints, and how data flows between storage and application layers."
)
},
{
"api_contracts": (
"API endpoint shapes, request and response schemas, authentication requirements, "
"versioning policy, and any breaking-change commitments or deprecation timelines."
)
},
{
"deployment_runbook": (
"How to build, release, deploy, and roll back the project. CI/CD pipeline steps, "
"environment-specific configuration, and on-call runbook entries."
)
},
{
"team_norms": (
"Team working agreements, PR review etiquette, branching strategy, on-call "
"rotation, and other social or process conventions the team has agreed on."
)
},
{
"domain_glossary": (
"Domain-specific terms, abbreviations, and acronyms with their precise meanings "
"in this project -- prevents misunderstandings across code, docs, and discussion."
)
},
{
"experiment_results": (
"Results from A/B tests, feature-flag experiments, spikes, or proof-of-concept "
"work -- what was tried, what was measured, and what conclusion was reached."
)
},
]
@@ -87,17 +159,19 @@ def main() -> int:
)
args = ap.parse_args()
if not os.environ.get("MEM0_API_KEY"):
print("ERROR: MEM0_API_KEY is not set. Export it and try again.", file=sys.stderr)
api_key = resolve_api_key()
if not api_key:
print("ERROR: MEM0_API_KEY is not set. Export it or configure it via plugin userConfig.", file=sys.stderr)
return 1
os.environ["MEM0_API_KEY"] = api_key
try:
from mem0 import MemoryClient
except ImportError:
print(
"ERROR: the mem0ai Python SDK is not installed.\n"
"Install with: pip install mem0ai\n"
"Then re-run this script.",
"ERROR: mem0ai SDK not found. The plugin's ensure_deps.sh hook should\n"
"install it automatically on session start. Try restarting Claude Code,\n"
"or run manually: pip install mem0ai",
file=sys.stderr,
)
return 1
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Lightweight fire-and-forget telemetry for the mem0 plugin.
Sends anonymous usage events to PostHog using the same project key and
endpoint as the mem0 Python SDK and CLI. No posthog library dependency —
uses stdlib urllib directly (same pattern as cli/python telemetry_sender.py).
CLI usage (called from hooks as a background subprocess):
python3 telemetry.py <event_type> [--memory_count=N] [--categories_count=N]
[--error_detected] [--file_paths_detected]
[--source=<src>] [--tool=<name>]
Opt-out: set MEM0_TELEMETRY=false (or 0/no/off) to disable all telemetry.
Never sends: user content, memory content, API keys, raw user/project IDs.
Only sends: event type, platform, plugin version, anonymized hashes, counts.
"""
from __future__ import annotations
import hashlib
import json
import os
import platform
import random
import sys
import urllib.error
import urllib.request
PLUGIN_VERSION = "0.2.1"
POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX"
POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/"
REQUEST_TIMEOUT = 2
# All events sampled at 10% to keep PostHog costs predictable.
SAMPLE_RATE = 0.1
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _distinct_id() -> str:
"""Stable anonymous ID: MD5 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()
user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown"
return _sha256(user_id)
def detect_platform() -> str:
if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"):
return "claude-code"
if os.environ.get("CURSOR_PLUGIN_ROOT"):
return "cursor"
if os.environ.get("CODEX_PLUGIN_ROOT"):
return "codex"
return "unknown"
def is_enabled() -> bool:
return os.environ.get("MEM0_TELEMETRY", "true").lower() not in ("false", "0", "no", "off")
def _should_sample() -> bool:
return random.random() < SAMPLE_RATE
def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict:
project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown"
return {
"api_key": POSTHOG_API_KEY,
"distinct_id": _distinct_id(),
"event": event_name,
"properties": {
"source": "plugin",
"platform": detect_platform(),
"plugin_version": PLUGIN_VERSION,
"project_hash": _sha256(project_id),
"os": sys.platform,
"os_version": platform.version(),
"sample_rate": SAMPLE_RATE,
"$process_person_profile": False,
"$lib": "posthog-python",
**(properties or {}),
},
}
def send(payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
POSTHOG_HOST,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT):
pass
except Exception:
pass
def emit(event_type: str, properties: dict | None = None) -> None:
if not is_enabled():
return
event_name = f"plugin.{event_type}"
if not _should_sample():
return
send(build_posthog_payload(event_name, properties))
def main() -> int:
if not is_enabled():
return 0
if len(sys.argv) < 2:
return 1
event_type = sys.argv[1]
properties: dict = {}
for arg in sys.argv[2:]:
if arg.startswith("--memory_count="):
try:
properties["memory_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
elif arg.startswith("--categories_count="):
try:
properties["categories_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
elif arg == "--error_detected":
properties["error_detected"] = True
elif arg == "--file_paths_detected":
properties["file_paths_detected"] = True
elif arg.startswith("--source="):
properties["source_detail"] = arg.split("=", 1)[1]
elif arg.startswith("--tool="):
properties["tool"] = arg.split("=", 1)[1]
elif arg.startswith("--files_count="):
try:
properties["files_count"] = int(arg.split("=", 1)[1])
except ValueError:
pass
emit(event_type, properties)
return 0
if __name__ == "__main__":
sys.exit(main())
+123
View File
@@ -0,0 +1,123 @@
---
name: mem0-digest
description: >
Summarize recent memory activity for the current project. Shows new memories,
categories touched, and growth trends over the past 7 days.
TRIGGER: user runs /mem0:digest, or asks "weekly summary", "what's new in memory",
"mem0 digest", "memory recap".
---
# Mem0 Weekly Digest
Summarize recent memory activity for the current project.
## Execution
### Step 1: Fetch recent memories
Call `search_memories` in parallel with different time-scoped queries:
1. `query="decisions made this week"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"created_at": {"gte": "<7 days ago YYYY-MM-DD>"}}]}`, `limit=20`
2. `query="bugs errors fixes"`, same time filter, `limit=20`
3. `query="patterns conventions learnings"`, same time filter, `limit=20`
Also call `get_memories` with `user_id` + `app_id` to get the full count for comparison.
### Step 2: Deduplicate and analyze
Merge results by memory ID. For each memory, extract:
- `created_at` date
- `categories[0]` or `metadata.type`
- First 100 chars of content
Group into:
- **New this week** (created in last 7 days)
- **Older** (created before, but surfaced by search)
Calculate:
- Total memories in project
- Memories added in last 7 days
- Most active categories this week
- Days with most activity
### Step 3: Display
```
## mem0 Weekly Digest — <project_id>
Period: <start_date> to <today>
### New Memories This Week: <N>
<category>: <count>
- <memory summary, 80 chars> (<date>)
- ...
<category>: <count>
- ...
### Activity Pattern
Most active day: <day> (<N> memories)
Categories touched: <list>
### Project Totals
Total memories: <N> (up <N> from last week)
Top categories: <top 3 by count>
### Highlights
<2-3 sentence summary of the most important decisions, learnings, or patterns stored this week>
```
### Step 4: Write digest to file
After displaying, write the digest to `~/.mem0/weekly-digest.md` for persistence
and external consumption (email, Slack, etc.):
```bash
mkdir -p ~/.mem0
```
Write the full digest output (same markdown shown in terminal) to `~/.mem0/weekly-digest.md`
using the Write tool. **Overwrite** the file each time — it always contains the latest digest.
Also append a one-line summary to `~/.mem0/digest-history.log` for trend tracking:
```bash
echo "<YYYY-MM-DD> | <project_id> | +<new_count> memories | top: <top_category>" >> ~/.mem0/digest-history.log
```
Print at the end:
```
Digest saved to ~/.mem0/weekly-digest.md
```
### Step 5: Schedule recurring digests
When invoked with `--schedule` (e.g., `/mem0:digest --schedule weekly`), register
a cloud routine via Claude Code's `/schedule` command:
```
/schedule <frequency> /mem0:digest
```
For example:
- `/schedule weekly on Monday 9am /mem0:digest` — digest every Monday morning
- `/schedule daily at 8am /mem0:digest` — daily digest
Print:
```
Digest scheduled: <frequency>
Manage at: https://claude.ai/code/routines
```
If `/schedule` is unavailable, print a cron one-liner the user can install manually:
```bash
# macOS/Linux — weekly Monday 9am
(crontab -l 2>/dev/null; echo "0 9 * * 1 cd PROJECT_DIR && claude -p '/mem0:digest' >> /tmp/mem0-digest.log 2>&1") | crontab -
```
### Step 6: Empty state
If no memories in the last 7 days:
```
No new memories in the past week for <project_id>.
Total project memories: <N>.
Tip: mem0 captures learnings automatically as you work. Start coding!
```
+344
View File
@@ -0,0 +1,344 @@
---
name: mem0-dream
description: >
Memory consolidation pass. Fetches all project memories, finds near-duplicates,
merges them, flags contradictions, prunes stale entries per retention policy.
Outputs a diff for user approval before applying changes.
TRIGGER: user runs /mem0:dream, or asks "consolidate memories", "clean up memories",
"merge duplicate memories", "run dream".
---
# Mem0 Dream — Memory Consolidation
This skill performs a memory consolidation pass: it fetches all project memories,
identifies near-duplicates, flags contradictions, and prunes stale entries based on
configured retention policies. All proposed changes are shown as a diff for user
approval before anything is modified.
---
## Step 1: Load Retention Policies
Determine the active retention policy by running the parser script. Use the
appropriate `PLUGIN_ROOT` variable for the current platform (`${CLAUDE_PLUGIN_ROOT}`,
`${CODEX_PLUGIN_ROOT}`, or `${CURSOR_PLUGIN_ROOT}`):
```bash
python3 "<PLUGIN_ROOT>/scripts/parse_mem0_config.py" "<cwd>"
```
Parse the JSON output (a dict of `category → days | null`). If the script fails
or returns `{}`, fall back to these built-in defaults:
| `metadata.type` | Default retention |
|---|---|
| `session_state` | 90 days |
| `compact_summary` | 90 days |
| all others | no pruning |
Store the resolved policies for use in Step 3.
---
## Step 2: Fetch ALL Project Memories
Call `get_memories` to retrieve every memory for the active project:
```python
get_memories(
user_id="<active_user_id>",
app_id="<active_project_id>",
page_size=200,
)
```
If the response indicates more pages exist, paginate until all memories are fetched.
Collect the full list before proceeding. If zero memories are found, print:
```
No memories found for project <project_id>. Nothing to consolidate.
```
…and stop.
---
## Step 3: Analyze — Find Issues
Work entirely in-memory; do not modify anything yet.
Group memories by `metadata.type` (use `"unknown"` when the field is absent).
For each group, identify the following:
### 3a. Near-duplicate pairs (merge candidates)
Two memories are near-duplicates when they express the same fact or decision but
phrased differently (e.g., "Use PostgreSQL for auth" and "Auth DB is PostgreSQL").
Heuristics — two memories are near-duplicates if **all** of these hold:
- Similarity threshold: estimated cosine similarity > 0.9 (use noun/keyword overlap as proxy — if >60% of significant nouns overlap, treat as >0.9 similarity).
- Same `metadata.type`.
- Neither memory is pinned (`metadata.pinned != true`).
For each qualifying pair, draft a merged version that is more complete and specific
than either original.
### 3b. Contradictions
Two memories contradict when they assert opposing facts about the same topic
(e.g., "Deploy to ECS" vs. "Deploy to Vercel").
Identify the likely winner: the more recent memory with higher confidence wins.
Store both IDs and their content for user review.
### 3c. Prune candidates
A memory is a prune candidate when **any** of the following is true:
1. Its `metadata.type` has a retention policy and the memory is older than the
configured number of days (compare `created_at` to today).
2. Its confidence score is below 0.3 AND it contains no information unique to
this project (no file paths, identifiers, or domain-specific nouns).
**Always skip memories where `metadata.pinned == true`**, regardless of age or
confidence.
---
## Step 4: Print Diff Report (item 15)
Print a structured diff to the terminal before making any changes. Use exactly
this format:
```
## Dream — Memory Consolidation Report
### Merge proposals (<N> pairs)
MERGE [mem0:<id1>] + [mem0:<id2>] → NEW
- Original 1: "<content of memory 1, truncated to 120 chars>"
- Original 2: "<content of memory 2, truncated to 120 chars>"
- Merged: "<drafted merged content>"
### Contradictions (<N> pairs)
CONFLICT [mem0:<idA>] vs [mem0:<idB>]
- A: "<content>" (<created_at date>, confidence: <score>)
- B: "<content>" (<created_at date>, confidence: <score>)
Which is current? [A/B/skip]
### Prune candidates (<N> memories)
PRUNE [mem0:<id>] — <metadata.type>, <age>d old (policy: <policy_days>d)
---
Proposed: <N> merges, <N> prunes, <N> conflicts
Apply? [Y/n]
```
If there are zero items in any category, omit that section entirely.
If there are zero total proposals (no merges, no prunes, no conflicts), print:
```
Dream complete. No duplicate, contradictory, or stale memories found.
```
…and stop.
---
## Step 5: Wait for User Input and Apply
### 5a. Contradictions
For each `CONFLICT` pair in the report, wait for the user to type `A`, `B`, or
`skip` (case-insensitive). If they enter nothing (empty), treat as `skip`.
Record the winner for each pair before proceeding to the final apply confirmation.
### 5b. Final confirmation
After all conflict resolutions are collected, prompt:
```
Apply? [Y/n]
```
If the user types `n` or `no` (case-insensitive), print `Cancelled. No changes made.`
and stop.
If the user confirms (`Y`, `yes`, or empty / Enter), apply all changes in this order:
#### Merges
For each approved merge pair:
1. `delete_memory(<id1>)`
2. `delete_memory(<id2>)`
3. `add_memory` with:
- `messages=[{"role": "user", "content": "<merged content>"}]`
- `user_id=<active_user_id>`
- `app_id=<active_project_id>` (top-level, not in metadata)
- `metadata={"type": "<original type>", "branch": "<active_branch>", "confidence": <higher of the two original scores>, "source": "mem0-dream"}`
- `infer=False`
#### Contradictions (resolved)
For each resolved conflict where the user chose A or B:
- Identify the loser (the non-chosen memory).
- First call `get_memory(<loser_id>)` to read its current text content.
- Then call `update_memory(<loser_id>, data=<original_text_content>)` to preserve the text while updating it.
- **Important:** `update_memory` requires the `data` (text) parameter. A metadata-only call may error or wipe the content. Always read first, then update with the original text.
Contradictions where the user chose `skip` are left untouched.
#### Prunes
For each prune candidate:
- `delete_memory(<memory_id>)`
---
## Step 6: Print Summary
After all changes are applied, print:
```
Dream complete.
Merged: <N> pairs → <N> new memories
Pruned: <N> memories deleted
Flagged: <N> contradictions resolved, <N> skipped
```
---
## Auto mode
When invoked with `--auto` (e.g., `/mem0:dream --auto`), run non-interactively:
- **Merges**: applied automatically (no contradiction, both are compatible).
- **Prunes**: applied automatically (age/confidence-based, no ambiguity).
- **Contradictions**: skipped — they require human judgment.
In auto mode:
1. Load policies and fetch memories (Steps 1–3) as normal.
2. Apply merges and prunes silently without printing the diff or prompting.
3. Print a compact summary:
```
[mem0-dream --auto] project=<id> merged=<N> pruned=<N> conflicts_skipped=<N>
```
4. If contradictions were detected but skipped, store a reminder memory:
```python
add_memory(
messages=[{"role": "user", "content": "mem0-dream detected <N> contradiction(s) requiring manual review. Run /mem0:dream to resolve them interactively."}],
user_id="<active_user_id>",
app_id="<active_project_id>",
metadata={"type": "task_learning", "source": "mem0-dream-auto", "branch": "<active_branch>"},
infer=False,
)
```
## Scheduling recurring dreams
When invoked with `--schedule` (e.g., `/mem0:dream --schedule weekly`), register a
cloud routine via Claude Code's built-in `/schedule` command so the dream runs
automatically without any local cron or launchd setup.
### Step S1: Parse schedule frequency
Accept natural-language frequency after `--schedule`:
| User input | Cron equivalent | Description |
|---|---|---|
| `weekly` or `--schedule weekly` | Every Sunday 3:00 AM local | Default weekly consolidation |
| `daily` | Every day 3:00 AM local | For high-volume projects |
| `biweekly` | Every other Sunday 3:00 AM local | Lower frequency option |
| Custom (e.g., `"every Monday 9am"`) | Pass verbatim to `/schedule` | Let Claude Code resolve it |
### Step S2: Create the routine
Use Claude Code's `/schedule` command to create a cloud routine. The routine runs
`/mem0:dream --auto` on the specified schedule against the current repository:
```
/schedule <frequency> /mem0:dream --auto
```
For example:
- `/schedule weekly /mem0:dream --auto` — runs every week
- `/schedule daily at 3am /mem0:dream --auto` — runs every day at 3 AM
- `/schedule every Monday 9am /mem0:dream --auto` — runs every Monday at 9 AM
The `/schedule` command handles all the cloud infrastructure: repository cloning,
environment setup, and cron scheduling. The routine runs as a full Claude Code
cloud session with access to the mem0 MCP tools.
### Step S3: Confirm to user
After the routine is created, print:
```
Dream scheduled: <frequency>
Routine name: mem0-dream-<project_id>
Next run: <next scheduled time>
Manage at: https://claude.ai/code/routines
Edit: /schedule list → /schedule update
Cancel: /schedule list → delete the routine
```
### Managing scheduled dreams
| Action | Command |
|---|---|
| List all routines | `/schedule list` |
| Run dream now | `/schedule run` (select the dream routine) |
| Change frequency | `/schedule update` (select the dream routine) |
| Pause | Toggle off at claude.ai/code/routines |
| Delete | Delete at claude.ai/code/routines or `/schedule update` |
### Fallback for non-cloud users
If `/schedule` is unavailable (API key auth, no claude.ai subscription), fall back
to local options:
1. **macOS launchd plist** — generate and install:
```bash
cat > ~/Library/LaunchAgents/com.mem0.dream.plist << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.mem0.dream</string>
<key>ProgramArguments</key>
<array>
<string>claude</string>
<string>-p</string>
<string>/mem0:dream --auto</string>
<string>--allowedTools</string>
<string>mcp__mem0__*</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Weekday</key><integer>0</integer>
<key>Hour</key><integer>3</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>StandardOutPath</key><string>/tmp/mem0-dream.log</string>
<key>StandardErrorPath</key><string>/tmp/mem0-dream.err</string>
<key>WorkingDirectory</key><string>PROJECT_DIR</string>
</dict>
</plist>
PLIST
launchctl load ~/Library/LaunchAgents/com.mem0.dream.plist
```
Replace `PROJECT_DIR` with the actual project path.
2. **Linux cron** — add entry:
```bash
(crontab -l 2>/dev/null; echo "0 3 * * 0 cd PROJECT_DIR && claude -p '/mem0:dream --auto' >> /tmp/mem0-dream.log 2>&1") | crontab -
```
Print which method was used and how to verify:
```
Dream scheduled (local: launchd/cron): weekly Sundays 3am
Verify: launchctl list | grep mem0 # macOS
crontab -l | grep mem0 # Linux
```
+81
View File
@@ -0,0 +1,81 @@
---
name: mem0-export
description: >
Export all memories for the current project to a local Markdown file.
Each memory is written as a YAML-frontmatter block that can be re-imported later.
TRIGGER: user runs /mem0:export, or asks "export memories", "backup memories",
"download my memories", "save memories to file".
---
# Mem0 Export
Export all memories for the current project to a portable Markdown file.
## Execution
### Step 1: Resolve identity
Determine the active identity:
- `user_id` from `MEM0_USER_ID` env var, else `$USER`, else `"default"`
- `project_id` (used as `app_id`) from `MEM0_PROJECT_ID` env var, or via the project resolver
### Step 2: Fetch all memories
Call `get_memories` with:
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `page_size=200`
If the response is paginated (i.e. the result contains a `next` cursor or the count equals `page_size`), continue fetching pages until all memories are retrieved.
### Step 3: Format each memory as a YAML-frontmatter block
For each memory record, produce a block in this exact format:
```
---
id: <memory.id>
created_at: <memory.created_at>
type: <memory.metadata.type or "">
confidence: <memory.metadata.confidence or "">
branch: <memory.metadata.branch or "">
files: <memory.metadata.files joined with ", " or "">
categories: <memory.categories joined with ", " or "">
---
<memory.memory or memory content string>
```
Notes:
- The `---` delimiters must be on their own lines with no extra whitespace.
- `files` and `categories` are written as comma-separated values on a single line.
- Leave a blank line after the content before the next `---` (for readability).
- If a field is missing or null, write an empty string (not "null").
### Step 4: Write the export file
Determine the output filename:
```
mem0-export-<project_id>-<YYYY-MM-DD>.md
```
Where `<YYYY-MM-DD>` is today's date in UTC.
Write all formatted blocks to this file using the Write tool (or equivalent). The file is written to the current working directory.
### Step 5: Print summary
```
Exported <N> memories to <filename>
```
Where `<N>` is the total number of memory blocks written.
## Error Handling
- If `get_memories` returns an error or zero memories, print:
```
No memories found for project <project_id>. Nothing exported.
```
- If the write fails, report the error to the user.
+76
View File
@@ -0,0 +1,76 @@
---
name: mem0-forget
description: >
Delete memories by search query or memory ID. Shows matches for confirmation
before deleting. Safe — always confirms before destructive action.
TRIGGER: user runs /mem0:forget <query>, or says "forget this", "delete memory",
"remove that memory about X".
---
# Mem0 Forget
Delete specific memories from mem0.
## Execution
### Step 1: Parse input
The user provides either:
- A search query: `/mem0:forget auth module decisions`
- A memory ID: `/mem0:forget <memory_id>`
If no argument, ask: "What should I forget? Provide a search query or memory ID."
### Step 2: Find memories
**If memory ID provided** (looks like a UUID or hex string):
- Call `get_memory` with the ID to verify it exists.
- Show: `Found: "<memory content first 120 chars>" (created <date>)`
**If search query provided:**
- Call `search_memories` with:
- `query=<user's query>`
- `user_id=<active_user_id>`
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
- `limit=10`
- Show numbered list:
```
Found <N> memories matching "<query>":
1. <content, 120 chars> (type: <type>, created: <date>) [ID: <short_id>]
2. ...
```
### Step 3: Confirm
Ask: "Delete which memories? Enter numbers (e.g., 1,3,5), 'all', or 'cancel'."
For a single memory ID, ask: "Delete this memory? [y/N]"
**Never delete without confirmation.** This is destructive.
### Step 4: Delete
For each confirmed memory, call `delete_memory` with the memory ID.
### Step 5: Report
```
Deleted <N> memories.
```
If any deletions failed, report which ones and why.
## Undo recent writes
If the user says "undo last N memories" or "undo last write":
1. Read session stats to get recently written memory IDs:
```bash
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}}/scripts"
python3 "$SCRIPT_DIR/session_stats.py" peek
```
2. Parse the `recent_ids` array from the JSON output. Each entry has `id`, `category`, `ts`.
3. Show the last N entries (default 1) and ask for confirmation.
4. Delete confirmed entries via `delete_memory`.
If `recent_ids` is empty, tell the user: "No recent memory IDs tracked this session. Use a search query instead."
+171
View File
@@ -0,0 +1,171 @@
---
name: mem0-health
description: >
Diagnostic health check for the mem0 plugin. Verifies API key, MCP server
connectivity, identity resolution, and memory read/write capability.
TRIGGER: user runs /mem0:health, or asks "is mem0 working", "mem0 health",
"check mem0 connection", "debug mem0".
---
# Mem0 Health Check
Run a diagnostic check on the mem0 plugin. Useful for troubleshooting.
## Execution
Run ALL checks, then display a single summary. Do not stop on the first failure.
### Check 1: API key
```bash
echo "${MEM0_API_KEY:-${CLAUDE_PLUGIN_OPTION_MEM0_API_KEY:-NOT_SET}}"
```
- If `NOT_SET`: FAIL — "No API key configured"
- If set: PASS — show first 6 chars + `...` (never print the full key)
### Check 2: Identity resolution
Read the active identity from the SessionStart banner or resolve manually:
- `user_id`: from `MEM0_RESOLVED_USER_ID` or `$USER`
- `project_id`: from `MEM0_PROJECT_ID` or current directory name
- `branch`: from `MEM0_BRANCH` or `git rev-parse --abbrev-ref HEAD`
PASS if all three are non-empty. WARN if any falls back to defaults.
### Check 3: MCP server connectivity
Call `search_memories` with:
- `query="health check"`, `user_id=<id>`, `limit=1`
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<project_id>"}]}`
- If returns successfully (even empty): PASS
- If errors: FAIL — show the error message
### Check 4: Memory write capability
Call `add_memory` with:
- `messages=[{"role": "user", "content": "Health check probe — safe to delete."}]`
- `user_id=<id>`, `app_id=<project_id>`
- `metadata={"type": "health_check", "probe": true}`
If it returns a memory ID: PASS — then immediately call `delete_memory` with that ID to clean up.
If it errors: FAIL — show the error.
### Check 5: Session stats tracker
Check if the session stats file exists and is readable:
```bash
STATS_FILE="/tmp/mem0_session_stats_${USER}.json"
if [ -f "$STATS_FILE" ] && python3 -c "import json; json.load(open('$STATS_FILE'))" 2>/dev/null; then
echo "OK"
else
echo "FAIL"
fi
```
This file is created by the SessionStart hook and updated by PostToolUse hooks throughout the session. If it doesn't exist, the session hooks may not have fired yet — try sending a message first, then recheck.
### Display
```
## mem0 Health Check
| Check | Status | Detail |
|--------------------|--------|-------------------------------|
| API Key | PASS | m0-dVe... |
| Identity | PASS | user=kartik, project=mem0 |
| MCP Connectivity | PASS | 142ms round-trip |
| Memory Write/Read | PASS | write + delete OK |
| Session Tracker | PASS | stats file active |
All checks passed. mem0 is healthy.
```
If any check fails, add a `## Troubleshooting` section with specific fix steps for each failure.
## Extended mode: Memory Quality Analysis
When invoked with `--deep` (e.g., `/mem0:health --deep`) or `--fix` (e.g., `/mem0:health --fix`), run the standard 5 checks above **plus** a memory quality scan.
`--fix` implies `--deep` and automatically applies safe fixes after showing the analysis (see bottom of this section).
### Quality Check 1: Duplicates
Call `get_memories` with `user_id`, `app_id`, `page_size=200`. Compare all pairs within the same `metadata.type` group for high textual overlap (shared nouns/keywords > 60%). Report:
```
Potential duplicates: <N> pairs
[mem0:<id1>] ≈ [mem0:<id2>] — both about "<shared topic>"
```
### Quality Check 2: Stale memories
Flag memories where:
- `metadata.type` is `session_state` or `compact_summary` AND older than 90 days
- `metadata.confidence` < 0.3 AND older than 30 days
```
Stale candidates: <N>
[mem0:<id>] — session_state, 142d old
```
### Quality Check 2b: Low-confidence memories
Flag memories where `metadata.confidence` < 0.5 (regardless of age). Report separately from stale:
```
Low-confidence memories: <N>
[mem0:<id>] — confidence=0.3, "<content preview>"
```
### Quality Check 3: Contradictions
Within each `metadata.type` group, flag pairs that assert opposing facts about the same topic. Use semantic judgment — look for negation patterns, conflicting tool/framework choices, or reversed decisions.
```
Possible contradictions: <N>
[mem0:<idA>] vs [mem0:<idB>] — conflicting on "<topic>"
```
### Quality Check 4: Orphan memories
Memories with no `metadata.type` set, or with `metadata.type` not in the 17 known coding categories. These were likely written without proper tagging.
```
Untagged/orphan memories: <N>
```
### Quality summary
```
## Memory Quality
| Metric | Count | Action |
|----------------|-------|---------------------------------|
| Duplicates | <N> | Run /mem0:dream to merge |
| Stale | <N> | Run /mem0:dream to prune |
| Contradictions | <N> | Run /mem0:dream to resolve |
| Orphans | <N> | Consider retagging via MCP |
```
If all counts are 0: `Memory quality: clean. No duplicates, stale entries, or contradictions found.`
### Auto-fix mode (`--fix`)
When `--fix` is passed, apply these safe fixes automatically after displaying the quality summary:
1. **Orphans:** For each untagged memory, infer a `metadata.type` from content and call `update_memory` to set it. If inference is uncertain, skip.
2. **Stale `session_state`/`compact_summary` > 90d:** Delete them via `delete_memory`. These are ephemeral by design.
3. **Duplicates:** Do NOT auto-merge — print "Run `/mem0:dream` to merge duplicates" instead.
4. **Contradictions:** Do NOT auto-resolve — print "Run `/mem0:dream` to resolve contradictions" instead.
5. **Low-confidence < 0.3 AND > 30d old:** Delete them via `delete_memory`.
Print a summary of actions taken:
```
## Auto-fix Results
Deleted: <N> stale, <N> low-confidence
Retagged: <N> orphans
Skipped: <N> duplicates (use /mem0:dream), <N> contradictions (use /mem0:dream)
```
@@ -0,0 +1,106 @@
---
name: mem0-import-tools
description: >
Import memories from competing AI tool configuration files into mem0.
Supports Cursor (.cursorrules), GitHub Copilot (.github/copilot-instructions.md),
Cline (memory-bank/), and Continue (.continue/rules.md).
TRIGGER: user runs /mem0:import-tools, or asks "import from cursor",
"import cursorrules", "import from cline", "import from copilot",
"import from continue", "migrate from cursor", "migrate memories".
---
# Mem0 Import from Competing Tools
Import configuration and memory files from other AI coding tools into mem0.
## Supported Tools
| Tool | Default file/directory |
|------|----------------------|
| Cursor | `.cursorrules` |
| GitHub Copilot | `.github/copilot-instructions.md` |
| Cline | `memory-bank/` (directory of `.md` files) |
| Continue | `.continue/rules.md` |
## Execution
### Step 1: Detect which tool files exist
Check for the presence of each tool's file/directory in the current working directory:
```bash
# Check each location
test -f .cursorrules && echo "cursor: .cursorrules"
test -f .github/copilot-instructions.md && echo "copilot: .github/copilot-instructions.md"
test -d memory-bank/ && echo "cline: memory-bank/"
test -f .continue/rules.md && echo "continue: .continue/rules.md"
```
### Step 2: Report findings and ask user
List all found files to the user. For example:
```
Found the following tool configuration files:
[1] Cursor rules: .cursorrules
[2] Cline memory bank: memory-bank/
Which would you like to import? (enter numbers, comma-separated, or "all"):
```
If no files are found, print:
```
No competing tool configuration files found in the current directory.
Checked: .cursorrules, .github/copilot-instructions.md, memory-bank/, .continue/rules.md
```
and stop.
### Step 3: Run the import script for each selected tool
Determine the plugin root. Use the appropriate variable for the current platform:
- Claude Code: `${CLAUDE_PLUGIN_ROOT}`
- Codex: `${CODEX_PLUGIN_ROOT}`
- Cursor: `${CURSOR_PLUGIN_ROOT}`
For each tool the user selected, run the corresponding sub-command:
**Cursor (.cursorrules):**
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" cursorrules --path .cursorrules
```
**GitHub Copilot:**
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" copilot --path .github/copilot-instructions.md
```
**Cline:**
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" cline --path memory-bank/
```
**Continue:**
```bash
python3 "<PLUGIN_ROOT>/scripts/import_competing_tools.py" continue --path .continue/rules.md
```
### Step 4: Report results
After each script runs, echo its output to the user. Then print a combined summary:
```
Import complete.
Cursor: <N> memories
Copilot: <N> memories
Total: <N> memories imported into project <project_id>
```
Adjust the summary to reflect only the tools that were actually imported.
## Notes
- Memories are imported with `infer=False` — no AI inference is applied, content is stored as-is.
- Each section or file becomes a separate memory tagged with `metadata.source=<tool>-import` and `metadata.type=project_profile`.
- Sections shorter than 50 characters are automatically skipped (too short to be useful).
- Content longer than 10,000 characters is automatically truncated per chunk.
- You can re-run this skill safely — duplicate content will be caught by mem0's deduplication.
+104
View File
@@ -0,0 +1,104 @@
---
name: mem0-import
description: >
Import memories from a mem0 export file back into the current project.
Reads a YAML-frontmatter Markdown file produced by /mem0:export and
adds each memory block to mem0.
TRIGGER: user runs /mem0:import, or asks "import memories", "restore memories",
"load memories from file", "reimport backup".
---
# Mem0 Import
Import memories from a mem0 export file into the current project.
## Execution
### Step 1: Determine the export file to import
If the user provided a filename as an argument to `/mem0:import <filename>`, use that file.
Otherwise, list `.md` files in the current directory whose names contain `mem0-export`:
```bash
ls -1 *.md 2>/dev/null | grep mem0-export || echo "No export files found"
```
If multiple files are found, ask the user which one to import. If none are found, print:
```
No mem0-export files found in the current directory.
Run /mem0:export first, or provide the filename: /mem0:import <path-to-file>
```
### Step 2: Parse the export file
Determine the plugin root. Use the appropriate variable for the current platform:
- Claude Code: `${CLAUDE_PLUGIN_ROOT}`
- Codex: `${CODEX_PLUGIN_ROOT}`
- Cursor: `${CURSOR_PLUGIN_ROOT}`
Run the parser script to extract memory records as JSON:
```bash
python3 "<PLUGIN_ROOT>/scripts/parse_export_file.py" "<path-to-export-file>"
```
This outputs a JSON array where each element has:
- `id` — original memory ID (for reference only; a new ID will be assigned on import)
- `type` — metadata type
- `confidence` — metadata confidence value
- `branch` — metadata branch
- `files` — list of associated files
- `categories` — list of categories
- `content` — the memory text
If the script fails or outputs `[]`, print:
```
Failed to parse <filename> or file contains no valid memory blocks.
```
and stop.
### Step 3: Resolve identity
Determine the active identity:
- `user_id` from `MEM0_USER_ID` env var, else `$USER`, else `"default"`
- `project_id` (used as `app_id`) from `MEM0_PROJECT_ID` env var, or via the project resolver
### Step 4: Import each memory
For each record in the parsed JSON array, call `add_memory` with:
- `messages=[{"role": "user", "content": "<record.content>"}]`
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `metadata={`
- `"type": "<record.type>"` (if non-empty)
- `"confidence": "<record.confidence>"` (if non-empty)
- `"branch": "<record.branch>"` (if non-empty)
- `"files": <record.files>` (the list, if non-empty)
- `"source": "import"`
- `}`
- `infer=False`
Notes:
- Do NOT pass the original `id` — the platform assigns a new ID.
- Skip records where `content` is empty (the parser already filters these, but be defensive).
- Continue importing even if individual records fail; track the count of successes.
### Step 5: Print results
```
Imported <N> memories into project <project_id>
```
Where `<N>` is the number of successfully imported memories.
If any failed:
```
Imported <N>/<total> memories into project <project_id> (<failed> failed)
```
## Error Handling
- If the parser script is not found at `<PLUGIN_ROOT>/scripts/parse_export_file.py`, print an error and stop.
- If `add_memory` calls fail consistently (e.g. auth error), report the issue and stop early.
+147 -14
View File
@@ -18,17 +18,16 @@ Decide whether persistent memory context would improve your response, then act a
## Project scoping
Every memory operation MUST be scoped to the current project:
Every memory operation MUST be scoped to the current project using `app_id` (entity-scoped memory):
- **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).
- **On `add_memory`:** Always pass `app_id=<active_project_id>` as a **top-level parameter** (not in metadata).
- **On `search_memories`:** Always include `{"app_id": "<your_project_id>"}` in the AND filter.
Full filter template:
```python
filters={"AND": [
{"user_id": "<your_user_id>"},
{"metadata": {"project_id": "<your_project_id>"}},
{"app_id": "<your_project_id>"},
{"metadata": {"type": "decision"}}
]}
```
@@ -67,7 +66,7 @@ Two rules from the v2 filter spec:
1. The root **must** be a logical operator (`AND` / `OR` / `NOT`) with an array. A bare `{"user_id": "..."}` won't work.
2. Metadata uses a **nested** object, not a dotted key. `{"metadata": {"type": "decision"}}`, never `{"metadata.type": "decision"}`. Only top-level metadata keys are filterable.
Combine `user_id` with one metadata clause per call:
Combine `user_id` + `app_id` with one metadata clause per call:
| `metadata.type` clause | Use for |
|--------|---------|
@@ -76,9 +75,33 @@ 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 |
### Which categories to search by query intent
When a query clearly maps to one of the platform's custom categories, fan-out to 2–3 parallel `search_memories` calls scoped to those categories so recall is precise without being noisy. Use the `metadata.type` filter as your primary discriminator; treat the category column below as the semantic lens to pick the right query nouns.
| User intent / signal | Primary categories to search | Example query nouns |
|---|---|---|
| Design or architecture question | `architecture_decisions`, `api_contracts`, `data_model` | `"architecture decision"`, `"API schema"`, `"data model"` |
| Something failed / debugging | `anti_patterns`, `bug_fixes`, `security_constraints` | `"bug root cause"`, `"failure pattern"`, `"security constraint"` |
| How do we do X here? | `coding_conventions`, `team_norms`, `testing_patterns` | `"code convention"`, `"team norm"`, `"test strategy"` |
| Which library / version to use | `dependency_decisions`, `tooling_setup`, `architecture_decisions` | `"dependency choice"`, `"library version"`, `"tooling setup"` |
| Performance or scale concern | `performance_findings`, `architecture_decisions`, `data_model` | `"performance bottleneck"`, `"profiling result"`, `"optimisation"` |
| Security / auth / compliance | `security_constraints`, `api_contracts`, `coding_conventions` | `"auth rule"`, `"security requirement"`, `"compliance"` |
| Test strategy or coverage | `testing_patterns`, `coding_conventions`, `anti_patterns` | `"test framework"`, `"coverage target"`, `"fixture pattern"` |
| Schema / DB / domain object | `data_model`, `api_contracts`, `domain_glossary` | `"schema"`, `"column"`, `"domain object"` |
| API shape or versioning | `api_contracts`, `data_model`, `architecture_decisions` | `"endpoint"`, `"request schema"`, `"versioning"` |
| How to deploy / release / rollback | `deployment_runbook`, `tooling_setup`, `team_norms` | `"deploy step"`, `"rollback"`, `"CI pipeline"` |
| Team process / branching / PRs | `team_norms`, `coding_conventions`, `deployment_runbook` | `"branching strategy"`, `"PR review"`, `"working agreement"` |
| What does this term mean? | `domain_glossary`, `data_model`, `api_contracts` | `"glossary"`, `"abbreviation"`, `"domain term"` |
| Experiment / spike / A-B test | `experiment_results`, `performance_findings`, `anti_patterns` | `"experiment result"`, `"A/B test"`, `"spike outcome"` |
| User's tool / language preferences | `user_preferences`, `tooling_setup`, `coding_conventions` | `"user preference"`, `"preferred tool"`, `"language choice"` |
| Past task strategies that worked | `task_learnings`, `anti_patterns`, `coding_conventions` | `"task strategy"`, `"approach that worked"` |
| Environment / setup question | `tooling_setup`, `deployment_runbook`, `dependency_decisions` | `"environment setup"`, `"build tool"`, `"install step"` |
| Anything related to current state | `task_learnings`, `architecture_decisions`, `anti_patterns` | (combine with recency filter — see below) |
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": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "decision"}}]}
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "decision"}}]}
```
### Worked example
@@ -91,16 +114,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` and `project_id` for the placeholders):
Do (parallel — substitute the active `user_id` and `app_id` for the placeholders):
```python
search_memories(query="auth module decisions",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "decision"}}]})
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "decision"}}]})
search_memories(query="JWT",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}]})
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}]})
search_memories(query="auth refactor failures",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "anti_pattern"}}]})
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "anti_pattern"}}]})
search_memories(query="auth",
filters={"AND": [{"user_id": "<your_user_id>"}, {"metadata": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "user_preference"}}]})
filters={"AND": [{"user_id": "<your_user_id>"}, {"app_id": "<your_project_id>"}, {"metadata": {"type": "user_preference"}}]})
```
## After completing significant work
@@ -114,6 +137,8 @@ Extract key learnings and store them using the `add_memory` tool:
- **Environment/setup discoveries** -> Include metadata `{"type": "environmental"}`
- **Conventions established** -> Include metadata `{"type": "convention"}`
Always include `"branch": "<active_branch>"` in the metadata object alongside `type`. The active branch is shown in the SessionStart banner. This enables branch-scoped filtering later (e.g., "what did we do on feature/auth-rewrite?").
> `metadata.type` (which you set explicitly) and `categories` (which the platform auto-tags after the project's custom-category list — see `scripts/setup_coding_categories.py`) are complementary. Always set `metadata.type` for explicit filtering; the platform fills in `categories` on its own. Don't try to set `categories` on `add_memory` calls — per-request overrides aren't supported on the managed API.
### Expiration: high-churn vs durable
@@ -133,7 +158,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": {"project_id": "<your_project_id>"}}, {"metadata": {"type": "session_state"}}, {"created_at": {"gte": "<90 days ago, YYYY-MM-DD>"}}]}
{"AND": [{"user_id": "<id>"}, {"app_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.
@@ -148,7 +173,8 @@ When you've done the extraction work yourself — pre-compaction summaries, deci
add_memory(
messages=[{"role": "user", "content": "<your structured fact>"}],
user_id="<active user_id>",
metadata={"type": "decision"},
app_id="<active project_id>",
metadata={"type": "decision", "branch": "<active branch>"},
infer=False,
)
```
@@ -180,8 +206,115 @@ If context is about to be compacted or the session is ending, store a comprehens
Include metadata: `{"type": "session_state"}`
## Inline citations
When your response is informed by specific memories, cite them so the user can trace provenance. Use the memory ID returned by `search_memories`.
Format: `[mem0:<short_id>]` where `<short_id>` is the first 8 characters of the memory ID.
Example:
> We chose Postgres over SQLite for production [mem0:a3f8b2c1] and the auth module uses JWT tokens [mem0:7e2d9f4a].
Rules:
- Only cite when the memory **directly informed** your answer. Don't cite for general knowledge.
- Place citations inline, at the end of the relevant sentence.
- If multiple memories support the same point, cite all: `[mem0:abc12345][mem0:def67890]`.
- Don't cite `session_state` or `compact_summary` memories — those are internal bookkeeping.
- Keep it subtle. One or two citations per response is typical. Don't over-cite.
## Memory hygiene
- Do NOT write to MEMORY.md or any file-based memory. Use mem0 MCP tools exclusively.
- Only store genuinely useful learnings. Skip trivial interactions.
- Use specific, searchable language in memory content.
### Confidence scoring on every add_memory
Every `add_memory` call MUST include a `confidence` field in its `metadata` object. This captures how certain the stored fact is, so downstream callers can filter out speculation.
| `metadata.confidence` value | Meaning | When to use |
|---|---|---|
| `1.0` | User explicitly stated it | User said "we use Postgres", "always lint before commit", "never use floats for currency" |
| `0.8` | Observed directly in code / config | You read it from a file, migration, or config — not inferred |
| `0.5` | Inferred from context | You derived it from surrounding evidence but the user didn't confirm it |
| `0.3` | Guessed / low-signal | Extrapolated from a single weak signal; treat as a tentative hypothesis |
Example:
```python
add_memory(
messages=[{"role": "user", "content": "We always use Postgres — never SQLite in production."}],
user_id="<active user_id>",
app_id="<active project_id>",
metadata={"type": "architecture_decisions", "branch": "<active branch>", "confidence": 1.0},
infer=False,
)
```
**Search guidance:** When recalling actionable facts (decisions, conventions, security constraints), optionally apply a confidence threshold of 0.6 or above to avoid surfacing low-confidence guesses. Only top-level metadata keys are filterable, so `confidence` filtering requires SDK-side post-filtering or a dedicated high-confidence write path — for now, include the confidence value in every write and document it in the memory content so it is searchable via text.
### File path tagging on every add_memory
Every `add_memory` call that is associated with specific files MUST include a `files` key in its `metadata` object. The value is an array of affected file paths relative to the project root.
```python
add_memory(
messages=[{"role": "user", "content": "The auth middleware lives in src/middleware/auth.ts and validates JWTs using the shared key in config/secrets.ts."}],
user_id="<active user_id>",
app_id="<active project_id>",
metadata={
"type": "architecture_decisions",
"branch": "<active branch>",
"confidence": 0.8,
"files": ["src/middleware/auth.ts", "config/secrets.ts"],
},
infer=False,
)
```
**Filtering by files:** Use the `contains` operator to filter by `metadata.files` at search time:
```python
search_memories(
query="auth middleware",
filters={
"AND": [
{"user_id": "<id>"},
{"app_id": "<project_id>"},
{"metadata.files": {"contains": "src/middleware/auth.ts"}},
]
},
limit=5,
)
```
Also embed bare filenames in the memory content text as a fallback — the vector search will surface them even if the structured filter misses.
### Access counter: track memory usage
When you retrieve a memory via `search_memories` and **actually use it** in your response (i.e., it informed your answer or you cited it), increment its access counter and update the last-accessed timestamp by calling:
```python
# 1. Read current state
mem = get_memory(memory_id=<id>)
current_text = mem["content"] # or mem["memory"], depending on response shape
current_meta = mem.get("metadata", {})
# 2. Bump access_count and set last_accessed
import datetime
current_meta["access_count"] = current_meta.get("access_count", 0) + 1
current_meta["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
# 3. Update with preserved content and bumped metadata
update_memory(
memory_id=<id>,
data=current_text, # preserve original text — required parameter
metadata=current_meta, # pass updated access_count and last_accessed
)
```
**Important:** `update_memory` requires the `data` (text) parameter. Always `get_memory` first to read the current content, then pass it back unchanged. A metadata-only update may error or wipe the content.
**When to increment:** Only when you actually used the memory to answer. Don't bump on every search hit — that inflates counts for memories that were returned but irrelevant. Aim for 1-3 bumps per response at most.
**Why:** `access_count` and `last_accessed` feed into `/mem0:dream` pruning decisions. Memories that are never accessed after creation are candidates for cleanup. Frequently accessed memories are protected from pruning regardless of age.
+43 -24
View File
@@ -11,27 +11,31 @@ description: >
Run this wizard to set up the mem0 plugin for the current project. Complete in ~30 seconds.
## Step 1: Verify API key
## Step 0: Ensure mem0ai SDK is installed
Check if `MEM0_API_KEY` is set in the current environment:
The plugin installs the `mem0ai` Python SDK automatically on session start via a venv in `${CLAUDE_PLUGIN_DATA}/venv`. If Step 4 (categories) fails with an import error, run:
```bash
echo "${MEM0_API_KEY:+SET}" || echo "NOT_SET"
"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh"
```
- 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.
This is silent and idempotent — safe to run anytime.
## Step 1: Verify API key and MCP connection
Check that mem0 MCP tools are available. Use ToolSearch with query `"mem0 search_memories"` — the exact tool name varies by install method (may be `mcp__mem0__search_memories` or `mcp__plugin_mem0_mem0__search_memories`).
- If **any mem0 search tool found**: Proceed to Step 2. The API key is working.
- If **NOT found**: The MCP server failed to connect. Tell the user:
1. "MCP server not connected. Make sure `MEM0_API_KEY` is exported in your shell."
2. Show: `export MEM0_API_KEY="m0-your-key-here"` then restart Claude Code.
3. If they need a key: https://app.mem0.ai/dashboard/api-keys or `mem0 init --agent --json`
4. **STOP here.** Do not proceed — all other steps need MCP tools.
## 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.
- Call `search_memories` with `query="project setup"`, `user_id=<active_user_id>`, `filters={"AND": [{"user_id": "<active_user_id>"}, {"app_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.
@@ -51,34 +55,49 @@ If user says yes (or default):
- 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"}`
- `app_id=<active_project_id>`
- `metadata={"type": "project_profile", "file": "<filename>", "source": "onboard", "branch": "<active_branch>"}`
- `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}`
If yes, run the setup script using the plugin's venv python:
```bash
python3 "<PLUGIN_ROOT>/scripts/setup_coding_categories.py" --apply
VENV_PY="${CLAUDE_PLUGIN_DATA}/venv/bin/python3"
if [ -x "${VENV_PY}" ]; then
"${VENV_PY}" "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply
else
python3 "${CLAUDE_PLUGIN_ROOT}/scripts/setup_coding_categories.py" --apply
fi
```
If the script reports an error, show the error message and suggest checking the API key.
If the script fails with "mem0ai SDK not found", run the dependency installer first:
```bash
"${CLAUDE_PLUGIN_ROOT}/scripts/ensure_deps.sh"
```
Then retry the categories script.
## Step 5: Summary
## Step 5: Mark project as onboarded
Create a marker file so SessionStart won't re-trigger onboarding next session:
```bash
_SAFE_PID=$(printf '%s' "<active_project_id>" | tr '/:' '--')
mkdir -p ~/.mem0 && touch ~/.mem0/.onboarded_${_SAFE_PID}
```
This is silent — no user-facing output needed.
## Step 6: Summary
Print a summary:
```
Onboarding complete.
user_id: <user_id>
project_id: <project_id>
branch: <branch>
project_id: <project_id> (app_id)
imported: <N> files
categories: <installed or skipped>
+46
View File
@@ -0,0 +1,46 @@
---
name: mem0-peek
description: >
Quick search — compact one-liner results. Faster and lighter than /mem0:tour.
Takes a search query as argument.
TRIGGER: user runs /mem0:peek <query>, or says "quick search mem0",
"what do we know about X", "peek at memories about Y".
---
# Mem0 Peek
Quick search with compact output. Lighter than `/mem0:tour`.
## Execution
### Step 1: Parse query
The user provides a search query: `/mem0:peek auth middleware`
If no query provided, ask: "What should I search for?"
### Step 2: Search
Run 2 parallel `search_memories` calls:
1. Broad: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
2. Targeted: `query=<user's query>`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}, {"metadata": {"type": "decision"}}]}`, `limit=5`
### Step 3: Display
Deduplicate by ID, then show compact results:
```
## mem0 peek: "<query>" (<N> results)
1. [decision] Auth module uses JWT with RS256 keys (2025-05-15) [mem0:a3f8b2c1]
2. [anti_pattern] Don't use symmetric HS256 — leaked in env (2025-05-10) [mem0:7e2d9f4a]
3. [convention] All middleware in src/middleware/ (2025-05-08) [mem0:c4d5e6f7]
```
Format: `<number>. [<type>] <content, 80 chars> (<date>) [mem0:<short_id>]`
If no results:
```
No memories matching "<query>" for project <project_id>.
```
+68
View File
@@ -0,0 +1,68 @@
---
name: mem0-pin
description: >
Pin important memories so they surface prominently. Updates metadata to mark
a memory as pinned. Pinned memories should be prioritized during search.
TRIGGER: user runs /mem0:pin <query or ID>, or says "pin this memory",
"mark as important", "always remember this".
---
# Mem0 Pin
Pin a memory to mark it as high-priority.
## Execution
### Step 1: Find the memory
The user provides either a search query or memory ID.
**If memory ID:**
- Call `get_memory` with the ID.
**If search query:**
- Call `search_memories` with the query, `user_id`, `app_id`, `limit=5`.
- Show numbered list with content previews.
- Ask: "Which memory to pin? Enter a number."
### Step 2: Read current content
Call `get_memory` with the selected memory ID. Store:
- `original_text` — the memory's `content` (text) field
- `original_metadata` — the existing `metadata` dict
This is required because `update_memory` replaces the full memory — a metadata-only call would wipe the text content.
### Step 3: Pin it
Call `update_memory` with:
- `memory_id=<selected_id>`
- `data=<original_text>` (preserve the existing content)
- `metadata=` merge `original_metadata` with `{"pinned": true}`
Example:
```python
updated_meta = {**original_metadata, "pinned": True}
update_memory(memory_id=<selected_id>, data=<original_text>, metadata=updated_meta)
```
**Important:** `update_memory` requires the `data` (text) parameter. Passing only metadata may error or wipe content. Always read first, then update with the full text and explicit metadata.
### Step 4: Confirm
```
Pinned: "<memory content, first 80 chars>..."
Memory ID: <id>
Pinned memories surface first when relevant to a search.
```
### Unpin
If the user says "unpin" or `/mem0:unpin`:
1. Call `get_memory` to read current content and metadata.
2. Set `metadata.pinned = false` explicitly:
```python
updated_meta = {**original_metadata, "pinned": False}
update_memory(memory_id=<id>, data=<original_text>, metadata=updated_meta)
```
3. Print: `Unpinned: "<content>..."`
+53
View File
@@ -0,0 +1,53 @@
---
name: mem0-remember
description: >
Quick-add a memory from the user's input. No extraction pass — stores verbatim.
TRIGGER: user runs /mem0:remember <text>, or says "remember this", "save this",
"store this fact", "don't forget that".
---
# Mem0 Remember
Store a fact or learning directly into mem0.
## Execution
### Step 1: Extract the content
The user provides the content as an argument: `/mem0:remember <text>`
If no text was provided, ask: "What should I remember?"
### Step 2: Classify the memory
Based on the content, pick the best `metadata.type`:
| Content signal | Type |
|---|---|
| "we decided...", "always use...", "never..." | `decision` |
| "X doesn't work because...", "don't try..." | `anti_pattern` |
| "I prefer...", "use X instead of Y" | `user_preference` |
| "the convention is...", "we always..." | `convention` |
| "learned that...", "figured out..." | `task_learning` |
| setup, env, tooling, config | `environmental` |
| anything else | `task_learning` |
### Step 3: Store
Call `add_memory` with:
- `messages=[{"role": "user", "content": "<the user's text>"}]`
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `metadata={"type": "<classified_type>", "branch": "<active_branch>", "confidence": 1.0, "source": "remember_command"}`
- `infer=False`
`infer=False` because the user stated the fact explicitly — no extraction needed.
`confidence=1.0` because the user explicitly asked to store this.
### Step 4: Confirm
Print:
```
Remembered as <type>: "<first 80 chars of content>..."
Memory ID: <id>
```
+96
View File
@@ -0,0 +1,96 @@
---
name: mem0-stats
description: >
Show memory statistics for the current session and project lifetime.
Combines local session counters with API-fetched totals.
TRIGGER: user runs /mem0:stats, or asks "how many memories", "mem0 stats",
"memory usage", "show memory count".
---
# Mem0 Stats
Show session and lifetime memory statistics.
## Execution
### Step 1: Gather session stats
Run the session stats reporter:
```bash
SCRIPT_DIR="${CLAUDE_PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CURSOR_PLUGIN_ROOT:-}}}/scripts"
python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null || echo "{}"
```
The `peek` command returns JSON without clearing the stats file (unlike `report`).
If the script returns empty or errors, note "No session data available" and continue.
### Step 2: Fetch lifetime stats from API
Call `get_memories` with:
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
- `page_size=100`
Count the returned memories. Group them by:
1. `categories[0]` (platform-assigned) — primary grouping
2. `metadata.type` (agent-assigned) — secondary if no categories
3. `created_at` date — for age analysis
Also run a `search_memories` call with `query="project"`, `limit=1` to measure round-trip latency (time the call).
### Step 3: Display
Print a compact dashboard with an ASCII histogram for category distribution:
```
## mem0 Stats
### This Session
Memories written: <N>
Searches run: <N>
Categories touched: <list>
### Project Lifetime (<project_id>)
Total memories: <N>
By category:
decision ████████████████ 24
convention ██████████░░░░░░ 15
anti_pattern ████░░░░░░░░░░░░ 6
task_learning ███░░░░░░░░░░░░░ 5
user_preference ██░░░░░░░░░░░░░░ 3
session_state █░░░░░░░░░░░░░░░ 2
By age:
< 7 days ████████████████ 5
7–30 days ██████████░░░░░░ 12
30–90 days ████░░░░░░░░░░░░ 10
> 90 days ██░░░░░░░░░░░░░░ 8
By access count:
Never accessed ████████████████ 18
1–5 accesses ████████░░░░░░░░ 10
6–20 accesses ████░░░░░░░░░░░░ 4
20+ accesses █░░░░░░░░░░░░░░░ 3
Oldest memory: <date>
Newest memory: <date>
### Health
API latency: <N>ms
User: <user_id>
Project: <project_id>
Branch: <branch>
```
**Histogram rules:**
- Max bar width: 16 characters. Scale all bars relative to the highest count.
- Use `█` for filled and `░` for empty. Right-align the count number.
- Sort categories by count descending. Omit categories with 0 memories.
- If only 1-2 categories exist, still show the histogram — it provides visual context.
- **Age buckets:** Compute from `created_at`. Buckets: <7d, 7–30d, 30–90d, >90d.
- **Access count buckets:** Read `metadata.access_count` (default 0 if absent). Buckets: 0, 1–5, 6–20, 20+.
Skip any section with zero data.
@@ -40,7 +40,7 @@ The user provides a project name as an argument: `/mem0:switch-project <project-
(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`
- Call `search_memories` with `query="project"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<PROJECT_NAME>"}]}`, `limit=1`
4. Print:
```
+70 -25
View File
@@ -13,34 +13,79 @@ 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`):
### Step 1: Fetch ALL memories for this project
- `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`
Call `get_memories` with:
- `user_id=<active_user_id>`
- `app_id=<active_project_id>`
2. Group results by category. For each category with results, print:
This returns every memory scoped to the project — no semantic filtering, no missed results.
```
## <category_name> (<count> memories)
- <memory_content_truncated_to_100_chars> (score: <similarity_score>)
- ...
```
If `get_memories` doesn't support `app_id` as a direct parameter, use:
- `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`
3. For categories with zero results, print: `<category_name>: (empty)`
Pass `page_size=100` (or the maximum allowed) to get a full picture.
4. Print totals at the end:
```
---
Total: <N> memories across <M> categories for project <project_id>
```
### Step 2: Run supplementary semantic searches
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.
```
In parallel, run these `search_memories` calls to get relevance-ranked results for key topics:
- `query="architecture decisions design choices"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
- `query="bugs errors failures anti-patterns"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
- `query="project setup tooling conventions preferences"`, `filters={"AND": [{"user_id": "<id>"}, {"app_id": "<pid>"}]}`, `limit=10`
**Do NOT filter by `metadata.type` in these calls.** The platform auto-assigns `categories` — filtering on `metadata.type` misses memories that were auto-categorized but don't have an explicit `metadata.type`.
### Step 3: Merge and group
Merge all results by memory ID (deduplicate). For each memory, determine its group using this priority:
1. **Platform `categories` field** (array on each memory, auto-assigned by Mem0). Use the first category value.
2. **`metadata.type` field** (if present, set explicitly by hooks/agent). Use as fallback if no `categories`.
3. **"other"** bucket for memories with neither.
Map category names to display names:
| Platform category / metadata.type | Display name |
|---|---|
| `architecture decisions`, `architecture_decisions`, `decision` | Architecture Decisions |
| `anti patterns`, `anti_patterns`, `anti_pattern` | Anti-Patterns |
| `task learnings`, `task_learnings`, `task_learning` | Task Learnings |
| `coding conventions`, `coding_conventions`, `convention` | Coding Conventions |
| `user preferences`, `user_preferences`, `user_preference` | User Preferences |
| `project profile`, `project_profile` | Project Profile |
| `tooling setup`, `tooling_setup`, `environmental` | Tooling & Setup |
| `technology`, `professional_details` | Tooling & Setup |
| `session_state` | Session State |
| `compact_summary` | Compact Summaries |
| anything else | Other |
### Step 4: Display results
For each group that has results, print:
```
## <display_name> (<count> memories)
- <full_memory_content> (score: <similarity_score_if_available>)
- ...
```
Show the **full memory text** for each entry — do NOT truncate. If a group has more than 10 entries, show top 10 by recency (or similarity score if from a search call) and note `... and <N> more`.
For groups with zero results, skip them entirely — don't print empty groups.
### Step 5: Print totals
```
---
Total: <N> unique memories across <M> categories for project <project_id>
Branch: <active_branch>
```
### Step 6: Empty state
If zero memories found for this project, 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,88 @@
"""Tests for setup_coding_categories.py -- CODING_CATEGORIES list completeness."""
from __future__ import annotations
import importlib
import os
import sys
import pytest
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
EXPECTED_KEYS = [
"architecture_decisions",
"anti_patterns",
"task_learnings",
"tooling_setup",
"bug_fixes",
"coding_conventions",
"user_preferences",
"dependency_decisions",
"performance_findings",
"security_constraints",
"testing_patterns",
"data_model",
"api_contracts",
"deployment_runbook",
"team_norms",
"domain_glossary",
"experiment_results",
]
@pytest.fixture()
def coding_categories():
"""Import CODING_CATEGORIES from setup_coding_categories, ensuring scripts/ is on path."""
abs_scripts = os.path.abspath(SCRIPTS_DIR)
inserted = False
if abs_scripts not in sys.path:
sys.path.insert(0, abs_scripts)
inserted = True
# Force re-import in case another test already loaded a stale version
mod_name = "setup_coding_categories"
if mod_name in sys.modules:
del sys.modules[mod_name]
mod = importlib.import_module(mod_name)
yield mod.CODING_CATEGORIES
if inserted and abs_scripts in sys.path:
sys.path.remove(abs_scripts)
def test_total_count(coding_categories):
"""CODING_CATEGORIES must contain exactly 17 entries."""
assert len(coding_categories) == 17, (
f"Expected 17 categories, found {len(coding_categories)}: "
f"{[list(c.keys())[0] for c in coding_categories]}"
)
def test_all_expected_keys_present(coding_categories):
"""Every expected category key must appear exactly once."""
actual_keys = [list(cat.keys())[0] for cat in coding_categories]
for key in EXPECTED_KEYS:
assert key in actual_keys, f"Missing expected category key: '{key}'"
def test_no_duplicate_keys(coding_categories):
"""No category key may appear more than once."""
actual_keys = [list(cat.keys())[0] for cat in coding_categories]
seen = set()
duplicates = []
for key in actual_keys:
if key in seen:
duplicates.append(key)
seen.add(key)
assert not duplicates, f"Duplicate category keys found: {duplicates}"
def test_each_description_is_non_empty_string(coding_categories):
"""Every category must have a non-empty string description."""
for cat in coding_categories:
assert len(cat) == 1, f"Category dict should have exactly one key, got: {cat}"
key = list(cat.keys())[0]
description = cat[key]
assert isinstance(description, str), (
f"Category '{key}' description is not a string: {type(description)}"
)
assert description.strip(), f"Category '{key}' has an empty description"
@@ -0,0 +1,355 @@
"""Tests for import_competing_tools.py — competing tool file importers."""
from __future__ import annotations
import json
import os
import sys
from unittest import mock
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# split_sections tests (unit tests on the splitter functions)
# ---------------------------------------------------------------------------
def test_split_by_headers_cursorrules():
"""split_by_headers correctly splits .cursorrules content on ## headers."""
from import_competing_tools import split_by_headers
content = """\
# My Cursor Rules
Some preamble text that belongs to the first section.
## TypeScript Conventions
Always use strict mode. Prefer const over let.
Never use var.
## React Patterns
Use functional components with hooks.
Avoid class components.
## Testing
Write tests for all utility functions.
"""
chunks = split_by_headers(content, "## ")
assert len(chunks) == 4 # preamble + 3 sections
# First chunk is the preamble (before any ## header)
assert "preamble text" in chunks[0]
# Remaining chunks start with their header
assert chunks[1].startswith("## TypeScript Conventions")
assert "strict mode" in chunks[1]
assert chunks[2].startswith("## React Patterns")
assert "functional components" in chunks[2]
assert chunks[3].startswith("## Testing")
assert "utility functions" in chunks[3]
def test_split_by_headers_copilot():
"""split_by_headers correctly splits copilot-instructions.md on ## headers."""
from import_competing_tools import split_by_headers
content = """\
## Code Style
Use 2-space indentation. Always add trailing commas.
## Architecture
Follow clean architecture principles. Keep business logic in domain layer.
"""
chunks = split_by_headers(content, "## ")
assert len(chunks) == 2
assert chunks[0].startswith("## Code Style")
assert "2-space indentation" in chunks[0]
assert chunks[1].startswith("## Architecture")
assert "clean architecture" in chunks[1]
def test_split_by_headers_no_headers():
"""split_by_headers returns entire content as one chunk if no headers found."""
from import_competing_tools import split_by_headers
content = "This file has no headers at all. Just plain text."
chunks = split_by_headers(content, "## ")
assert len(chunks) == 1
assert "Just plain text" in chunks[0]
def test_split_cline_multiple_md_files(tmp_path):
"""cmd_cline processes multiple .md files from memory-bank/ directory."""
from import_competing_tools import filter_and_truncate
# Create a temporary memory-bank directory with .md files
mb_dir = tmp_path / "memory-bank"
mb_dir.mkdir()
(mb_dir / "architecture.md").write_text(
"# Architecture Decisions\n\nUse microservices architecture with event sourcing."
)
(mb_dir / "conventions.md").write_text(
"# Code Conventions\n\nAll functions must have type hints. Use black formatter."
)
(mb_dir / "empty.md").write_text("") # empty file should be skipped
# Read and verify we can split the files
md_files = sorted(f for f in os.listdir(str(mb_dir)) if f.endswith(".md"))
assert "architecture.md" in md_files
assert "conventions.md" in md_files
assert "empty.md" in md_files
non_empty = []
for filename in md_files:
filepath = os.path.join(str(mb_dir), filename)
with open(filepath) as f:
content = f.read().strip()
if content:
chunks = filter_and_truncate([content])
non_empty.extend(chunks)
assert len(non_empty) == 2
assert any("microservices" in c for c in non_empty)
assert any("type hints" in c for c in non_empty)
def test_split_by_hr_or_headers_continue():
"""split_by_hr_or_headers correctly splits .continue/rules.md."""
from import_competing_tools import split_by_hr_or_headers
content = """\
## First Section
Content of first section.
---
## Second Section
Content of second section.
---
Third section without a header (just after HR).
"""
chunks = split_by_hr_or_headers(content)
# Should split into meaningful chunks
assert len(chunks) >= 2
assert any("First Section" in c for c in chunks)
assert any("Second Section" in c for c in chunks)
def test_filter_and_truncate_skips_short():
"""filter_and_truncate skips chunks shorter than MIN_CHUNK_CHARS (50)."""
from import_competing_tools import filter_and_truncate
chunks = [
"Short", # < 50 chars, should be filtered
"A" * 49, # exactly 49 chars, should be filtered
"A" * 50, # exactly 50 chars, should be kept
"A long enough chunk that definitely passes the minimum length filter.",
]
result = filter_and_truncate(chunks)
assert len(result) == 2
assert all(len(c) >= 50 for c in result)
def test_filter_and_truncate_truncates_long():
"""filter_and_truncate truncates chunks over MAX_CHUNK_CHARS (10000)."""
from import_competing_tools import MAX_CHUNK_CHARS, filter_and_truncate
long_chunk = "X" * (MAX_CHUNK_CHARS + 500)
result = filter_and_truncate([long_chunk])
assert len(result) == 1
assert len(result[0]) == MAX_CHUNK_CHARS
# ---------------------------------------------------------------------------
# Mock API tests
# ---------------------------------------------------------------------------
def _make_mock_response(status: int = 201, body: dict | None = None) -> mock.MagicMock:
"""Create a mock HTTP response object."""
if body is None:
body = {"id": "new-mem-id", "memory": "test"}
resp = mock.MagicMock()
resp.status = status
resp.read.return_value = json.dumps(body).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = mock.MagicMock(return_value=False)
return resp
def test_cursorrules_import_api_call(tmp_path):
"""cmd_cursorrules calls the API with correct app_id (top-level), infer=False, and correct source."""
from import_competing_tools import cmd_cursorrules
# Create a .cursorrules file with enough content
cursorrules = tmp_path / ".cursorrules"
cursorrules.write_text(
"## TypeScript Rules\n\nAlways use strict TypeScript. Never use 'any' type. "
"Prefer interfaces over type aliases for object shapes."
)
captured_requests: list[dict] = []
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode())
captured_requests.append(body)
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-testkey"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="testuser"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="my-project"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
original_cwd = os.getcwd()
os.chdir(str(tmp_path))
try:
cmd_cursorrules(["--path", str(cursorrules)])
finally:
os.chdir(original_cwd)
assert len(captured_requests) >= 1
req_body = captured_requests[0]
# app_id must be top-level (not inside metadata)
assert req_body["app_id"] == "my-project", f"Expected app_id at top level, got: {req_body}"
# infer must be False (not "false", but the boolean False)
assert req_body["infer"] is False, f"Expected infer=False, got: {req_body['infer']}"
# source must be cursor-import
assert req_body["metadata"]["source"] == "cursor-import", (
f"Expected source=cursor-import, got: {req_body['metadata'].get('source')}"
)
# user_id must be set
assert req_body["user_id"] == "testuser"
# messages must be a list with role/content
assert isinstance(req_body["messages"], list)
assert req_body["messages"][0]["role"] == "user"
assert len(req_body["messages"][0]["content"]) > 0
def test_copilot_import_api_call(tmp_path):
"""cmd_copilot calls the API with source=copilot-import."""
from import_competing_tools import cmd_copilot
copilot_dir = tmp_path / ".github"
copilot_dir.mkdir()
copilot_file = copilot_dir / "copilot-instructions.md"
copilot_file.write_text(
"## Code Style\n\nUse 2-space indentation. Always add trailing commas in multi-line structures. "
"Prefer const over let. Never use var in JavaScript code."
)
captured: list[dict] = []
def mock_urlopen(req, timeout=None):
captured.append(json.loads(req.data.decode()))
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="user1"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="proj1"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
cmd_copilot(["--path", str(copilot_file)])
assert len(captured) >= 1
assert captured[0]["metadata"]["source"] == "copilot-import"
assert captured[0]["app_id"] == "proj1"
assert captured[0]["infer"] is False
def test_cline_import_multiple_files(tmp_path):
"""cmd_cline imports one memory per non-empty .md file."""
from import_competing_tools import cmd_cline
mb = tmp_path / "memory-bank"
mb.mkdir()
(mb / "arch.md").write_text(
"Architecture: microservices with event-sourcing. Each service owns its database. "
"Communication via message bus only. No direct service-to-service HTTP calls."
)
(mb / "style.md").write_text(
"Code style: PEP 8 for Python. Black formatter. isort for imports. "
"Line length 120. Type hints required on all public functions and methods."
)
(mb / "empty.md").write_text("")
captured: list[dict] = []
def mock_urlopen(req, timeout=None):
captured.append(json.loads(req.data.decode()))
return _make_mock_response(201)
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("import_competing_tools.resolve_user_id", return_value="user1"), \
mock.patch("import_competing_tools.resolve_project_id", return_value="proj1"), \
mock.patch("import_competing_tools.resolve_branch", return_value="main"), \
mock.patch("urllib.request.urlopen", side_effect=mock_urlopen):
cmd_cline(["--path", str(mb)])
# Should have exactly 2 imports (empty.md skipped)
assert len(captured) == 2
sources = {r["metadata"]["source"] for r in captured}
assert sources == {"cline-import"}
for r in captured:
assert r["infer"] is False
assert r["app_id"] == "proj1"
def test_no_api_key_does_not_call_api(tmp_path):
"""When no API key is set, no HTTP call is made."""
from import_competing_tools import cmd_cursorrules
cursorrules = tmp_path / ".cursorrules"
cursorrules.write_text("## Rules\n\n" + "x" * 100)
with mock.patch("import_competing_tools.resolve_api_key", return_value=""), \
mock.patch("urllib.request.urlopen") as mock_url:
cmd_cursorrules(["--path", str(cursorrules)])
mock_url.assert_not_called()
def test_missing_file_does_not_call_api(tmp_path):
"""When the source file doesn't exist, no HTTP call is made."""
from import_competing_tools import cmd_cursorrules
with mock.patch("import_competing_tools.resolve_api_key", return_value="m0-key"), \
mock.patch("urllib.request.urlopen") as mock_url:
cmd_cursorrules(["--path", str(tmp_path / "nonexistent.cursorrules")])
mock_url.assert_not_called()
def test_main_unknown_subcommand_exits_zero():
"""Calling main() with an unknown subcommand exits 0."""
import subprocess
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "import_competing_tools.py"), "unknown"],
capture_output=True,
text=True,
env={**os.environ, "MEM0_API_KEY": ""},
)
assert result.returncode == 0
+298
View File
@@ -0,0 +1,298 @@
"""Tests for parse_export_file.py — mem0 export file parser."""
from __future__ import annotations
import json
import os
import subprocess
import sys
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# Direct function tests
# ---------------------------------------------------------------------------
def test_parse_blocks_single_valid_block():
"""parse_blocks returns one record for a single valid block."""
from parse_export_file import parse_blocks
content = """\
---
id: abc123
created_at: 2024-01-15T10:00:00Z
type: task_learnings
confidence: 0.85
branch: main
files: src/foo.py, src/bar.py
categories: coding_conventions, task_learnings
---
Always use context managers when opening files.
"""
records = parse_blocks(content)
assert len(records) == 1
r = records[0]
assert r["id"] == "abc123"
assert r["type"] == "task_learnings"
assert r["confidence"] == "0.85"
assert r["branch"] == "main"
assert r["files"] == ["src/foo.py", "src/bar.py"]
assert r["categories"] == ["coding_conventions", "task_learnings"]
assert "Always use context managers" in r["content"]
def test_parse_blocks_multiple_blocks():
"""parse_blocks returns the correct number of records for multiple blocks."""
from parse_export_file import parse_blocks
content = """\
---
id: mem001
type: architecture_decisions
confidence: 0.9
branch: main
files:
categories: architecture_decisions
---
Use hexagonal architecture for the core domain.
---
id: mem002
type: anti_patterns
confidence: 0.75
branch: feat/refactor
files: src/legacy.py
categories: anti_patterns
---
Avoid direct database calls from view layer.
---
id: mem003
type: coding_conventions
confidence: 0.8
branch: main
files: src/utils.py, src/helpers.py
categories:
---
Use snake_case for all Python identifiers.
"""
records = parse_blocks(content)
assert len(records) == 3
assert records[0]["id"] == "mem001"
assert records[0]["categories"] == ["architecture_decisions"]
assert "hexagonal architecture" in records[0]["content"]
assert records[1]["id"] == "mem002"
assert records[1]["files"] == ["src/legacy.py"]
assert "direct database calls" in records[1]["content"]
assert records[2]["id"] == "mem003"
assert records[2]["files"] == ["src/utils.py", "src/helpers.py"]
assert records[2]["categories"] == []
assert "snake_case" in records[2]["content"]
def test_parse_blocks_missing_optional_fields():
"""parse_blocks uses defaults when optional fields are absent."""
from parse_export_file import parse_blocks
# confidence, branch, files, categories all absent
content = """\
---
id: xyz789
type: task_learnings
---
Run tests before committing.
"""
records = parse_blocks(content)
assert len(records) == 1
r = records[0]
assert r["id"] == "xyz789"
assert r["confidence"] == "" # default empty string
assert r["branch"] == "" # default empty string
assert r["files"] == [] # default empty list
assert r["categories"] == [] # default empty list
assert "Run tests" in r["content"]
def test_parse_blocks_filters_empty_content():
"""parse_blocks skips blocks whose content is empty or whitespace-only."""
from parse_export_file import parse_blocks
content = """\
---
id: empty1
type: task_learnings
---
---
id: real1
type: task_learnings
---
This block has real content.
---
id: empty2
type: coding_conventions
---
"""
records = parse_blocks(content)
# Only the block with actual content should be returned
assert len(records) == 1
assert records[0]["id"] == "real1"
assert "real content" in records[0]["content"]
def test_parse_blocks_round_trip():
"""Content formatted by export matches what parse_blocks expects."""
from parse_export_file import parse_blocks
# Simulate the exact format produced by the export skill
memory_id = "test-id-001"
created_at = "2024-06-01T12:00:00Z"
mem_type = "architecture_decisions"
confidence = "0.92"
branch = "feat/new-feature"
files = ["src/main.py", "tests/test_main.py"]
categories = ["architecture_decisions", "coding_conventions"]
memory_content = "Use dependency injection for all service classes."
# Format exactly as the export skill would
block = (
"---\n"
f"id: {memory_id}\n"
f"created_at: {created_at}\n"
f"type: {mem_type}\n"
f"confidence: {confidence}\n"
f"branch: {branch}\n"
f"files: {', '.join(files)}\n"
f"categories: {', '.join(categories)}\n"
"---\n"
f"{memory_content}\n"
"\n"
)
records = parse_blocks(block)
assert len(records) == 1
r = records[0]
assert r["id"] == memory_id
assert r["type"] == mem_type
assert r["confidence"] == confidence
assert r["branch"] == branch
assert r["files"] == files
assert r["categories"] == categories
assert r["content"] == memory_content
def test_parse_blocks_multiline_content():
"""parse_blocks correctly captures multi-line memory content."""
from parse_export_file import parse_blocks
content = """\
---
id: multi001
type: task_learnings
---
Line one of the memory.
Line two of the memory.
Line four after blank line.
"""
records = parse_blocks(content)
assert len(records) == 1
assert "Line one" in records[0]["content"]
assert "Line two" in records[0]["content"]
assert "Line four" in records[0]["content"]
def test_parse_blocks_empty_input():
"""parse_blocks returns empty list for empty input."""
from parse_export_file import parse_blocks
assert parse_blocks("") == []
assert parse_blocks(" \n ") == []
def test_parse_blocks_no_blocks():
"""parse_blocks returns empty list for content without any --- delimiters."""
from parse_export_file import parse_blocks
assert parse_blocks("Just some text without any delimiters.") == []
def test_parse_blocks_value_with_colon():
"""parse_blocks handles values that themselves contain colons."""
from parse_export_file import parse_blocks
content = """\
---
id: colon-test
type: task_learnings
created_at: 2024-01-01T10:00:00Z
---
Timestamp values contain colons and should parse correctly.
"""
records = parse_blocks(content)
assert len(records) == 1
assert records[0]["id"] == "colon-test"
# created_at field should be captured (it's in the record if present)
assert "2024-01-01T10:00:00Z" in records[0].get("created_at", "")
# ---------------------------------------------------------------------------
# CLI / subprocess tests
# ---------------------------------------------------------------------------
def test_main_cli_outputs_json(tmp_path):
"""Running parse_export_file.py as a script outputs valid JSON."""
export_file = tmp_path / "mem0-export-test.md"
export_file.write_text("""\
---
id: cli-test-001
type: task_learnings
confidence: 0.8
branch: main
files:
categories: task_learnings
---
Prefer composition over inheritance.
""")
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py"), str(export_file)],
capture_output=True,
text=True,
)
assert result.returncode == 0
records = json.loads(result.stdout)
assert isinstance(records, list)
assert len(records) == 1
assert records[0]["id"] == "cli-test-001"
def test_main_cli_no_args_exits_zero():
"""Running parse_export_file.py with no arguments exits 0 and prints []."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py")],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout.strip() == "[]"
def test_main_cli_missing_file_exits_zero(tmp_path):
"""Running parse_export_file.py with a missing file exits 0 and prints []."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_export_file.py"),
str(tmp_path / "nonexistent.md")],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert result.stdout.strip() == "[]"
+424
View File
@@ -0,0 +1,424 @@
"""Tests for parse_mem0_config.py — mem0.md retention policy parser."""
from __future__ import annotations
import json
import os
import subprocess
import sys
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
# ---------------------------------------------------------------------------
# parse_retention — unit tests
# ---------------------------------------------------------------------------
def test_parse_retention_valid_section():
"""parse_retention extracts day-count policies correctly."""
from parse_mem0_config import parse_retention
content = """\
# Project Config
## Retention
session_state: 90d
compact_summary: 60d
decision: 180d
"""
result = parse_retention(content)
assert result == {
"session_state": 90,
"compact_summary": 60,
"decision": 180,
}
def test_parse_retention_forever_returns_none():
"""parse_retention maps 'forever' to None."""
from parse_mem0_config import parse_retention
content = """\
## Retention
user_preference: forever
anti_pattern: forever
session_state: 30d
"""
result = parse_retention(content)
assert result["user_preference"] is None
assert result["anti_pattern"] is None
assert result["session_state"] == 30
def test_parse_retention_no_section_returns_empty():
"""parse_retention returns {} when there is no ## Retention heading."""
from parse_mem0_config import parse_retention
content = """\
# Project Config
## Some Other Section
key: value
"""
result = parse_retention(content)
assert result == {}
def test_parse_retention_stops_at_next_heading():
"""parse_retention stops reading at the next ## heading."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 7d
## Other Section
other_key: 999d
"""
result = parse_retention(content)
assert "session_state" in result
assert "other_key" not in result
def test_parse_retention_malformed_lines_skipped():
"""Malformed lines (no colon, bad day format) are silently ignored."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 90d
bad_line_no_colon
another: badvalue
decision: 30d
"""
result = parse_retention(content)
assert result == {"session_state": 90, "decision": 30}
def test_parse_retention_comments_ignored():
"""Inline # comments are stripped before parsing."""
from parse_mem0_config import parse_retention
content = """\
## Retention
session_state: 90d # rolling 90-day window
user_preference: forever # never prune preferences
"""
result = parse_retention(content)
assert result["session_state"] == 90
assert result["user_preference"] is None
def test_parse_retention_case_insensitive_heading():
"""## retention (lowercase) is matched the same as ## Retention."""
from parse_mem0_config import parse_retention
content = """\
## retention
session_state: 14d
"""
result = parse_retention(content)
assert result == {"session_state": 14}
def test_parse_retention_empty_section_returns_empty():
"""A ## Retention section with no valid lines returns {}."""
from parse_mem0_config import parse_retention
content = """\
## Retention
# only comments here
## Next Section
"""
result = parse_retention(content)
assert result == {}
# ---------------------------------------------------------------------------
# load_retention_policies — integration tests with tmp files
# ---------------------------------------------------------------------------
def test_load_retention_policies_with_tmp_file(tmp_path):
"""load_retention_policies reads a real mem0.md from disk."""
from parse_mem0_config import load_retention_policies
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
## Retention
session_state: 90d
compact_summary: 60d
decision: forever
""",
encoding="utf-8",
)
result = load_retention_policies(str(tmp_path))
assert result == {
"session_state": 90,
"compact_summary": 60,
"decision": None,
}
def test_load_retention_policies_no_mem0_md_returns_empty(tmp_path):
"""load_retention_policies returns {} when no mem0.md exists."""
from parse_mem0_config import load_retention_policies
result = load_retention_policies(str(tmp_path))
assert result == {}
def test_load_retention_policies_no_retention_section_returns_empty(tmp_path):
"""load_retention_policies returns {} when mem0.md has no ## Retention."""
from parse_mem0_config import load_retention_policies
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
Some general project notes here.
No retention section.
""",
encoding="utf-8",
)
result = load_retention_policies(str(tmp_path))
assert result == {}
def test_load_retention_policies_defaults_to_cwd(tmp_path, monkeypatch):
"""load_retention_policies uses os.getcwd() when cwd is None."""
from parse_mem0_config import load_retention_policies
monkeypatch.chdir(tmp_path)
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text("## Retention\nsession_state: 45d\n", encoding="utf-8")
result = load_retention_policies() # no cwd arg
assert result == {"session_state": 45}
# ---------------------------------------------------------------------------
# CLI / main() — subprocess test
# ---------------------------------------------------------------------------
def test_cli_main_prints_json(tmp_path):
"""CLI: python parse_mem0_config.py <cwd> prints valid JSON."""
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"## Retention\nsession_state: 90d\nuser_preference: forever\n",
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["session_state"] == 90
assert data["user_preference"] is None
def test_cli_main_no_file_prints_empty_json(tmp_path):
"""CLI: prints '{}' when no mem0.md exists."""
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
assert json.loads(result.stdout) == {}
# ---------------------------------------------------------------------------
# parse_section_kv — unit tests
# ---------------------------------------------------------------------------
def test_parse_section_kv_basic():
"""parse_section_kv extracts key-value pairs from a named section."""
from parse_mem0_config import parse_section_kv
content = """\
## Search
default_limit: 10
boost_recency: true
"""
result = parse_section_kv(content, "Search")
assert result == {"default_limit": "10", "boost_recency": "true"}
def test_parse_section_kv_missing_section():
"""parse_section_kv returns {} when section doesn't exist."""
from parse_mem0_config import parse_section_kv
result = parse_section_kv("## Other\nfoo: bar\n", "Search")
assert result == {}
def test_parse_section_kv_stops_at_next_heading():
"""parse_section_kv stops at the next ## heading."""
from parse_mem0_config import parse_section_kv
content = """\
## Identity
user_id: kartik
project_id: mem0
## Other
ignored: yes
"""
result = parse_section_kv(content, "Identity")
assert result == {"user_id": "kartik", "project_id": "mem0"}
assert "ignored" not in result
# ---------------------------------------------------------------------------
# parse_section_list — unit tests
# ---------------------------------------------------------------------------
def test_parse_section_list_basic():
"""parse_section_list extracts list items from a named section."""
from parse_mem0_config import parse_section_list
content = """\
## Categories
- architecture_decisions
- bug_fixes
- coding_conventions
"""
result = parse_section_list(content, "Categories")
assert result == ["architecture_decisions", "bug_fixes", "coding_conventions"]
def test_parse_section_list_bare_lines():
"""parse_section_list works with bare lines (no bullet prefix)."""
from parse_mem0_config import parse_section_list
content = """\
## Categories
architecture_decisions
bug_fixes
"""
result = parse_section_list(content, "Categories")
assert result == ["architecture_decisions", "bug_fixes"]
def test_parse_section_list_missing_section():
"""parse_section_list returns [] when section doesn't exist."""
from parse_mem0_config import parse_section_list
result = parse_section_list("## Other\n- foo\n", "Categories")
assert result == []
# ---------------------------------------------------------------------------
# load_full_config — integration tests
# ---------------------------------------------------------------------------
def test_load_full_config_all_sections(tmp_path):
"""load_full_config extracts all sections from mem0.md."""
from parse_mem0_config import load_full_config
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"""\
# My Project
## Retention
session_state: 90d
decision: forever
## Search
default_limit: 20
boost_recency: true
## Categories
- architecture_decisions
- bug_fixes
- security_constraints
## Identity
user_id: kartik
project_id: my-project
""",
encoding="utf-8",
)
config = load_full_config(str(tmp_path))
assert config["retention"] == {"session_state": 90, "decision": None}
assert config["search"] == {"default_limit": "20", "boost_recency": "true"}
assert config["categories"] == ["architecture_decisions", "bug_fixes", "security_constraints"]
assert config["identity"] == {"user_id": "kartik", "project_id": "my-project"}
def test_load_full_config_partial_sections(tmp_path):
"""load_full_config only includes sections that exist."""
from parse_mem0_config import load_full_config
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text("## Retention\nsession_state: 30d\n", encoding="utf-8")
config = load_full_config(str(tmp_path))
assert "retention" in config
assert "search" not in config
assert "categories" not in config
assert "identity" not in config
def test_load_full_config_no_file(tmp_path):
"""load_full_config returns {} when no mem0.md exists."""
from parse_mem0_config import load_full_config
config = load_full_config(str(tmp_path))
assert config == {}
def test_cli_full_flag(tmp_path):
"""CLI: --full prints all sections as JSON."""
mem0_md = tmp_path / "mem0.md"
mem0_md.write_text(
"## Retention\nsession_state: 90d\n\n## Search\nlimit: 10\n",
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "parse_mem0_config.py"), "--full", str(tmp_path)],
capture_output=True,
text=True,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "retention" in data
assert "search" in data
+54
View File
@@ -0,0 +1,54 @@
"""Tests for on_pre_commit.py — pre-commit memory capture."""
from __future__ import annotations
import os
from unittest.mock import MagicMock
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
def test_import_succeeds():
"""on_pre_commit module can be imported."""
import on_pre_commit
assert hasattr(on_pre_commit, "main")
def test_no_api_key_exits_zero(monkeypatch):
"""main() exits 0 when no API key is set."""
import on_pre_commit
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.setattr("sys.stdin", MagicMock(isatty=lambda: True))
assert on_pre_commit.main() == 0
def test_empty_diff_exits_zero(monkeypatch):
"""main() exits 0 when stdin diff is empty."""
from io import StringIO
import on_pre_commit
monkeypatch.setenv("MEM0_API_KEY", "test-key")
monkeypatch.setattr("sys.stdin", StringIO(""))
assert on_pre_commit.main() == 0
def test_get_staged_summary_runs():
"""get_staged_summary doesn't crash even outside a git repo."""
import on_pre_commit
result = on_pre_commit.get_staged_summary()
assert isinstance(result, str)
def test_get_commit_message_runs():
"""get_commit_message doesn't crash even outside a git repo."""
import on_pre_commit
result = on_pre_commit.get_commit_message()
assert isinstance(result, str)
+105
View File
@@ -141,3 +141,108 @@ def test_cli_report_no_data(tmp_path):
env=env,
)
assert result.returncode == 0
def test_peek_returns_json_without_clearing(_isolate_stats_file):
"""peek returns JSON stats without deleting the stats file."""
import session_stats
session_stats.init()
session_stats.record_add("decisions")
session_stats.record_add("decisions")
session_stats.record_search()
result = session_stats.peek()
data = json.loads(result)
assert data["adds"] == 2
assert data["searches"] == 1
assert os.path.isfile(_isolate_stats_file)
def test_category_counts_tracked(_isolate_stats_file):
"""category_counts tracks per-category add counts."""
import session_stats
session_stats.init()
session_stats.record_add("bug_fixes")
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["category_counts"]["bug_fixes"] == 3
assert data["category_counts"]["decisions"] == 1
def test_category_counts_empty_category_not_tracked(_isolate_stats_file):
"""Empty category string doesn't appear in category_counts."""
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["category_counts"] == {}
def test_recent_ids_tracked(_isolate_stats_file):
"""record_add with memory_id stores ID in recent_ids."""
import session_stats
session_stats.init()
session_stats.record_add("decision", "abc-123")
session_stats.record_add("convention", "def-456")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert len(data["recent_ids"]) == 2
assert data["recent_ids"][0]["id"] == "abc-123"
assert data["recent_ids"][1]["id"] == "def-456"
assert data["recent_ids"][0]["category"] == "decision"
def test_recent_ids_capped(_isolate_stats_file):
"""recent_ids list is capped at MAX_RECENT_IDS."""
import session_stats
session_stats.init()
for i in range(60):
session_stats.record_add("test", f"id-{i}")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert len(data["recent_ids"]) == session_stats.MAX_RECENT_IDS
assert data["recent_ids"][0]["id"] == f"id-{60 - session_stats.MAX_RECENT_IDS}"
def test_recent_ids_empty_without_memory_id(_isolate_stats_file):
"""record_add without memory_id doesn't add to recent_ids."""
import session_stats
session_stats.init()
session_stats.record_add("decision")
session_stats.record_add("convention", "")
with open(_isolate_stats_file) as f:
data = json.load(f)
assert data["recent_ids"] == []
def test_cli_peek(tmp_path):
"""Test CLI invocation: session_stats.py peek outputs JSON."""
env = {**os.environ, "USER": "test"}
subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "init"],
capture_output=True, text=True, env=env,
)
result = subprocess.run(
[sys.executable, os.path.join(SCRIPTS_DIR, "session_stats.py"), "peek"],
capture_output=True, text=True, env=env,
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert "adds" in data
+163
View File
@@ -0,0 +1,163 @@
"""Tests for telemetry.py — fire-and-forget PostHog plugin telemetry."""
from __future__ import annotations
import json
import os
import sys
import urllib.error
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts")
sys.path.insert(0, os.path.abspath(SCRIPTS_DIR))
def test_import_succeeds():
import telemetry
assert hasattr(telemetry, "emit")
assert hasattr(telemetry, "main")
def test_opt_out_skips_send(monkeypatch):
import telemetry
monkeypatch.setenv("MEM0_TELEMETRY", "false")
sent = []
monkeypatch.setattr(telemetry, "send", lambda p: sent.append(p))
telemetry.emit("session_start")
assert sent == []
def test_opt_out_variants(monkeypatch):
import telemetry
for val in ("0", "no", "off", "FALSE", "No"):
monkeypatch.setenv("MEM0_TELEMETRY", val)
assert not telemetry.is_enabled()
def test_enabled_by_default(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
assert telemetry.is_enabled()
def test_posthog_payload_structure(monkeypatch):
import telemetry
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "testuser")
monkeypatch.setenv("MEM0_PROJECT_ID", "test-project")
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
payload = telemetry.build_posthog_payload("plugin.session_start", {"memory_count": 5})
assert payload["api_key"] == telemetry.POSTHOG_API_KEY
assert payload["event"] == "plugin.session_start"
assert "distinct_id" in payload
assert payload["properties"]["source"] == "plugin"
assert payload["properties"]["plugin_version"] == "0.2.1"
assert payload["properties"]["memory_count"] == 5
assert payload["properties"]["$process_person_profile"] is False
raw = json.dumps(payload)
assert "testuser" not in raw
assert "test-project" not in raw
def test_distinct_id_from_api_key(monkeypatch):
import hashlib
import telemetry
monkeypatch.setenv("MEM0_API_KEY", "m0-testkey123")
expected = hashlib.md5(b"m0-testkey123").hexdigest()
assert telemetry._distinct_id() == expected
def test_distinct_id_fallback_no_key(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "kartik")
assert telemetry._distinct_id() == telemetry._sha256("kartik")
def test_hash_deterministic():
import telemetry
h1 = telemetry._sha256("same-value")
h2 = telemetry._sha256("same-value")
assert h1 == h2
assert h1 != telemetry._sha256("different-value")
def test_platform_claude_code(monkeypatch):
import telemetry
monkeypatch.setenv("CLAUDECODE", "1")
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
assert telemetry.detect_platform() == "claude-code"
def test_platform_cursor(monkeypatch):
import telemetry
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path")
monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False)
assert telemetry.detect_platform() == "cursor"
def test_platform_codex(monkeypatch):
import telemetry
monkeypatch.delenv("CLAUDECODE", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False)
monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False)
monkeypatch.setenv("CODEX_PLUGIN_ROOT", "/path")
assert telemetry.detect_platform() == "codex"
def test_sampling_drops_at_high_random(monkeypatch):
import telemetry
monkeypatch.setattr(telemetry.random, "random", lambda: 0.5)
assert telemetry._should_sample() is False
def test_sampling_sends_at_low_random(monkeypatch):
import telemetry
monkeypatch.setattr(telemetry.random, "random", lambda: 0.05)
assert telemetry._should_sample() is True
def test_send_fails_silently(monkeypatch):
import telemetry
def raise_error(req, timeout):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(telemetry.urllib.request, "urlopen", raise_error)
telemetry.send({"event": "test"})
def test_cli_exits_zero_when_disabled(monkeypatch):
import telemetry
monkeypatch.setenv("MEM0_TELEMETRY", "false")
monkeypatch.setattr(sys, "argv", ["telemetry.py", "session_start"])
assert telemetry.main() == 0
def test_cli_no_args_exits_nonzero(monkeypatch):
import telemetry
monkeypatch.delenv("MEM0_TELEMETRY", raising=False)
monkeypatch.setattr(sys, "argv", ["telemetry.py"])
assert telemetry.main() == 1
+194
View File
@@ -0,0 +1,194 @@
"""Tests for write-path app_id migration and API key resolution.
Verifies that all scripts writing to the Mem0 API:
1. Pass app_id as a top-level parameter (not in metadata)
2. Do NOT include project_id in metadata
3. Include branch in metadata when available
4. Use resolve_api_key() for key resolution with userConfig fallback
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
def test_auto_import_post_memory_uses_app_id():
"""auto_import.post_memory sends app_id top-level, not metadata.project_id."""
from auto_import import post_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = post_memory(
api_key="test-key",
content="test content",
user_id="testuser",
filename="CLAUDE.md",
project_id="my-project",
branch="main",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "project_profile"
assert captured["metadata"]["branch"] == "main"
assert captured["infer"] is False
def test_auto_import_post_memory_omits_empty_branch():
"""auto_import.post_memory skips branch in metadata when empty."""
from auto_import import post_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
post_memory("key", "content", "user", "FILE.md", "proj", branch="")
assert "branch" not in captured.get("metadata", {})
def test_on_pre_compact_store_memory_uses_app_id():
"""on_pre_compact.store_memory sends app_id top-level."""
from on_pre_compact import store_memory
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = store_memory(
api_key="test-key",
content="session state content",
user_id="testuser",
source="pre-compaction",
session_id="sess-123",
project_id="my-project",
branch="feat/auth",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "session_state"
assert captured["metadata"]["source"] == "pre-compaction"
assert captured["metadata"]["branch"] == "feat/auth"
assert "expiration_date" in captured
def test_capture_compact_summary_store_uses_app_id():
"""capture_compact_summary.store_summary sends app_id top-level."""
from capture_compact_summary import store_summary
captured = {}
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
captured.update(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
result = store_summary(
api_key="test-key",
summary="compact summary text",
user_id="testuser",
session_id="sess-456",
project_id="my-project",
branch="main",
)
assert result is True
assert captured["app_id"] == "my-project"
assert captured["user_id"] == "testuser"
assert "project_id" not in captured.get("metadata", {})
assert captured["metadata"]["type"] == "compact_summary"
assert captured["metadata"]["branch"] == "main"
assert captured["infer"] is True
assert "expiration_date" in captured
def test_no_metadata_project_id_anywhere():
"""Ensure none of the write functions put project_id in metadata."""
from auto_import import post_memory
from capture_compact_summary import store_summary
from on_pre_compact import store_memory
bodies = []
def mock_urlopen(req, timeout=None):
body = json.loads(req.data.decode("utf-8"))
bodies.append(body)
resp = MagicMock()
resp.status = 200
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
with patch("urllib.request.urlopen", side_effect=mock_urlopen):
post_memory("k", "c", "u", "f", "proj", "br")
store_memory("k", "c", "u", "src", "sid", "proj", "br")
store_summary("k", "s", "u", "sid", "proj", "br")
for i, body in enumerate(bodies):
metadata = body.get("metadata", {})
assert "project_id" not in metadata, f"Write function #{i} still has metadata.project_id"
assert body.get("app_id") == "proj", f"Write function #{i} missing app_id top-level"
def test_resolve_api_key_prefers_env_var(monkeypatch):
"""resolve_api_key returns MEM0_API_KEY when both are set."""
from _identity import resolve_api_key
monkeypatch.setenv("MEM0_API_KEY", "direct-key")
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "fallback-key")
assert resolve_api_key() == "direct-key"
def test_resolve_api_key_falls_back_to_plugin_option(monkeypatch):
"""resolve_api_key falls back to CLAUDE_PLUGIN_OPTION_MEM0_API_KEY."""
from _identity import resolve_api_key
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.setenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", "fallback-key")
assert resolve_api_key() == "fallback-key"
def test_resolve_api_key_returns_empty_when_neither_set(monkeypatch):
"""resolve_api_key returns empty string when no key is available."""
from _identity import resolve_api_key
monkeypatch.delenv("MEM0_API_KEY", raising=False)
monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False)
assert resolve_api_key() == ""