feat(integrations): declare which surface each client is, and its version (#7326)

This commit is contained in:
Saket Aryan
2026-09-18 14:13:11 +05:30
committed by GitHub
parent 4e38b057fd
commit 1a5c7ad28c
31 changed files with 812 additions and 57 deletions
+2 -1
View File
@@ -31,7 +31,8 @@ export class PlatformBackend implements Backend {
this.headers = {
Authorization: `Token ${config.apiKey}`,
"Content-Type": "application/json",
"X-Mem0-Source": "cli",
"X-Mem0-Source": "CLI",
"X-Mem0-Client": `mem0-cli-node/${CLI_VERSION}`,
"X-Mem0-Client-Language": "node",
"X-Mem0-Client-Version": CLI_VERSION,
};
+2 -1
View File
@@ -27,7 +27,8 @@ class PlatformBackend(Backend):
headers={
"Authorization": f"Token {config.api_key}",
"Content-Type": "application/json",
"X-Mem0-Source": "cli",
"X-Mem0-Source": "CLI",
"X-Mem0-Client": f"mem0-cli-python/{__version__}",
"X-Mem0-Client-Language": "python",
"X-Mem0-Client-Version": __version__,
},
+43
View File
@@ -49,6 +49,48 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
- **`zapier-mem0/`** is a Zapier Platform CLI app: add, search, get, delete. It deploys to Zapier, not npm, so it is **not** in the release router. Deploy it with `gh workflow run zapier-mem0-cd.yml --ref main` (needs the `ZAPIER_DEPLOY_KEY` secret).
- **`mem0-strands/`** is a native Strands `MemoryStore` (Python, published to PyPI as `mem0-strands`). It plugs into the Strands `MemoryManager` for automatic recall and server-side extraction, over the hosted Mem0 platform or self-hosted Mem0 OSS. The package lives under `mem0-strands/python/`.
## Surface attribution
Every integration tells the Mem0 platform which surface it is. Three headers,
and the rules on them are what keep one layer from erasing another:
| Header | Carries | Rule |
|--------|---------|------|
| `X-Mem0-Source` | one canonical source value | **set-once** — write only if absent |
| `X-Application` | the host app it runs inside | **set-once** — write only if absent |
| `X-Mem0-Client` | `name/version`, outermost first | **append-only** — add yourself, never replace |
Set-once means check-then-set, never assignment. An integration that wraps the
SDK is the outermost layer and sets the source; the SDK underneath defers to it.
Assignment is exactly how every agent plugin came to be indistinguishable from
every other one at the platform.
How to declare it from an integration, in order of preference:
1. Send the headers yourself, if you make the HTTP call directly.
2. Pass `source` in the call options, if you go through an SDK.
3. Set `MEM0_SOURCE` / `MEM0_APPLICATION` / `MEM0_CLIENT_STACK` in the
environment before constructing the client. The SDKs read these and defer to
anything already present.
Append-only applies where a stack can actually form: an SDK handed a client that
already carries `X-Mem0-Client` appends itself rather than replacing. An SDK
constructed with no outer context simply reports itself, which is correct — it
is the outermost layer in that process.
The backend recognizes a fixed list of source values and buckets everything else
into `OTHERS`. A new value has to land in the platform's `EventSource` enum, so
do not invent one without that change going in too.
`X-Application` is allowlisted the same way, and this one has a rule of its own:
**omit the header when you do not know the host.** A value outside the allowlist
is discarded server-side, so guessing produces an event that claims an
attribution we do not actually have. The portable bundle is the case that
matters. It runs in whatever editor a user drops it into, so its build leaves
`PLATFORM_APPLICATION` empty and `memory_core` sends no header at all, while the
native bundles each name the host they were generated for. If you add a build
target, decide which of those two it is.
## Adding an integration
1. For a native coding-agent host, add `integrations/<name>-plugin/` with `plugin-build.json`, its manifest, and a thin adapter, then generate its shared runtime. Portable clients use the single `mem0-agent-plugin/` package. Independent TypeScript integrations stay self-contained and import shared lifecycle behavior from `agent-plugin-core/typescript/`.
@@ -59,3 +101,4 @@ Run the type check after every TypeScript change: `pnpm run typecheck` or `tsc -
5. If it is a Claude Code or editor marketplace plugin, register the generated native bundle path in the applicable marketplace files. Preserve the existing public plugin name.
6. Document it under `docs/integrations/` and add the page to `docs/docs.json` and `docs/llms.txt`.
7. Add rows to the table above and to the CI/CD tables in [`../.github/AGENTS.md`](../.github/AGENTS.md).
8. Send the three headers in [Surface attribution](#surface-attribution), and land the matching `EventSource` value on the platform in the same week. Until it exists, your traffic reports as `OTHERS`.
+13 -3
View File
@@ -81,15 +81,23 @@ def replace_output(staged: Path, output: Path) -> Path:
return output
def _render_harness_id(host: str) -> str:
def _render_harness_id(host: str, *, portable: bool = False) -> str:
"""Emit core/_harness_id.py for one host.
Carries both vocabularies from a single definition: the PostHog `source` tag
and the platform's X-Mem0-Source / X-Application pair. Keeping them together
is what stops the two from drifting into separate vocabularies for the same
thing.
The portable bundle runs in whatever editor a user drops it into, so it does
not know its host and must not guess one. HARNESS_ID stays "coding-agent",
which is true and useful for grouping in PostHog, but PLATFORM_APPLICATION is
left empty: X-Application names a real host app, is checked against an
allowlist server-side, and a value that is always discarded is worse than no
value -- it reads like an attribution we have and do not.
"""
tag = host.upper().replace("-", "_") + "_PLUGIN"
application = "" if portable else host
return (
'"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n'
"\n"
@@ -98,8 +106,10 @@ def _render_harness_id(host: str) -> str:
"\n"
"# Platform-side vocabulary (mem0_event.source + X-Application). The whole\n"
"# plugin family is one source; which editor it runs in is the application.\n"
"# An empty application means the host is unknown, and memory_core omits\n"
"# the header entirely rather than sending a placeholder.\n"
'PLATFORM_SOURCE = "MEM0_PLUGIN"\n'
f'PLATFORM_APPLICATION = "{host}"\n'
f'PLATFORM_APPLICATION = "{application}"\n'
)
@@ -122,7 +132,7 @@ def _bundle_python(
# to call telemetry.init(). mcp_server.py and the detached telemetry.py sender
# never did, which is how MCP searches reported harness=generic and every
# batch they drained was labelled MEM0_PLUGIN regardless of the real host.
(core / "_harness_id.py").write_text(_render_harness_id(host), encoding="utf-8")
(core / "_harness_id.py").write_text(_render_harness_id(host, portable=portable), encoding="utf-8")
values = {
"PLUGIN_ROOT": plugin_root,
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1835,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1862,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2008,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
@@ -70,6 +70,38 @@ def test_portable_bundle_is_conformant_and_self_contained(tmp_path: Path) -> Non
assert not any(path.is_symlink() for path in root.rglob("*"))
def _harness_identity(root: Path) -> dict[str, str]:
"""Read the generated core/_harness_id.py without importing it."""
values: dict[str, str] = {}
for line in (root / "core" / "_harness_id.py").read_text(encoding="utf-8").splitlines():
if "=" in line and not line.lstrip().startswith("#"):
name, _, raw = line.partition("=")
values[name.strip()] = raw.strip().strip('"')
return values
def test_the_portable_bundle_declares_no_host_application(tmp_path: Path) -> None:
"""It runs in whatever editor a user drops it into, so it cannot know the host.
X-Application is allowlisted server-side. A guessed value is silently dropped
there, which is the worst outcome: the wire says we know the host and the
stored event says we do not.
"""
identity = _harness_identity(build("mem0-agent-plugin", "portable", tmp_path / "portable"))
assert identity["PLATFORM_APPLICATION"] == ""
# The PostHog-side label is still useful for grouping and stays populated.
assert identity["HARNESS_ID"] == "coding-agent"
assert identity["PLATFORM_SOURCE"] == "MEM0_PLUGIN"
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
def test_a_native_bundle_names_the_host_it_was_built_for(host: str, tmp_path: Path) -> None:
identity = _harness_identity(build(host, "native", tmp_path / host))
assert identity["PLATFORM_APPLICATION"] == host
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
root = build(host, "native", tmp_path / host)
@@ -179,6 +179,33 @@ def test_source_tag_defaults_agree_between_the_two_modules():
assert left == right == "KIMI_PLUGIN"
def test_the_plugin_declares_its_surface_in_the_body_and_the_headers():
"""Body and headers both, because only the body works on every backend."""
core = _core_dir("claude-code-plugin")
if not core.exists():
pytest.skip("claude-code-plugin is not built in this tree")
with tempfile.TemporaryDirectory() as tmp:
out = _run(
core,
Path(tmp),
"import json, memory_core\n"
"h = memory_core.platform_headers('k')\n"
"print(json.dumps({'source': h.get('X-Mem0-Source'),"
" 'app': h.get('X-Application'),"
" 'client': h.get('X-Mem0-Client'),"
" 'auth': h.get('Authorization'),"
" 'ctype': h.get('Content-Type')}))",
)
headers = json.loads(out)
assert headers["source"] == "MEM0_PLUGIN"
assert headers["app"] == "claude-code"
assert headers["client"].startswith("mem0-plugin/")
# The transport headers the three call sites relied on must survive.
assert headers["auth"] == "Token k"
assert headers["ctype"] == "application/json"
def _session_start(core: Path, data_dir: Path) -> list[str]:
"""Drive the real hook_runner session-start path and return lifecycle events."""
recorded = "\n".join(
@@ -5,5 +5,7 @@ SOURCE_TAG = "ANTIGRAVITY_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "antigravity"
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1835,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1862,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2008,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
@@ -5,5 +5,7 @@ SOURCE_TAG = "CLAUDE_CODE_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "claude-code"
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1835,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1862,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2008,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
@@ -5,5 +5,7 @@ SOURCE_TAG = "CODEX_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "codex"
+38 -3
View File
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1835,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1862,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2008,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
@@ -5,5 +5,7 @@ SOURCE_TAG = "CURSOR_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "cursor"
+38 -3
View File
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1835,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1862,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2008,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
+3 -2
View File
@@ -26,8 +26,9 @@ export const name = "mem0";
export const inject = ["tools", "systemPrompt"];
// Tags writes so Mem0's backend attributes them to this integration in
// telemetry. The backend's KNOWN_EVENT_SOURCES allowlist recognizes this value;
// anything outside it buckets into "OTHERS".
// telemetry. Values outside the backend's KNOWN_EVENT_SOURCES allowlist bucket
// into "OTHERS"; this one is added by mem0ai/platform#3602 and reads as OTHERS
// until that ships.
const SOURCE = "DEEPSEEK_HARNESS";
const DEFAULT_SEARCH_LIMIT = 10;
@@ -5,5 +5,7 @@ SOURCE_TAG = "KIMI_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "kimi"
+38 -3
View File
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1835,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1862,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2008,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
@@ -5,5 +5,7 @@ SOURCE_TAG = "CODING_AGENT_PLUGIN"
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
# plugin family is one source; which editor it runs in is the application.
# An empty application means the host is unknown, and memory_core omits
# the header entirely rather than sending a placeholder.
PLATFORM_SOURCE = "MEM0_PLUGIN"
PLATFORM_APPLICATION = "coding-agent"
PLATFORM_APPLICATION = ""
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
return batches
# Platform surface attribution. Read from the generated per-host module so a new
# entrypoint is correct without remembering to configure anything.
try: # pragma: no cover - absent only in the un-built shared source tree
from _harness_id import PLATFORM_APPLICATION as _PLATFORM_APPLICATION
from _harness_id import PLATFORM_SOURCE as _PLATFORM_SOURCE
except ImportError:
_PLATFORM_SOURCE = "MEM0_PLUGIN"
_PLATFORM_APPLICATION = ""
def platform_headers(key: str) -> dict[str, str]:
"""Auth plus the three surface-identity headers.
X-Mem0-Source and X-Application are set-once by contract: this is the
outermost layer, so it sets them, and nothing below may overwrite them.
X-Mem0-Client is append-only — anything downstream adds itself to the tail.
"""
headers = {
"Authorization": f"Token {key}",
"Content-Type": "application/json",
"X-Mem0-Source": _PLATFORM_SOURCE,
"X-Mem0-Client": f"mem0-plugin/{PLUGIN_VERSION}",
}
if _PLATFORM_APPLICATION:
headers["X-Application"] = _PLATFORM_APPLICATION
return headers
def _request_json(
url: str, key: str, payload: dict[str, Any], timeout: float
) -> tuple[dict[str, Any] | list[Any], int, int]:
@@ -1807,7 +1835,7 @@ def _request_json(
request = urllib.request.Request(
url,
data=raw,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="POST",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1834,7 +1862,7 @@ def _get_json(
) -> tuple[dict[str, Any] | list[Any], int]:
request = urllib.request.Request(
url,
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="GET",
)
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -1980,6 +2008,13 @@ def flush_session(
"user_id": write_user,
"app_id": repo.app_id,
"run_id": session_id,
# Top level, not metadata: the backend reads `source` from the body or
# the query string, never from metadata, which is where this used to
# sit. The X-Mem0-Source header is also read, but only from the
# platform release that ships alongside this change, so the body value
# is what makes attribution work on both. The harness tag stays in
# metadata as hook provenance.
"source": _PLATFORM_SOURCE,
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
"custom_instructions": PERSONAL_MEMORY_INSTRUCTIONS,
@@ -2523,7 +2558,7 @@ def _collect_memory_ids(
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
request = urllib.request.Request(
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
headers=platform_headers(key),
method="DELETE",
)
try:
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { applySurfaceHeaders, PLATFORM_APPLICATION, PLATFORM_SOURCE } from "./attribution.ts";
function client(headers: Record<string, string> = {}) {
return { headers: { Authorization: "Token k", ...headers } } as never;
}
describe("applySurfaceHeaders", () => {
it("stamps the shared client so every path is attributed, not just commands", () => {
const mem0 = client();
applySurfaceHeaders(mem0);
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
expect(headers["X-Mem0-Source"]).toBe(PLATFORM_SOURCE);
expect(headers["X-Application"]).toBe(PLATFORM_APPLICATION);
expect(headers["X-Mem0-Client"]).toMatch(/^mem0-pi-agent\//);
expect(headers.Authorization).toBe("Token k");
});
it("defers to a surface an outer wrapper already declared", () => {
const mem0 = client({ "X-Mem0-Source": "OPENCLAW", "X-Application": "vscode" });
applySurfaceHeaders(mem0);
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
expect(headers["X-Mem0-Source"]).toBe("OPENCLAW");
expect(headers["X-Application"]).toBe("vscode");
});
it("appends to the client stack rather than replacing it", () => {
const mem0 = client({ "X-Mem0-Client": "openclaw/2.1.0" });
applySurfaceHeaders(mem0);
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
expect(headers["X-Mem0-Client"]).toMatch(/^openclaw\/2\.1\.0, mem0-pi-agent\//);
});
it("treats a blank header as absent", () => {
const mem0 = client({ "X-Mem0-Source": " " });
applySurfaceHeaders(mem0);
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
expect(headers["X-Mem0-Source"]).toBe(PLATFORM_SOURCE);
});
it("bounds the stack so a long chain cannot grow the header without limit", () => {
const mem0 = client({ "X-Mem0-Client": "a/1, b/1, c/1, d/1, e/1" });
applySurfaceHeaders(mem0);
const headers = (mem0 as unknown as { headers: Record<string, string> }).headers;
expect(headers["X-Mem0-Client"].split(",").length).toBeLessThanOrEqual(4);
expect(headers["X-Mem0-Client"].length).toBeLessThanOrEqual(200);
});
});
describe("client stack bounding", () => {
it("keeps our own entry when the caller already filled the stack", () => {
// The defect: pushing then trimming to four dropped exactly the entry this
// function exists to add, so we vanished from our own stack.
const mem0 = client({ "X-Mem0-Client": "a/1, b/2, c/3, d/4" });
applySurfaceHeaders(mem0);
const stack = (mem0 as unknown as { headers: Record<string, string> }).headers["X-Mem0-Client"];
expect(stack).toMatch(/mem0-pi-agent\//);
expect(stack.split(",").length).toBeLessThanOrEqual(4);
});
it("drops whole entries at the character cap, never a fragment", () => {
const long = `${"n".repeat(90)}/1.0, ${"m".repeat(90)}/1.0, ${"o".repeat(90)}/1.0`;
const mem0 = client({ "X-Mem0-Client": long });
applySurfaceHeaders(mem0);
const stack = (mem0 as unknown as { headers: Record<string, string> }).headers["X-Mem0-Client"];
expect(stack.length).toBeLessThanOrEqual(200);
expect(stack.endsWith("/0.0.0") || /mem0-pi-agent\/[\w.\-]+$/.test(stack)).toBe(true);
// Every surviving entry is whole: name/version, no severed tail.
for (const entry of stack.split(",")) {
expect(entry.trim()).toMatch(/^[^/]+\/[^/]+$/);
}
});
});
@@ -0,0 +1,65 @@
import type MemoryClient from "mem0ai";
import * as fs from "node:fs";
/** Surface identity for this plugin, as the platform's EventSource knows it. */
export const PLATFORM_SOURCE = "PI_AGENT";
/** Host app the plugin runs inside. Allowlisted server-side. */
export const PLATFORM_APPLICATION = "pi";
const PLUGIN_VERSION = (() => {
try {
return JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
).version as string;
} catch {
return "unknown";
}
})();
const MAX_STACK_ENTRIES = 4;
const MAX_STACK_CHARS = 200;
/**
* Append our own entry and bound the result, dropping WHOLE entries.
*
* Neither cap cuts characters: slicing the joined string severs an identifier
* and leaves a fragment the platform parses as a real client name. And the
* reserved slot is ours, since it is the only entry this layer can vouch for.
*/
function boundedStack(callerEntries: string[], own: string): string {
const kept: string[] = [];
let budget = MAX_STACK_CHARS - own.length;
for (const entry of callerEntries.slice(0, MAX_STACK_ENTRIES - 1)) {
const cost = entry.length + ", ".length;
if (cost > budget) break;
budget -= cost;
kept.push(entry);
}
return [...kept, own].join(", ");
}
/**
* Stamp surface identity onto the shared client, once, at construction.
*
* Tagging individual call sites was not enough: automatic recall, capture, the
* memory tools and deletion all go through this same client, so everything
* except the explicit slash commands reached the platform as generic SDK
* traffic. Every request method in the SDK sends `this.headers`, so setting
* them here covers all of them.
*
* X-Mem0-Source and X-Application are set-once, so a wrapper that already named
* a surface keeps it. X-Mem0-Client is append-only, so the platform sees the
* whole chain rather than only the last speaker.
*/
export function applySurfaceHeaders(client: MemoryClient): void {
const headers = client.headers as Record<string, string>;
if (!headers["X-Mem0-Source"]?.trim()) headers["X-Mem0-Source"] = PLATFORM_SOURCE;
if (!headers["X-Application"]?.trim()) headers["X-Application"] = PLATFORM_APPLICATION;
const existing = (headers["X-Mem0-Client"] ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
headers["X-Mem0-Client"] = boundedStack(existing, `mem0-pi-agent/${PLUGIN_VERSION}`);
}
+14 -2
View File
@@ -1,3 +1,4 @@
import type { SearchMemoryOptions } from "mem0ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type MemoryClient from "mem0ai";
import type { Mem0Config, ScopeContext, Scope } from "./types.ts";
@@ -5,6 +6,12 @@ import { DEFAULT_CUSTOM_CATEGORIES } from "./types.ts";
import { resolveSearchFilters, resolveAddParams } from "./memory/scoping.ts";
import { formatMemoryList, formatMemoryCompact, groupByCategory } from "./memory/formatting.ts";
import { captureCommandEvent } from "./telemetry.ts";
import { PLATFORM_SOURCE } from "./attribution.ts";
// Wire identity is set once on the shared client in entry.ts, which covers
// every path including recall, capture, tools and deletion. It stays in the
// body of the two calls below as well: body `source` is what the backend reads
// when the header is absent.
const SEARCH_TOP_K = 10;
@@ -29,7 +36,12 @@ export function registerCommands(
threshold: config.searchThreshold,
topK: SEARCH_TOP_K,
rerank: true,
});
source: PLATFORM_SOURCE,
// Widened by exactly this one property. `source` reaches the wire via the
// SDK's camelToSnakeKeys spread, but it is absent from SearchMemoryOptions
// in the published mem0ai types. A blanket `as never` would also disable
// checking of filters, threshold, topK and rerank above.
} as SearchMemoryOptions & { source: string });
return result.results ?? [];
};
@@ -45,7 +57,7 @@ export function registerCommands(
const addParams = resolveAddParams(config.defaultScope, getScopeCtx());
const result = await mem0.add(
[{ role: "user", content: text }],
{ ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES, infer: false },
{ ...addParams, customCategories: DEFAULT_CUSTOM_CATEGORIES, infer: false, source: PLATFORM_SOURCE },
);
captureCommandEvent("mem0-remember", {}, telemetryCtx);
@@ -10,6 +10,7 @@ import { captureEvent } from "./telemetry.ts";
import * as os from "node:os";
import type { ScopeContext } from "./types.ts";
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
import { applySurfaceHeaders } from "./attribution.ts";
export { buildRecallContext } from "../../agent-plugin-core/typescript/src/lifecycle.ts";
@@ -29,6 +30,11 @@ export default function mem0Extension(pi: ExtensionAPI): void {
}
const mem0 = new MemoryClient({ apiKey: config.apiKey });
// Every path below shares this client: automatic recall, capture, the memory
// tools and deletion as well as the slash commands. Attribution belongs here
// rather than on individual calls, or everything except the commands reports
// as generic SDK traffic.
applySurfaceHeaders(mem0);
const scopeCtx: ScopeContext = {
userId: resolveUserId(config.userId),
+17 -2
View File
@@ -1,3 +1,10 @@
declare const __MEM0_PROVIDER_VERSION__: string | undefined;
// Replaced at build time by tsup `define`. The fallback only applies when the
// source is run unbundled, such as in tests.
const PROVIDER_VERSION =
typeof __MEM0_PROVIDER_VERSION__ !== "undefined" ? __MEM0_PROVIDER_VERSION__ : "dev";
import { LanguageModelV3Prompt } from '@ai-sdk/provider';
import { Mem0ConfigSettings } from './mem0-types';
import { loadApiKey } from '@ai-sdk/provider-utils';
@@ -277,7 +284,11 @@ const searchInternalMemories = async (query: string, config?: Mem0ConfigSettings
method: 'POST',
headers: {
Authorization: `Token ${apiKey}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
// Surface attribution. Set-once by contract: this wrapper is the
// outermost layer on these raw fetch calls.
'X-Mem0-Source': 'VERCEL_AI_SDK',
'X-Mem0-Client': `mem0-vercel-ai-provider/${PROVIDER_VERSION}`
},
body: JSON.stringify(body),
};
@@ -331,7 +342,11 @@ const updateMemories = async (messages: Array<Message>, config?: Mem0ConfigSetti
method: 'POST',
headers: {
Authorization: `Token ${apiKey}`,
'Content-Type': 'application/json'
'Content-Type': 'application/json',
// Surface attribution. Set-once by contract: this wrapper is the
// outermost layer on these raw fetch calls.
'X-Mem0-Source': 'VERCEL_AI_SDK',
'X-Mem0-Client': `mem0-vercel-ai-provider/${PROVIDER_VERSION}`
},
body: JSON.stringify(body),
};
+1
View File
@@ -12,6 +12,7 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"types": ["@types/node", "jest"],
@@ -1,4 +1,5 @@
import { defineConfig } from 'tsup'
import pkg from './package.json'
export default defineConfig([
{
@@ -6,5 +7,11 @@ export default defineConfig([
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
sourcemap: true,
// Injected rather than written in the source. A hardcoded literal matches
// package.json on the day it is written and misreports the client version
// from the next release bump onwards. Same mechanism as mem0-ts.
define: {
__MEM0_PROVIDER_VERSION__: JSON.stringify(pkg.version),
},
},
])
+61
View File
@@ -95,6 +95,66 @@ interface ClientIdentity {
const IDENTITY_CACHE_MAX_DEFAULT = 50;
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
declare const __MEM0_SDK_VERSION__: string | undefined;
// Injected by tsup (see mem0-ts/tsup.config.ts `define`), the same mechanism
// telemetry.ts already uses. A hardcoded literal goes stale at the next release
// bump and then misreports the client version forever.
const SDK_VERSION =
typeof __MEM0_SDK_VERSION__ !== "undefined" ? __MEM0_SDK_VERSION__ : "dev";
const MAX_STACK_ENTRIES = 4;
const MAX_STACK_CHARS = 200;
/**
* Append our own entry and bound the result, dropping WHOLE entries.
*
* Neither cap cuts characters: slicing the joined string severs an identifier
* and leaves a fragment the platform parses as a real client name. And the
* reserved slot is ours. Pushing first and then trimming to four dropped exactly
* the entry this exists to add whenever a caller already sent four, so we
* vanished from our own stack while every caller claim survived.
*/
function boundedStack(callerEntries: string[], own: string): string {
const kept: string[] = [];
let budget = MAX_STACK_CHARS - own.length;
for (const entry of callerEntries.slice(0, MAX_STACK_ENTRIES - 1)) {
const cost = entry.length + ", ".length;
if (cost > budget) break;
budget -= cost;
kept.push(entry);
}
return [...kept, own].join(", ");
}
/**
* Surface-identity headers.
*
* X-Mem0-Source and X-Application are SET-ONCE by contract: whichever layer is
* outermost sets them and nothing below overwrites, so a plugin wrapping this
* SDK keeps its own identity. X-Mem0-Client is APPEND-ONLY - every layer adds
* itself, so the platform sees the whole stack and not just the last speaker.
*/
function surfaceHeaders(): Record<string, string> {
const env: Record<string, string | undefined> =
typeof process !== "undefined" && process.env ? process.env : {};
const existing = (env.MEM0_CLIENT_STACK ?? "").trim();
const entries = existing
? existing
.split(",")
.map((part) => part.trim())
.filter(Boolean)
: [];
const headers: Record<string, string> = {
"X-Mem0-Client": boundedStack(entries, `mem0-js/${SDK_VERSION}`),
};
const source = (env.MEM0_SOURCE ?? "").trim();
if (source) headers["X-Mem0-Source"] = source;
const application = (env.MEM0_APPLICATION ?? "").trim();
if (application) headers["X-Application"] = application;
return headers;
}
export default class MemoryClient {
apiKey: string;
host: string;
@@ -129,6 +189,7 @@ export default class MemoryClient {
this.headers = {
Authorization: `Token ${this.apiKey}`,
"Content-Type": "application/json",
...surfaceHeaders(),
};
this.client = axios.create({
+3
View File
@@ -30,6 +30,9 @@ export interface SearchMemoryOptions {
showExpired?: boolean;
referenceDate?: string | number;
keywordSearch?: boolean;
/** Surface that produced the call, e.g. "OPENCLAW". Must be a value the
* backend's EventSource enum knows, or it buckets into OTHERS. */
source?: string;
}
export interface GetAllMemoryOptions {
+94 -24
View File
@@ -79,6 +79,95 @@ def _maybe_alias_anon_to_email(user_email):
logger.debug("Failed to alias anon telemetry to %r: %s", user_email, e)
def _sdk_version() -> str:
"""Resolved here rather than imported from the package root, which would cycle."""
try:
import importlib.metadata
return importlib.metadata.version("mem0ai")
except Exception:
return "unknown"
def _apply_client_headers(client: Any, api_key: str, user_id: str) -> None:
"""Merge our headers into a caller-supplied client without erasing theirs.
A wrapper may hand us a client already carrying its own X-Mem0-Source or a
partial X-Mem0-Client stack. Blanket update() replaced both, which is the
opposite of the set-once / append-only contract: the outermost layer is the
one whose identity should survive.
"""
existing = client.headers
mine = _client_headers(api_key, user_id)
outer_stack = existing.get("X-Mem0-Client")
if outer_stack:
entries = [part.strip() for part in str(outer_stack).split(",") if part.strip()]
mine["X-Mem0-Client"] = _bounded_stack(entries, f"mem0-python/{_sdk_version()}")
for name, value in mine.items():
if name in ("X-Mem0-Source", "X-Application") and existing.get(name):
continue
existing[name] = value
MAX_STACK_ENTRIES = 4
MAX_STACK_CHARS = 200
def _bounded_stack(caller_entries, own: str) -> str:
"""Append our own entry and bound the result, dropping WHOLE entries.
Two rules, and the second is the one that was wrong. Neither cap cuts
characters: a blunt slice severs an identifier and leaves a fragment that
parses as a real client name. And the reserved slot is OURS. Appending first
and then trimming to four dropped exactly the entry this function exists to
add, every time a caller already sent four, so the SDK vanished from its own
stack while the caller's claims all survived.
"""
kept = []
budget = MAX_STACK_CHARS - len(own)
for entry in list(caller_entries)[: MAX_STACK_ENTRIES - 1]:
cost = len(entry) + len(", ")
if cost > budget:
break
budget -= cost
kept.append(entry)
return ", ".join(kept + [own])
def _client_headers(api_key: str, user_id: str) -> Dict[str, str]:
"""Auth plus surface-identity headers.
X-Mem0-Source and X-Application are SET-ONCE by contract: whichever layer is
outermost sets them, and nothing below overwrites. A plugin or harness that
wraps this SDK therefore keeps its own identity — it declares via MEM0_SOURCE
/ MEM0_APPLICATION and the SDK defers.
X-Mem0-Client is APPEND-ONLY: every layer adds itself, so the platform sees
the whole stack rather than only whoever spoke last.
"""
headers = {
"Authorization": f"Token {api_key}",
"Mem0-User-ID": user_id,
"X-Mem0-Client": _client_stack(),
}
source = os.getenv("MEM0_SOURCE", "").strip()
if source:
headers["X-Mem0-Source"] = source
application = os.getenv("MEM0_APPLICATION", "").strip()
if application:
headers["X-Application"] = application
return headers
def _client_stack() -> str:
"""This SDK appended to any stack an outer layer already declared."""
existing = os.getenv("MEM0_CLIENT_STACK", "").strip()
entries = [part.strip() for part in existing.split(",") if part.strip()] if existing else []
return _bounded_stack(entries, f"mem0-python/{_sdk_version()}")
class MemoryClient:
"""Client for interacting with the Mem0 API.
@@ -129,19 +218,11 @@ class MemoryClient:
self.client = client
# Ensure the client has the correct base_url and headers
self.client.base_url = httpx.URL(self.host)
self.client.headers.update(
{
"Authorization": f"Token {self.api_key}",
"Mem0-User-ID": self.user_id,
}
)
_apply_client_headers(self.client, self.api_key, self.user_id)
else:
self.client = httpx.Client(
base_url=self.host,
headers={
"Authorization": f"Token {self.api_key}",
"Mem0-User-ID": self.user_id,
},
headers=_client_headers(self.api_key, self.user_id),
timeout=300,
)
self.user_email = self._validate_api_key()
@@ -1018,19 +1099,11 @@ class AsyncMemoryClient:
self.async_client = client
# Ensure the client has the correct base_url and headers
self.async_client.base_url = httpx.URL(self.host)
self.async_client.headers.update(
{
"Authorization": f"Token {self.api_key}",
"Mem0-User-ID": self.user_id,
}
)
_apply_client_headers(self.async_client, self.api_key, self.user_id)
else:
self.async_client = httpx.AsyncClient(
base_url=self.host,
headers={
"Authorization": f"Token {self.api_key}",
"Mem0-User-ID": self.user_id,
},
headers=_client_headers(self.api_key, self.user_id),
timeout=300,
)
@@ -1053,10 +1126,7 @@ class AsyncMemoryClient:
params = self._prepare_params()
response = requests.get(
f"{self.host}/v1/ping/",
headers={
"Authorization": f"Token {self.api_key}",
"Mem0-User-ID": self.user_id,
},
headers=_client_headers(self.api_key, self.user_id),
params=params,
)
response.raise_for_status()
+63
View File
@@ -0,0 +1,63 @@
"""Surface-identity headers, and that a client can be constructed at all.
The construction test exists because it was not there: a signature change to
_bounded_stack missed the _client_stack call site, every MemoryClient(...) raised
TypeError, and the whole suite stayed green because nothing built one.
"""
import os
from unittest.mock import patch
from mem0.client.main import _bounded_stack, _client_headers, _client_stack
def test_a_client_can_be_constructed():
from mem0 import MemoryClient
# _validate_api_key normally populates org/project from the API response;
# stubbing it leaves them None, which a later accessor rejects. Set them the
# way a real validation would. This test is about construction reaching the
# header stage at all.
def _stub(self):
self.org_id, self.project_id = "org", "proj"
with patch.object(MemoryClient, "_validate_api_key", _stub):
client = MemoryClient(api_key="m0-test")
assert client.client.headers["X-Mem0-Client"].startswith("mem0-python/")
# AsyncMemoryClient is deliberately not constructed here: its validation path
# makes a real request to /v1/ping/, and a unit test that needs the network is
# worse than none. It shares _client_headers with the sync client, which is the
# code the construction test above actually guards.
def test_headers_carry_this_sdk():
headers = _client_headers("m0-test", "u1")
assert headers["X-Mem0-Client"].startswith("mem0-python/")
def test_our_entry_survives_a_caller_that_already_filled_the_stack():
# Appending first and trimming to four dropped exactly the entry the
# function exists to add.
stack = _bounded_stack(["a/1", "b/2", "c/3", "d/4"], "mem0-python/9.9.9")
assert "mem0-python/9.9.9" in stack
assert len(stack.split(",")) <= 4
def test_the_character_cap_drops_whole_entries_not_characters():
long_entries = [f"{'n' * 90}/1.0", f"{'m' * 90}/1.0", "c/3"]
stack = _bounded_stack(long_entries, "mem0-python/9.9.9")
assert len(stack) <= 200
assert stack.endswith("mem0-python/9.9.9")
for entry in stack.split(","):
assert entry.strip().count("/") == 1, f"severed entry: {entry!r}"
def test_an_outer_stack_is_appended_to_not_replaced():
with patch.dict(os.environ, {"MEM0_CLIENT_STACK": "openclaw/2.1.0"}):
stack = _client_stack()
assert stack.startswith("openclaw/2.1.0")
assert "mem0-python/" in stack