555e282cc4
ci / changelog_check (push) Waiting to run
ci / check_changes (push) Waiting to run
ci / build_mem0 (3.10) (push) Blocked by required conditions
ci / build_mem0 (3.11) (push) Blocked by required conditions
ci / build_mem0 (3.12) (push) Blocked by required conditions
CLI Node CI / lint (push) Waiting to run
CLI Node CI / test (20) (push) Waiting to run
CLI Node CI / test (22) (push) Waiting to run
CLI Node CI / build (push) Waiting to run
CLI Python CI / lint (push) Waiting to run
CLI Python CI / test (3.10) (push) Waiting to run
CLI Python CI / test (3.11) (push) Waiting to run
CLI Python CI / test (3.12) (push) Waiting to run
CLI Python CI / build (push) Waiting to run
openclaw checks / lint (push) Waiting to run
openclaw checks / test (20) (push) Waiting to run
openclaw checks / test (22) (push) Waiting to run
openclaw checks / build (push) Waiting to run
opencode-plugin checks / build (push) Waiting to run
pi-agent-plugin checks / lint (push) Waiting to run
pi-agent-plugin checks / test (20) (push) Waiting to run
pi-agent-plugin checks / test (22) (push) Waiting to run
pi-agent-plugin checks / build (push) Waiting to run
TypeScript SDK CI / check_changes (push) Waiting to run
TypeScript SDK CI / changelog_check (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / build_ts_sdk (22) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (20) (push) Blocked by required conditions
TypeScript SDK CI / integration_ts_sdk (22) (push) Blocked by required conditions
375 lines
12 KiB
Python
375 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Auto-import declarative project files into mem0.
|
|
|
|
Runs in the background from the SessionStart hook (startup only).
|
|
Imports CLAUDE.md, AGENTS.md, .cursorrules, .windsurfrules, mem0.md
|
|
into mem0 as project profile memories, skipping unchanged files via
|
|
SHA-256 hashing.
|
|
|
|
Input: MEM0_CWD env var (optional, defaults to os.getcwd())
|
|
Output: stderr logs only (exit 0 always — must not block)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from _chunking import filter_and_truncate, split_by_headers
|
|
from _identity import resolve_api_key, resolve_user_id
|
|
from _project import resolve_branch, resolve_project_id, save_project_mapping
|
|
|
|
log = logging.getLogger("mem0-auto-import")
|
|
log.setLevel(logging.DEBUG)
|
|
_handler = logging.StreamHandler(sys.stderr)
|
|
_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(message)s"))
|
|
log.addHandler(_handler)
|
|
|
|
if os.environ.get("MEM0_DEBUG"):
|
|
_log_dir = os.path.expanduser("~/.mem0")
|
|
try:
|
|
os.makedirs(_log_dir, exist_ok=True)
|
|
_file_handler = logging.FileHandler(os.path.join(_log_dir, "hooks.log"))
|
|
_file_handler.setFormatter(logging.Formatter("[mem0-auto-import] %(asctime)s %(message)s"))
|
|
log.addHandler(_file_handler)
|
|
except OSError:
|
|
pass
|
|
|
|
API_URL = "https://api.mem0.ai"
|
|
MAX_FILE_SIZE = 100_000 # skip files over 100 KB
|
|
TARGET_FILES = ["CLAUDE.md", "AGENTS.md", ".cursorrules", ".windsurfrules", "mem0.md"]
|
|
HASH_STORE = os.path.expanduser("~/.mem0/file_hashes.json")
|
|
LOCK_FILE = os.path.expanduser("~/.mem0/auto_import.lock")
|
|
|
|
|
|
def _acquire_lock() -> bool:
|
|
"""Try to acquire a file lock. Returns False if another instance is running."""
|
|
try:
|
|
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
|
|
fd = os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
os.write(fd, str(os.getpid()).encode())
|
|
os.close(fd)
|
|
return True
|
|
except FileExistsError:
|
|
try:
|
|
mtime = os.path.getmtime(LOCK_FILE)
|
|
import time
|
|
if time.time() - mtime > 120:
|
|
os.unlink(LOCK_FILE)
|
|
return _acquire_lock()
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _release_lock() -> None:
|
|
try:
|
|
os.unlink(LOCK_FILE)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
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()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def load_hashes() -> dict[str, str]:
|
|
"""Load the hash store from disk; return empty dict on any error."""
|
|
if not os.path.isfile(HASH_STORE):
|
|
return {}
|
|
try:
|
|
with open(HASH_STORE) as f:
|
|
return json.load(f)
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
|
|
def save_hashes(hashes: dict[str, str]) -> None:
|
|
"""Persist the hash store to disk."""
|
|
mem0_dir = os.path.expanduser("~/.mem0")
|
|
os.makedirs(mem0_dir, exist_ok=True)
|
|
try:
|
|
with open(HASH_STORE, "w") as f:
|
|
json.dump(hashes, f, indent=2)
|
|
except OSError as e:
|
|
log.warning("Could not save hash store: %s", e)
|
|
|
|
|
|
def already_imported(api_key: str, user_id: str, project_id: str, filename: str) -> bool:
|
|
body = json.dumps({
|
|
"query": filename,
|
|
"filters": {
|
|
"AND": [
|
|
{"user_id": user_id},
|
|
{"app_id": project_id},
|
|
{"metadata": {"source": "auto-import"}},
|
|
]
|
|
},
|
|
"top_k": 10,
|
|
"threshold": 0.0,
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"{API_URL}/v3/memories/search/",
|
|
data=body,
|
|
headers={"Content-Type": "application/json", "Authorization": f"Token {api_key}"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
data = json.loads(r.read())
|
|
results = data if isinstance(data, list) else data.get("results", [])
|
|
for result in results:
|
|
meta = result.get("metadata", {}) if isinstance(result, dict) else {}
|
|
file_field = meta.get("file", "")
|
|
if file_field == filename or file_field.startswith(f"{filename}["):
|
|
return True
|
|
return False
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _delete_stale_chunks(api_key: str, user_id: str, project_id: str, filename: str) -> int:
|
|
"""Find and delete existing chunks for a file before re-import. Returns count deleted."""
|
|
body = json.dumps({
|
|
"query": filename,
|
|
"filters": {
|
|
"AND": [
|
|
{"user_id": user_id},
|
|
{"app_id": project_id},
|
|
{"metadata": {"source": "auto-import"}},
|
|
]
|
|
},
|
|
"top_k": 20,
|
|
"threshold": 0.0,
|
|
}).encode()
|
|
req = urllib.request.Request(
|
|
f"{API_URL}/v3/memories/search/",
|
|
data=body,
|
|
headers={"Content-Type": "application/json", "Authorization": f"Token {api_key}"},
|
|
method="POST",
|
|
)
|
|
ids_to_delete = []
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
data = json.loads(r.read())
|
|
results = data if isinstance(data, list) else data.get("results", [])
|
|
for result in results:
|
|
if not isinstance(result, dict):
|
|
continue
|
|
meta = result.get("metadata", {})
|
|
file_field = meta.get("file", "")
|
|
if file_field == filename or file_field.startswith(f"{filename}["):
|
|
mid = result.get("id")
|
|
if mid:
|
|
ids_to_delete.append(mid)
|
|
except Exception as e:
|
|
log.warning("Failed to search for stale chunks of %s: %s", filename, e)
|
|
return 0
|
|
|
|
deleted = 0
|
|
for mid in ids_to_delete:
|
|
try:
|
|
del_req = urllib.request.Request(
|
|
f"{API_URL}/v1/memories/{mid}/",
|
|
headers={"Authorization": f"Token {api_key}"},
|
|
method="DELETE",
|
|
)
|
|
with urllib.request.urlopen(del_req, timeout=10):
|
|
deleted += 1
|
|
except Exception as e:
|
|
log.warning("Failed to delete stale chunk %s: %s", mid, e)
|
|
|
|
if deleted:
|
|
log.info("Deleted %d stale chunk(s) for %s before re-import", deleted, filename)
|
|
return deleted
|
|
|
|
|
|
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": [
|
|
{
|
|
"role": "user",
|
|
"content": f"## Project Profile: {filename}\n\nProject: {project_id}\n\n{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=15) as resp:
|
|
if resp.status in (200, 201):
|
|
log.info("Imported %s (project=%s)", filename, project_id)
|
|
return True
|
|
log.warning("API returned status %d for %s", resp.status, filename)
|
|
return False
|
|
except urllib.error.URLError as e:
|
|
log.warning("API call failed for %s: %s", filename, e)
|
|
return False
|
|
|
|
|
|
def main() -> None:
|
|
api_key = resolve_api_key()
|
|
if not api_key:
|
|
log.debug("MEM0_API_KEY not set, skipping auto-import")
|
|
return
|
|
|
|
cwd = os.environ.get("MEM0_CWD", "").strip() or os.getcwd()
|
|
user_id = resolve_user_id()
|
|
project_id = resolve_project_id(cwd)
|
|
branch = resolve_branch(cwd)
|
|
|
|
save_project_mapping(cwd, project_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
|
|
seen_content_hashes: set[str] = set()
|
|
|
|
for filename in TARGET_FILES:
|
|
filepath = ""
|
|
for search_dir in search_dirs:
|
|
candidate = os.path.join(search_dir, filename)
|
|
if os.path.isfile(candidate):
|
|
filepath = candidate
|
|
break
|
|
|
|
if not filepath:
|
|
log.debug("Not found, skipping: %s", filename)
|
|
continue
|
|
|
|
filepath = os.path.realpath(filepath)
|
|
|
|
try:
|
|
file_size = os.path.getsize(filepath)
|
|
except OSError:
|
|
log.debug("Cannot stat %s, skipping", filename)
|
|
continue
|
|
|
|
if file_size > MAX_FILE_SIZE:
|
|
log.debug("Skipping %s: size %d exceeds %d bytes", filename, file_size, MAX_FILE_SIZE)
|
|
continue
|
|
|
|
try:
|
|
current_hash = sha256_file(filepath)
|
|
except OSError as e:
|
|
log.debug("Cannot hash %s: %s", filename, e)
|
|
continue
|
|
|
|
if current_hash in seen_content_hashes:
|
|
log.debug("Duplicate content, skipping: %s (same as earlier file)", filename)
|
|
continue
|
|
seen_content_hashes.add(current_hash)
|
|
|
|
hash_key = f"{project_id}:{branch}:{filename}" if branch else f"{project_id}:{filename}"
|
|
if hashes.get(hash_key) == current_hash:
|
|
if already_imported(api_key, user_id, project_id, filename):
|
|
log.debug("Unchanged and still in mem0, skipping: %s", filename)
|
|
continue
|
|
log.info("Hash matches but memories missing server-side, re-importing: %s", filename)
|
|
|
|
elif already_imported(api_key, user_id, project_id, filename):
|
|
log.debug("Already in mem0, updating hash store: %s", filename)
|
|
hashes[hash_key] = current_hash
|
|
updated = True
|
|
continue
|
|
|
|
try:
|
|
with open(filepath, encoding="utf-8", errors="replace") as f:
|
|
content = f.read()
|
|
except OSError as e:
|
|
log.debug("Cannot read %s: %s", filename, e)
|
|
continue
|
|
|
|
_delete_stale_chunks(api_key, user_id, project_id, filename)
|
|
|
|
is_markdown = filename.endswith(".md")
|
|
if is_markdown:
|
|
chunks = filter_and_truncate(split_by_headers(content))
|
|
else:
|
|
chunks = filter_and_truncate([content])
|
|
|
|
if not chunks:
|
|
chunks = [content[:10000]]
|
|
|
|
success = True
|
|
for i, chunk in enumerate(chunks):
|
|
chunk_name = f"{filename}[{i+1}/{len(chunks)}]" if len(chunks) > 1 else filename
|
|
if not post_memory(api_key, chunk, user_id, chunk_name, project_id, branch):
|
|
success = False
|
|
|
|
if success:
|
|
hashes[hash_key] = current_hash
|
|
updated = True
|
|
|
|
if updated:
|
|
save_hashes(hashes)
|
|
else:
|
|
log.debug("No files imported this run")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if not _acquire_lock():
|
|
log.debug("Another auto_import instance is running — skipping")
|
|
sys.exit(0)
|
|
try:
|
|
main()
|
|
except Exception as e:
|
|
log.error("Unexpected error: %s", e)
|
|
finally:
|
|
_release_lock()
|
|
sys.exit(0)
|