Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/sync-upstream.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Sync Upstream

on:
workflow_dispatch:
schedule:
- cron: "17 * * * *"

permissions:
contents: write
pull-requests: write

jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout fork branch
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Add upstream remote
run: |
git remote add upstream https://github.com/mem0ai/mem0.git
git fetch upstream main

- name: Check for upstream changes
id: changes
run: |
if [ "$(git rev-list --count origin/main..upstream/main)" -eq 0 ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Stop when upstream is unchanged
if: steps.changes.outputs.changed != 'true'
run: echo "Upstream main is already in sync."

- name: Create sync branch
if: steps.changes.outputs.changed == 'true'
run: |
git checkout -B sync/upstream origin/main

- name: Merge upstream into sync branch
if: steps.changes.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git merge --no-edit upstream/main

- name: Push sync branch
if: steps.changes.outputs.changed == 'true'
run: |
git push --force-with-lease origin sync/upstream

- name: Create or update PR
if: steps.changes.outputs.changed == 'true'
id: pr
env:
GH_TOKEN: ${{ github.token }}
run: |
PR_NUMBER="$(gh pr list --head sync/upstream --base main --state open --json number --jq '.[0].number')"
if [ -n "$PR_NUMBER" ]; then
echo "PR already exists: #$PR_NUMBER"
else
gh pr create \
--base main \
--head sync/upstream \
--title "chore: sync upstream mem0" \
--body "Automated sync from mem0ai/mem0 main into this fork."
PR_NUMBER="$(gh pr list --head sync/upstream --base main --state open --json number --jq '.[0].number')"
fi
echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"

- name: Enable auto-merge
if: steps.changes.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr merge "${{ steps.pr.outputs.pr_number }}" --auto --squash
134 changes: 134 additions & 0 deletions integrations/mem0-plugin/scripts/_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
"""Mem0 REST compatibility helpers for plugin hooks.

Cloud remains the default. Set ``MEM0_API_MODE=self_hosted`` and
``MEM0_API_URL`` to target the self-hosted FastAPI server.
"""

from __future__ import annotations

import json
import os
import urllib.parse
import urllib.request
from typing import Any

CLOUD_API_URL = "https://api.mem0.ai"


def api_mode() -> str:
raw = os.environ.get("MEM0_API_MODE", "cloud").strip().lower().replace("-", "_")
return "self_hosted" if raw in {"self_hosted", "selfhosted", "self"} else "cloud"


def api_base_url() -> str:
return os.environ.get("MEM0_API_URL", CLOUD_API_URL).rstrip("/")


def auth_headers(api_key: str) -> dict[str, str]:
if api_mode() == "self_hosted":
header = os.environ.get("MEM0_AUTH_HEADER", "X-API-Key")
return {header: api_key}
return {"Authorization": f"Token {api_key}"}


def _request_json(
api_key: str,
path: str,
*,
method: str = "GET",
body: dict[str, Any] | None = None,
timeout: int = 15,
) -> tuple[int, Any]:
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {"Content-Type": "application/json", **auth_headers(api_key)}
req = urllib.request.Request(f"{api_base_url()}{path}", data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if not raw:
return resp.status, {}
if not isinstance(raw, (bytes, bytearray, str)):
return resp.status, {}
return resp.status, json.loads(raw)


def _self_hosted_body(body: dict[str, Any]) -> dict[str, Any]:
mapped = dict(body)
app_id = mapped.pop("app_id", None)
if app_id:
metadata = mapped.setdefault("metadata", {}) or {}
metadata["project"] = app_id
mapped["metadata"] = metadata
return mapped


def _self_hosted_filters(value: Any) -> Any:
if not isinstance(value, dict):
if isinstance(value, list):
return [_self_hosted_filters(item) for item in value]
return value

# Flatten AND clauses if present to support flat backend filters
flat: dict[str, Any] = {}
if "AND" in value and isinstance(value["AND"], list):
for item in value["AND"]:
if isinstance(item, dict):
for k, v in item.items():
flat[k] = v
# Also include any other top-level keys that aren't logical operators
for k, v in value.items():
if k not in ("AND", "OR", "NOT"):
flat[k] = v
else:
flat = value

mapped: dict[str, Any] = {}
for key, item in flat.items():
mapped["project" if key == "app_id" else key] = _self_hosted_filters(item)
return mapped


def add_memory(api_key: str, body: dict[str, Any], timeout: int = 15) -> tuple[int, Any]:
path = "/memories" if api_mode() == "self_hosted" else "/v3/memories/add/"
if api_mode() == "self_hosted":
body = _self_hosted_body(body)
return _request_json(api_key, path, method="POST", body=body, timeout=timeout)


def search_memories(api_key: str, body: dict[str, Any], timeout: int = 5) -> tuple[int, Any]:
path = "/search" if api_mode() == "self_hosted" else "/v3/memories/search/"
if api_mode() == "self_hosted" and "limit" in body and "top_k" not in body:
body = {**body, "top_k": body["limit"]}
if api_mode() == "self_hosted" and "filters" in body:
body = {**body, "filters": _self_hosted_filters(body["filters"])}
return _request_json(api_key, path, method="POST", body=body, timeout=timeout)


def list_memories(api_key: str, body: dict[str, Any], timeout: int = 5) -> tuple[int, Any]:
if api_mode() == "self_hosted":
filters = _self_hosted_filters(body.get("filters") or {})
params: dict[str, Any] = {}
for key in ("user_id", "agent_id", "run_id"):
if key in filters and isinstance(filters[key], str) and filters[key] != "*":
params[key] = filters[key]
if "page_size" in body:
params["top_k"] = body["page_size"]
elif "top_k" in body:
params["top_k"] = body["top_k"]
query = f"?{urllib.parse.urlencode(params)}" if params else ""
return _request_json(api_key, f"/memories{query}", method="GET", timeout=timeout)

page = body.get("page", 1)
page_size = body.get("page_size") or body.get("top_k") or 10
return _request_json(
api_key,
f"/v3/memories/?page={page}&page_size={page_size}",
method="POST",
body={"filters": body.get("filters", {})},
timeout=timeout,
)


def delete_memory(api_key: str, memory_id: str, timeout: int = 10) -> tuple[int, Any]:
if api_mode() == "self_hosted":
return _request_json(api_key, f"/memories/{memory_id}", method="DELETE", timeout=timeout)
return _request_json(api_key, f"/v1/memories/{memory_id}/", method="DELETE", timeout=timeout)
21 changes: 21 additions & 0 deletions integrations/mem0-plugin/scripts/_identity.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@

_SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd )"

# Define a fallback function for python3 if only python is available (common on Windows)
if ! command -v python3 >/dev/null 2>&1; then
if command -v python >/dev/null 2>&1; then
python3() {
python "$@"
}
export -f python3 2>/dev/null || true
fi
fi

# Resolve API key: env var > userConfig > shell profile extraction
if [ -z "${MEM0_API_KEY:-}" ] && [ -n "${CLAUDE_PLUGIN_OPTION_API_KEY:-}" ]; then
MEM0_API_KEY="$CLAUDE_PLUGIN_OPTION_API_KEY"
Expand Down Expand Up @@ -56,6 +66,17 @@ _mem0_resolve_identity() {
MEM0_RESOLVED_USER_ID="$(_mem0_resolve_identity)"
export MEM0_RESOLVED_USER_ID

_mem0_resolve_agent() {
if [ -n "${MEM0_AGENT_ID:-}" ]; then
printf '%s' "$MEM0_AGENT_ID"
return
fi
printf '%s' "codex"
}

MEM0_RESOLVED_AGENT_ID="$(_mem0_resolve_agent)"
export MEM0_RESOLVED_AGENT_ID

_MEM0_IDENTITY_ANNOTATION=""
if [ -n "${MEM0_USER_ID:-}" ] && [ "$MEM0_USER_ID" != "${USER:-default}" ]; then
_MEM0_IDENTITY_ANNOTATION=" (override; default: ${USER:-default})"
Expand Down
17 changes: 4 additions & 13 deletions integrations/mem0-plugin/scripts/_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@

from __future__ import annotations

import json
import os
import urllib.request

SEARCH_URL = "https://api.mem0.ai/v3/memories/search/"
from _api import search_memories as api_search_memories

SEARCH_TIMEOUT = 5


Expand All @@ -33,16 +32,8 @@ def should_rerank() -> bool:


def _do_search(api_key: str, payload: dict) -> list[dict]:
body = json.dumps(payload).encode()
req = urllib.request.Request(
SEARCH_URL,
data=body,
headers={"Authorization": f"Token {api_key}", "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=SEARCH_TIMEOUT) as r:
data = json.loads(r.read())
return data if isinstance(data, list) else data.get("results", [])
_status, data = api_search_memories(api_key, payload, timeout=SEARCH_TIMEOUT)
return data if isinstance(data, list) else data.get("results", [])


def search_memories(
Expand Down
28 changes: 8 additions & 20 deletions integrations/mem0-plugin/scripts/auto_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
import os
import sys
import urllib.error
import urllib.request

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _api import add_memory
from _identity import resolve_api_key, resolve_user_id
from _project import resolve_branch, resolve_project_id

Expand All @@ -39,7 +39,6 @@
except OSError:
pass

API_URL = "https://api.mem0.ai"
TAIL_LINES = 200
MAX_CONTENT_CHARS = 8000
MIN_CONTENT_CHARS = 100
Expand Down Expand Up @@ -127,25 +126,14 @@ def store_exchange(api_key: str, messages: list[dict], user_id: str,
"infer": True,
}

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):
result = json.loads(resp.read())
log.info("Auto-captured: event_id=%s status=%s",
result.get("event_id", "?"), result.get("status", "?"))
return True
log.warning("API returned status %d", resp.status)
return False
status, result = add_memory(api_key, body, timeout=15)
if status in (200, 201):
log.info("Auto-captured: event_id=%s status=%s",
result.get("event_id", "?"), result.get("status", "?"))
return True
log.warning("API returned status %d", status)
return False
except urllib.error.URLError as e:
log.warning("API call failed: %s", e)
return False
Expand Down
Loading
Loading