feat(integrations): declare which surface each client is, and its version (#7326)
This commit is contained in:
@@ -31,7 +31,8 @@ export class PlatformBackend implements Backend {
|
|||||||
this.headers = {
|
this.headers = {
|
||||||
Authorization: `Token ${config.apiKey}`,
|
Authorization: `Token ${config.apiKey}`,
|
||||||
"Content-Type": "application/json",
|
"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-Language": "node",
|
||||||
"X-Mem0-Client-Version": CLI_VERSION,
|
"X-Mem0-Client-Version": CLI_VERSION,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ class PlatformBackend(Backend):
|
|||||||
headers={
|
headers={
|
||||||
"Authorization": f"Token {config.api_key}",
|
"Authorization": f"Token {config.api_key}",
|
||||||
"Content-Type": "application/json",
|
"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-Language": "python",
|
||||||
"X-Mem0-Client-Version": __version__,
|
"X-Mem0-Client-Version": __version__,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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).
|
- **`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/`.
|
- **`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
|
## 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/`.
|
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.
|
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`.
|
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).
|
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`.
|
||||||
|
|||||||
@@ -81,15 +81,23 @@ def replace_output(staged: Path, output: Path) -> Path:
|
|||||||
return output
|
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.
|
"""Emit core/_harness_id.py for one host.
|
||||||
|
|
||||||
Carries both vocabularies from a single definition: the PostHog `source` tag
|
Carries both vocabularies from a single definition: the PostHog `source` tag
|
||||||
and the platform's X-Mem0-Source / X-Application pair. Keeping them together
|
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
|
is what stops the two from drifting into separate vocabularies for the same
|
||||||
thing.
|
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"
|
tag = host.upper().replace("-", "_") + "_PLUGIN"
|
||||||
|
application = "" if portable else host
|
||||||
return (
|
return (
|
||||||
'"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n'
|
'"""Generated by integrations/agent-plugin-core/build/build.py. Do not edit."""\n'
|
||||||
"\n"
|
"\n"
|
||||||
@@ -98,8 +106,10 @@ def _render_harness_id(host: str) -> str:
|
|||||||
"\n"
|
"\n"
|
||||||
"# Platform-side vocabulary (mem0_event.source + X-Application). The whole\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"
|
"# 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'
|
'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
|
# 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
|
# never did, which is how MCP searches reported harness=generic and every
|
||||||
# batch they drained was labelled MEM0_PLUGIN regardless of the real host.
|
# 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 = {
|
values = {
|
||||||
"PLUGIN_ROOT": plugin_root,
|
"PLUGIN_ROOT": plugin_root,
|
||||||
|
|||||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
|||||||
return 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(
|
def _request_json(
|
||||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
|||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=raw,
|
data=raw,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
|||||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
|||||||
"user_id": write_user,
|
"user_id": write_user,
|
||||||
"app_id": repo.app_id,
|
"app_id": repo.app_id,
|
||||||
"run_id": session_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)},
|
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||||
"custom_instructions": PERSONAL_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:
|
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
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",
|
method="DELETE",
|
||||||
)
|
)
|
||||||
try:
|
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("*"))
|
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"])
|
@pytest.mark.parametrize("host", ["claude-code", "cursor", "codex", "kimi", "antigravity"])
|
||||||
def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
|
def test_native_bundle_is_self_contained(host: str, tmp_path: Path) -> None:
|
||||||
root = build(host, "native", tmp_path / host)
|
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"
|
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]:
|
def _session_start(core: Path, data_dir: Path) -> list[str]:
|
||||||
"""Drive the real hook_runner session-start path and return lifecycle events."""
|
"""Drive the real hook_runner session-start path and return lifecycle events."""
|
||||||
recorded = "\n".join(
|
recorded = "\n".join(
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ SOURCE_TAG = "ANTIGRAVITY_PLUGIN"
|
|||||||
|
|
||||||
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
||||||
# plugin family is one source; which editor it runs in is the application.
|
# 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_SOURCE = "MEM0_PLUGIN"
|
||||||
PLATFORM_APPLICATION = "antigravity"
|
PLATFORM_APPLICATION = "antigravity"
|
||||||
|
|||||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
|||||||
return 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(
|
def _request_json(
|
||||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
|||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=raw,
|
data=raw,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
|||||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
|||||||
"user_id": write_user,
|
"user_id": write_user,
|
||||||
"app_id": repo.app_id,
|
"app_id": repo.app_id,
|
||||||
"run_id": session_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)},
|
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||||
"custom_instructions": PERSONAL_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:
|
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
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",
|
method="DELETE",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ SOURCE_TAG = "CLAUDE_CODE_PLUGIN"
|
|||||||
|
|
||||||
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
||||||
# plugin family is one source; which editor it runs in is the application.
|
# 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_SOURCE = "MEM0_PLUGIN"
|
||||||
PLATFORM_APPLICATION = "claude-code"
|
PLATFORM_APPLICATION = "claude-code"
|
||||||
|
|||||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
|||||||
return 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(
|
def _request_json(
|
||||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
|||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=raw,
|
data=raw,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
|||||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
|||||||
"user_id": write_user,
|
"user_id": write_user,
|
||||||
"app_id": repo.app_id,
|
"app_id": repo.app_id,
|
||||||
"run_id": session_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)},
|
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||||
"custom_instructions": PERSONAL_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:
|
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
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",
|
method="DELETE",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ SOURCE_TAG = "CODEX_PLUGIN"
|
|||||||
|
|
||||||
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
||||||
# plugin family is one source; which editor it runs in is the application.
|
# 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_SOURCE = "MEM0_PLUGIN"
|
||||||
PLATFORM_APPLICATION = "codex"
|
PLATFORM_APPLICATION = "codex"
|
||||||
|
|||||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
|||||||
return 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(
|
def _request_json(
|
||||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
|||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=raw,
|
data=raw,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
|||||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
|||||||
"user_id": write_user,
|
"user_id": write_user,
|
||||||
"app_id": repo.app_id,
|
"app_id": repo.app_id,
|
||||||
"run_id": session_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)},
|
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||||
"custom_instructions": PERSONAL_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:
|
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
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",
|
method="DELETE",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ SOURCE_TAG = "CURSOR_PLUGIN"
|
|||||||
|
|
||||||
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
||||||
# plugin family is one source; which editor it runs in is the application.
|
# 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_SOURCE = "MEM0_PLUGIN"
|
||||||
PLATFORM_APPLICATION = "cursor"
|
PLATFORM_APPLICATION = "cursor"
|
||||||
|
|||||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
|||||||
return 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(
|
def _request_json(
|
||||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
|||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=raw,
|
data=raw,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
|||||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
|||||||
"user_id": write_user,
|
"user_id": write_user,
|
||||||
"app_id": repo.app_id,
|
"app_id": repo.app_id,
|
||||||
"run_id": session_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)},
|
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||||
"custom_instructions": PERSONAL_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:
|
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
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",
|
method="DELETE",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ export const name = "mem0";
|
|||||||
export const inject = ["tools", "systemPrompt"];
|
export const inject = ["tools", "systemPrompt"];
|
||||||
|
|
||||||
// Tags writes so Mem0's backend attributes them to this integration in
|
// Tags writes so Mem0's backend attributes them to this integration in
|
||||||
// telemetry. The backend's KNOWN_EVENT_SOURCES allowlist recognizes this value;
|
// telemetry. Values outside the backend's KNOWN_EVENT_SOURCES allowlist bucket
|
||||||
// anything outside it buckets into "OTHERS".
|
// into "OTHERS"; this one is added by mem0ai/platform#3602 and reads as OTHERS
|
||||||
|
// until that ships.
|
||||||
const SOURCE = "DEEPSEEK_HARNESS";
|
const SOURCE = "DEEPSEEK_HARNESS";
|
||||||
|
|
||||||
const DEFAULT_SEARCH_LIMIT = 10;
|
const DEFAULT_SEARCH_LIMIT = 10;
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ SOURCE_TAG = "KIMI_PLUGIN"
|
|||||||
|
|
||||||
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
||||||
# plugin family is one source; which editor it runs in is the application.
|
# 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_SOURCE = "MEM0_PLUGIN"
|
||||||
PLATFORM_APPLICATION = "kimi"
|
PLATFORM_APPLICATION = "kimi"
|
||||||
|
|||||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
|||||||
return 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(
|
def _request_json(
|
||||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
|||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=raw,
|
data=raw,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
|||||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
|||||||
"user_id": write_user,
|
"user_id": write_user,
|
||||||
"app_id": repo.app_id,
|
"app_id": repo.app_id,
|
||||||
"run_id": session_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)},
|
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||||
"custom_instructions": PERSONAL_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:
|
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
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",
|
method="DELETE",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,5 +5,7 @@ SOURCE_TAG = "CODING_AGENT_PLUGIN"
|
|||||||
|
|
||||||
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
# Platform-side vocabulary (mem0_event.source + X-Application). The whole
|
||||||
# plugin family is one source; which editor it runs in is the application.
|
# 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_SOURCE = "MEM0_PLUGIN"
|
||||||
PLATFORM_APPLICATION = "coding-agent"
|
PLATFORM_APPLICATION = ""
|
||||||
|
|||||||
@@ -1800,6 +1800,34 @@ def extraction_message_batches(
|
|||||||
return 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(
|
def _request_json(
|
||||||
url: str, key: str, payload: dict[str, Any], timeout: float
|
url: str, key: str, payload: dict[str, Any], timeout: float
|
||||||
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
) -> tuple[dict[str, Any] | list[Any], int, int]:
|
||||||
@@ -1807,7 +1835,7 @@ def _request_json(
|
|||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
data=raw,
|
data=raw,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="POST",
|
method="POST",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1834,7 +1862,7 @@ def _get_json(
|
|||||||
) -> tuple[dict[str, Any] | list[Any], int]:
|
) -> tuple[dict[str, Any] | list[Any], int]:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
url,
|
url,
|
||||||
headers={"Authorization": f"Token {key}", "Content-Type": "application/json"},
|
headers=platform_headers(key),
|
||||||
method="GET",
|
method="GET",
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
@@ -1980,6 +2008,13 @@ def flush_session(
|
|||||||
"user_id": write_user,
|
"user_id": write_user,
|
||||||
"app_id": repo.app_id,
|
"app_id": repo.app_id,
|
||||||
"run_id": session_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)},
|
"metadata": {**metadata, "author": write_user, "dirs": directory_chain(repo)},
|
||||||
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
"agent_custom_instructions": PROJECT_MEMORY_INSTRUCTIONS,
|
||||||
"custom_instructions": PERSONAL_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:
|
def _delete_memory(api_url: str, key: str, memory_id: str) -> bool:
|
||||||
request = urllib.request.Request(
|
request = urllib.request.Request(
|
||||||
f"{api_url}/v1/memories/{urllib.parse.quote(memory_id)}/",
|
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",
|
method="DELETE",
|
||||||
)
|
)
|
||||||
try:
|
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}`);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SearchMemoryOptions } from "mem0ai";
|
||||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||||
import type MemoryClient from "mem0ai";
|
import type MemoryClient from "mem0ai";
|
||||||
import type { Mem0Config, ScopeContext, Scope } from "./types.ts";
|
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 { resolveSearchFilters, resolveAddParams } from "./memory/scoping.ts";
|
||||||
import { formatMemoryList, formatMemoryCompact, groupByCategory } from "./memory/formatting.ts";
|
import { formatMemoryList, formatMemoryCompact, groupByCategory } from "./memory/formatting.ts";
|
||||||
import { captureCommandEvent } from "./telemetry.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;
|
const SEARCH_TOP_K = 10;
|
||||||
|
|
||||||
@@ -29,7 +36,12 @@ export function registerCommands(
|
|||||||
threshold: config.searchThreshold,
|
threshold: config.searchThreshold,
|
||||||
topK: SEARCH_TOP_K,
|
topK: SEARCH_TOP_K,
|
||||||
rerank: true,
|
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 ?? [];
|
return result.results ?? [];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,7 +57,7 @@ export function registerCommands(
|
|||||||
const addParams = resolveAddParams(config.defaultScope, getScopeCtx());
|
const addParams = resolveAddParams(config.defaultScope, getScopeCtx());
|
||||||
const result = await mem0.add(
|
const result = await mem0.add(
|
||||||
[{ role: "user", content: text }],
|
[{ 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);
|
captureCommandEvent("mem0-remember", {}, telemetryCtx);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { captureEvent } from "./telemetry.ts";
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import type { ScopeContext } from "./types.ts";
|
import type { ScopeContext } from "./types.ts";
|
||||||
import { createMemoryLifecycle } from "../../agent-plugin-core/typescript/src/lifecycle.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";
|
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 });
|
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 = {
|
const scopeCtx: ScopeContext = {
|
||||||
userId: resolveUserId(config.userId),
|
userId: resolveUserId(config.userId),
|
||||||
|
|||||||
@@ -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 { LanguageModelV3Prompt } from '@ai-sdk/provider';
|
||||||
import { Mem0ConfigSettings } from './mem0-types';
|
import { Mem0ConfigSettings } from './mem0-types';
|
||||||
import { loadApiKey } from '@ai-sdk/provider-utils';
|
import { loadApiKey } from '@ai-sdk/provider-utils';
|
||||||
@@ -277,7 +284,11 @@ const searchInternalMemories = async (query: string, config?: Mem0ConfigSettings
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Token ${apiKey}`,
|
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),
|
body: JSON.stringify(body),
|
||||||
};
|
};
|
||||||
@@ -331,7 +342,11 @@ const updateMemories = async (messages: Array<Message>, config?: Mem0ConfigSetti
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Token ${apiKey}`,
|
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),
|
body: JSON.stringify(body),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"preserveWatchOutput": true,
|
"preserveWatchOutput": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["@types/node", "jest"],
|
"types": ["@types/node", "jest"],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from 'tsup'
|
import { defineConfig } from 'tsup'
|
||||||
|
import pkg from './package.json'
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
{
|
{
|
||||||
@@ -6,5 +7,11 @@ export default defineConfig([
|
|||||||
entry: ['src/index.ts'],
|
entry: ['src/index.ts'],
|
||||||
format: ['cjs', 'esm'],
|
format: ['cjs', 'esm'],
|
||||||
sourcemap: true,
|
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),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@@ -95,6 +95,66 @@ interface ClientIdentity {
|
|||||||
const IDENTITY_CACHE_MAX_DEFAULT = 50;
|
const IDENTITY_CACHE_MAX_DEFAULT = 50;
|
||||||
const identityByCredentials = new Map<string, Promise<ClientIdentity>>();
|
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 {
|
export default class MemoryClient {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -129,6 +189,7 @@ export default class MemoryClient {
|
|||||||
this.headers = {
|
this.headers = {
|
||||||
Authorization: `Token ${this.apiKey}`,
|
Authorization: `Token ${this.apiKey}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...surfaceHeaders(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export interface SearchMemoryOptions {
|
|||||||
showExpired?: boolean;
|
showExpired?: boolean;
|
||||||
referenceDate?: string | number;
|
referenceDate?: string | number;
|
||||||
keywordSearch?: boolean;
|
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 {
|
export interface GetAllMemoryOptions {
|
||||||
|
|||||||
+94
-24
@@ -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)
|
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:
|
class MemoryClient:
|
||||||
"""Client for interacting with the Mem0 API.
|
"""Client for interacting with the Mem0 API.
|
||||||
|
|
||||||
@@ -129,19 +218,11 @@ class MemoryClient:
|
|||||||
self.client = client
|
self.client = client
|
||||||
# Ensure the client has the correct base_url and headers
|
# Ensure the client has the correct base_url and headers
|
||||||
self.client.base_url = httpx.URL(self.host)
|
self.client.base_url = httpx.URL(self.host)
|
||||||
self.client.headers.update(
|
_apply_client_headers(self.client, self.api_key, self.user_id)
|
||||||
{
|
|
||||||
"Authorization": f"Token {self.api_key}",
|
|
||||||
"Mem0-User-ID": self.user_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.client = httpx.Client(
|
self.client = httpx.Client(
|
||||||
base_url=self.host,
|
base_url=self.host,
|
||||||
headers={
|
headers=_client_headers(self.api_key, self.user_id),
|
||||||
"Authorization": f"Token {self.api_key}",
|
|
||||||
"Mem0-User-ID": self.user_id,
|
|
||||||
},
|
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
self.user_email = self._validate_api_key()
|
self.user_email = self._validate_api_key()
|
||||||
@@ -1018,19 +1099,11 @@ class AsyncMemoryClient:
|
|||||||
self.async_client = client
|
self.async_client = client
|
||||||
# Ensure the client has the correct base_url and headers
|
# Ensure the client has the correct base_url and headers
|
||||||
self.async_client.base_url = httpx.URL(self.host)
|
self.async_client.base_url = httpx.URL(self.host)
|
||||||
self.async_client.headers.update(
|
_apply_client_headers(self.async_client, self.api_key, self.user_id)
|
||||||
{
|
|
||||||
"Authorization": f"Token {self.api_key}",
|
|
||||||
"Mem0-User-ID": self.user_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.async_client = httpx.AsyncClient(
|
self.async_client = httpx.AsyncClient(
|
||||||
base_url=self.host,
|
base_url=self.host,
|
||||||
headers={
|
headers=_client_headers(self.api_key, self.user_id),
|
||||||
"Authorization": f"Token {self.api_key}",
|
|
||||||
"Mem0-User-ID": self.user_id,
|
|
||||||
},
|
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1053,10 +1126,7 @@ class AsyncMemoryClient:
|
|||||||
params = self._prepare_params()
|
params = self._prepare_params()
|
||||||
response = requests.get(
|
response = requests.get(
|
||||||
f"{self.host}/v1/ping/",
|
f"{self.host}/v1/ping/",
|
||||||
headers={
|
headers=_client_headers(self.api_key, self.user_id),
|
||||||
"Authorization": f"Token {self.api_key}",
|
|
||||||
"Mem0-User-ID": self.user_id,
|
|
||||||
},
|
|
||||||
params=params,
|
params=params,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user