Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:03:45 +08:00

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)