feat(external-context): Support configurable Mem0 providers (#9952)

* feat(external-context): Support open-source Mem0 providers

Add a mem0 provider type to the external-context integration that speaks
the open-source Mem0 REST API (POST /v2/memories/search for on-demand
recall, POST /v1/memories with infer:false for exact direct-import
writes), so self-hosted mem0 servers and managed OSS-protocol services
such as Aliyun PolarDB Mem0 can serve as the external context corpus.

The tenant identity (user_id, optional app_id) is fixed in the
administrator-managed configuration and is never supplied by the model.
A new allowInsecureHttp opt-in permits plain-HTTP endpoints such as
PolarDB Mem0 while credentials-in-URL remain rejected.

Refs #9951

* test(external-context): Cover the open-source Mem0 example config

Assert the exact example structure and that it loads through loadConfig
once the endpoint and user placeholders are substituted, so the shipped
sample cannot drift from the provider schema.

* refactor(external-context): Address review on the mem0 provider

Enforce the OSS baseUrl policy only in the adapter constructor, matching
the sibling providers, and have the rejection message point at the
allowInsecureHttp opt-in. Extract the shared write-rejection classifier
used by both Mem0 adapters, pin localhost and IPv6 loopback acceptance,
cover non-HTTP write failures mapping to unknown, clean up the example
test's temp directory, and correct the README rollout guarantees and the
OSS write-enablement guidance.

* fix(external-context): Ship the mem0 example with HTTPS defaults

The example pre-enabled allowInsecureHttp, inverting the README guidance
it is cited by. Default to an HTTPS baseUrl without the flag; the hinted
validation error and the README paragraph lead plain-HTTP whitelist
deployments to the explicit opt-in.

* test(external-context): prove shipped mem0 example constructs a provider

* refactor(external-context): address mem0 review round — merge duplicate arms, gate HTTP hint, fix README wording

* fix(external-context): Scope OSS mem0 by agent_id instead of app_id

The open-source mem0 v1.0.1 REST protocol (implemented by Aliyun PolarDB
Mem0) scopes memories by user_id / agent_id / run_id; app_id is a
hosted-platform-only field these endpoints do not accept. Rename the
optional mem0 provider field to agentId, send it as agent_id, and place it
top-level in the v2 search request (filters carry user_id) to match the
protocol.

Verified live against a PolarDB Mem0 instance: add and search accept
agent_id, infer:false is honored for direct import, and the MCP chain works
end to end.

* fix(external-context): Scope Mem0 provider to PolarDB

* feat(external-context): add versioned Mem0 presets

Replace the protocol-specific draft provider with a generic Mem0 provider backed by reviewed, immutable presets for Mem0 Platform, stock Mem0 REST, and PolarDB.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(external-context): survive an unverified Mem0 deployment build

The endpoint is operator-supplied and its build is not guaranteed - these
services are self-hosted or vendor-packaged, and the stock Mem0 REST server
carries no API version in its paths at all. A preset is therefore a claim
about what is deployed, and it can be wrong. Nothing here probes or falls
back; instead a mismatch is kept from passing silently.

Send session identity at both the request root and under `filters` for the
Mem0 server preset. `_build_filters_and_metadata` seeds the effective filters
from `filters` and then overwrites each identity key with the root value, so
one request resolves to a byte-equal scope on builds that read either
position. A filters-only search is rejected outright by builds predating that
move, which surfaced as a 500 rather than as an unscoped search.

Claim `stored` for a direct import only when the echoed `memory` matches the
submitted text and the event is not a delete. `infer: false` is what makes the
stored memory the exact text approved at the confirmation Hook, but a build
whose request model predates the field drops it silently, runs fact
extraction, and returns a rewritten memory with a valid identifier - reported
until now as `stored`.

Write provider failures to stderr with the HTTP status, which separates a
contract mismatch from an outage. The model-facing error stays opaque, and no
provider response body is ever written.

Rename the preset to `mem0-server-rest-2026-08`: "OSS" names an object storage
service in some of the clouds these deployments run in, and the preset list it
sits in already names one of those clouds.

Report which scope key and which basePath rule a configuration broke, narrow
the preset unions to the members a shipped preset uses, and cover the write
result classes, basePath rules, both scope rules in isolation, the identifier
bound, the direct-import basePath join, the Platform V3 preset write request,
and the mem0 arm of auto-recall credential scrubbing.

* feat(external-context): add a read-only Mem0 preset probe

Every other field of a `mem0` block is a fact the administrator already holds.
`preset` is not: it is a claim about which wire contract the deployment
implements, and these services are self-hosted or vendor-packaged, so the
answer is not visible from the configuration side. The configuration made that
claim mandatory without offering any way to check it.

The probe runs a candidate preset through the real adapter against the real
endpoint, so a preset that reaches the service but whose items the parser
discards is reported as the mismatch it is. It also reads the declared search
schema when one is served, and compares scope placements so a preset can be
moved to `body-and-filters` on evidence rather than on assumption.

An empty corpus is reported as inconclusive rather than as a pass: three
equally empty result sets look like agreement and are not.

Read-only. It issues search requests and one `GET /openapi.json`, never
writes, and never renders the credential.

* refactor(external-context): drop the vendor prefix from the PolarDB preset

PolarDB Mem0 is offered by more than one cloud, so an identifier scoped to one
of them reads as inapplicable to an operator running the same contract
elsewhere - the preset names a wire contract, not a vendor. The mapping's
verification evidence came from an Aliyun deployment, which the design doc now
says explicitly rather than encoding in the identifier.

Record who controls the build behind each preset, and probe placements only
where that is the operator. A vendor-operated service has one published
contract and no version skew to hedge against, so reporting that a different
request shape also works would invite a deviation with a cost and no benefit.

A skipped comparison no longer stands in for a working preset: when the
baseline search fails, the verdict is the mismatch, not "see the baseline".

* fix(external-context): restore bounded Mem0 contracts

Co-authored-by: Qwen-Coder <[email protected]>

* docs(external-context): ship a PolarDB example that loads as it is shipped

The console hands out a plain-HTTP address and port, while the shipped example
carries an `https://` origin with no port - the shape the endpoint should have,
not the shape it arrives in. Pasting the console address into that example
fails the URL policy, and nothing tells the operator which of the legitimate
resolutions their deployment wants.

Add a loopback example instead of loosening the policy. An `ssh -L` relay
carries the credential and the memories, loopback HTTP loads with no opt-in, and
only the user id has to be substituted, so the path from a new instance to a
working search is one relay command and one copied file. The HTTPS example is
unchanged: pre-enabling the opt-in in a shipped file would invert the posture
the surrounding section asks operators to keep.

The loopback example omits `agentId`, which the preset sends at the request
root - a placement no PolarDB document confirms, and an optional field is not
worth staking a first connection on.

* docs(external-context): clarify PolarDB setup

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-09-17 09:22:34 +00:00
1 parent 80a866c6ee
commit 9f75842786
19 files changed
+1577 -110

No files matched your search

@@ -0,0 +1,199 @@
# Direct External Context Mem0 Presets
**Status:** Implemented in the private direct integration
**Date:** 2026-08-27
**Related designs:**
[Direct External Context Provider](./direct-external-context-provider.md),
[Direct External Context Mem0 Write](./direct-external-context-mem0-write.md),
[External Context Provider Extensions](./external-context-provider-extensions.md)
## Decision
The private Direct External Context integration uses one `mem0` provider type
with an administrator-selected, versioned built-in preset for Mem0-compatible
REST services. A preset owns the upstream wire contract; instance
configuration owns only the deployment endpoint, credential reference, fixed
scope, and timeout inherited from the direct profile.
This replaces provider types that combine a product name with one hard-coded
protocol, such as `polardb-mem0`. It does not expose a free-form API version,
request template, custom header map, JSONPath expression, or dynamic provider
module. The model-facing MCP contract remains `context_search({ query })` and,
only for an explicitly enabled version 1 configuration whose preset defines a
verified direct-import operation, `context_remember({ content })`.
The existing `mem0-platform-v3` configuration remains accepted for backward
compatibility. New deployments use `type: "mem0"`.
## Why a preset instead of `apiVersion`
One upstream product can mix operation versions. PolarDB Mem0 search and
direct import, for example, use different versioned paths. Authentication,
scope placement, result fields, write response semantics, and trailing-slash
requirements also vary independently of a numeric API version.
A preset therefore identifies one complete, verified contract. Published
preset identifiers are immutable. An incompatible upstream change receives a
new identifier rather than silently changing an existing mapping.
The first built-in presets are:
- `mem0-platform-v3`
- `mem0-oss-rest-2026-08`
- `aliyun-polardb-mysql-2026-08`
## Configuration
```json
{
"version": 1,
"timeoutMs": 5000,
"provider": {
"type": "mem0",
"preset": "aliyun-polardb-mysql-2026-08",
"endpoint": {
"origin": "https://memory.example.com",
"basePath": ""
},
"credentialEnv": "MEM0_API_KEY",
"scope": {
"userId": "repository-memory",
"agentId": "qwen-code"
}
}
}
```
`origin` contains only scheme and authority. `basePath` is a static optional
reverse-proxy prefix. They are validated separately so joining a preset path
cannot discard a configured prefix or reinterpret endpoint authority.
Embedded URL credentials, query strings, fragments, dot segments, encoded path
material, whitespace, and control characters are rejected from endpoint
configuration.
HTTPS is required by default. Loopback HTTP is accepted for local relays.
Non-loopback HTTP requires the explicit `allowInsecureHttp` flag and sends the
credential and memory content in cleartext; it is intended only for deployments
whose trusted private network is deliberately part of the boundary.
The preset declares which scope values it consumes and whether each value is
required or optional. Startup fails closed when a required value is missing or
when configuration supplies a value the selected preset does not use. Scope is
administrator input and never appears in tool arguments.
One absolute `QWEN_EXTERNAL_CONTEXT_CONFIG` path is loaded once per MCP child.
The path, file contents, endpoint, preset, scope, and credential-to-corpus
binding must remain immutable for the whole Qwen session, including MCP child
restarts. Switching any of them requires a new Qwen session and a new config
path.
## Bounded preset contract
Built-in presets select only reviewed constants:
- `Authorization: Token`, `Authorization: Bearer`, or `X-API-Key`
- one static POST search path
- `top_k` or `limit` as the result-limit field
- `user_id`, `agent_id`, and `app_id` placed at the JSON root, under
`filters`, or omitted
- a closed set of fixed search options such as `threshold` and `rerank`
- a `results` response collection with reviewed identifier and content fields
- an optional static direct-import path and one reviewed response mapping
The engine always sends at most five as the provider limit and retains at most
five valid results. It does not retry, redirect, probe alternate paths, or
fallback between presets. A malformed item is dropped independently; a
malformed response envelope fails the request.
Adding a preset requires authoritative protocol evidence plus request and
response contract tests. A service that cannot fit this grammar uses its own
local or remote MCP Extension under the External Context Provider Extensions
design instead of expanding this configuration into a programming language.
## Initial mappings
### Mem0 Platform V3
- Search: `POST /v3/memories/search/`
- Authentication: `Authorization: Token`
- Scope: required `appId` under `filters.app_id`
- Limit: `top_k`
- Direct import: `POST /v3/memories/add/`, `infer: false`
- Write response: `PENDING` plus a UUID `event_id` is `accepted`; only
`SUCCEEDED` is `stored`
The legacy `mem0-platform-v3` configuration remains a fixed-endpoint shorthand
for this mapping.
### Mem0 OSS REST 2026-08
- Search: `POST /search`
- Authentication: `X-API-Key`
- Scope: required `userId` and optional `agentId` under `filters`
- Limit: `top_k`
- Direct import: `POST /memories`, `infer: false`
- Write response: a valid `results[].id` is `stored`; otherwise `unknown`
This mapping follows the stock `mem0ai/mem0` REST server. Bearer-authenticated
deployments need a separately verified preset rather than a per-instance
authentication override.
### Aliyun PolarDB MySQL 2026-08
- Search: `POST /v2/memories/search`
- Authentication: `Authorization: Token`
- Scope: required `userId` under `filters`, optional top-level `agentId`
- Limit: `top_k`
- Direct import: `POST /v1/memories`, `infer: false`
- Write response: a valid `results[].id` is `stored`; otherwise `unknown`
An `event_id` alone is never treated as proof of storage. If a later PolarDB
contract documents asynchronous event polling, that behavior requires a new
preset and write design rather than changing this preset in place.
## Protocol evidence
- Mem0 Platform V3 follows the official
[search API](https://docs.mem0.ai/api-reference/memory/search-memories) and
[V2-to-V3 migration contract](https://docs.mem0.ai/migration/platform-v2-to-v3).
- Mem0 OSS REST is pinned to upstream commit
[`39bc023`](https://github.com/mem0ai/mem0/tree/39bc02330563764e7d4465f1ecff5f002d94da1a),
specifically [`server/main.py`](https://github.com/mem0ai/mem0/blob/39bc02330563764e7d4465f1ecff5f002d94da1a/server/main.py)
and [`server/auth.py`](https://github.com/mem0ai/mem0/blob/39bc02330563764e7d4465f1ecff5f002d94da1a/server/auth.py).
- The PolarDB preset follows the official
[PolarDB for MySQL Mem0 contract](https://help.aliyun.com/en/polardb/polardb-for-mysql/use-polardb-mem0)
and the [live end-to-end evidence](https://github.com/QwenLM/qwen-code/pull/9952#issuecomment-5407141853)
recorded for this PR. The public documentation covers the endpoint,
authentication, and scope placement; the live instance additionally proves
that `infer: false` is honored for direct import.
Tests must continue to pin these exact request and response shapes. If an
upstream contract changes incompatibly, add a new preset identifier instead of
updating an existing mapping in place.
## Relationship to provider Extensions
This provider is a bounded compatibility feature inside the already private
`integrations/external-context` process. It is not a public provider registry.
Third-party teams still own independently distributed integrations through
MCP Extensions. The direct preset path is appropriate only for Mem0-family
contracts that Qwen maintainers explicitly verify and agree to maintain.
Operators must enable either this direct server or another Mem0 Extension for
one corpus, not both. Exposing two `context_search` tools for the same corpus
would make provider selection model-controlled and could duplicate queries.
## Verification
The implementation must prove:
- strict configuration parsing and fail-closed preset/scope validation
- safe `origin` plus `basePath` joining
- exact authentication, path, scope, and limit mapping for every preset
- per-item response normalization and the five-result cap
- conservative synchronous versus asynchronous write outcomes
- no retry on search or write failures
- loadability of the shipped PolarDB and OSS examples
- compatibility of the existing `mem0-platform-v3` configuration
@@ -18,9 +18,13 @@ configuration containing the strict `write: { enabled: true }` block. The
default extension manifest, existing version 1 configurations, Generic HTTP,
and version 2 auto-recall remain write-free.
The tool sends the validated text unchanged through Mem0 V3 Direct Import with
`infer: false`. It does not pre-search, summarize, normalize, retry, poll, cache,
or deduplicate. A separately installed `PreToolUse` command Hook displays a
The tool sends the validated text unchanged through the selected, verified
Mem0 direct-import preset with `infer: false`. The legacy
`mem0-platform-v3` configuration remains supported. New `mem0` configurations
select an immutable built-in preset as defined in
[Direct External Context Mem0 Presets](./direct-external-context-mem0-presets.md).
The tool does not pre-search, summarize, normalize, retry, poll, cache, or
deduplicate. A separately installed `PreToolUse` command Hook displays a
reversible escaped representation of the complete text and asks the user to
confirm it. This confirmation is a direct-profile user-experience safeguard,
not a server-side authorization boundary.
@@ -30,8 +34,9 @@ not a server-side authorization boundary.
### Goals
- Let trusted collaborators save exact repository-shared text to one
administrator-bound Mem0 Project.
- Keep the Project credential and `app_id` outside model-controlled input.
administrator-bound Mem0 corpus.
- Keep the credential, endpoint, preset, and fixed scope outside
model-controlled input.
- Make the complete text visible before the MCP call executes.
- Perform at most one Provider request per approved tool invocation.
- Represent asynchronous and ambiguous Provider outcomes without claiming
@@ -56,7 +61,7 @@ sequenceDiagram
participant Q as Qwen Code
participant H as PreToolUse Hook
participant E as External Context MCP
participant P as Bound Mem0 Project
participant P as Bound Mem0 corpus
M->>Q: context_remember(content)
Q->>Q: Ordinary MCP permission check
Q->>H: Exact tool name, mode, and content
@@ -121,6 +126,12 @@ configuration validation. The default extension manifest still includes only
linking. Administrators must use the dedicated pinned MCP configuration whose
`includeTools` contains exactly search and remember.
New Mem0-compatible deployments may replace the legacy provider block with a
strict `type: "mem0"` block containing one built-in preset, endpoint,
credential reference, and preset-valid fixed scope. Writes are accepted only
when that preset defines a reviewed direct-import mapping. A custom endpoint
does not imply a custom write protocol.
The remember tool annotations are `readOnlyHint: false`,
`destructiveHint: false`, `idempotentHint: false`, and `openWorldHint: false`.
They describe behavior for clients; they are not permission or authorization.
@@ -174,7 +185,7 @@ content sent to Mem0.
## Mem0 request and result semantics
The adapter sends exactly one request:
The legacy Platform V3 adapter sends exactly one request:
```http
POST /v3/memories/add/
@@ -196,19 +207,28 @@ detection, so approving the same text twice can create two memories. The
integration intentionally does not add a hidden search or content hash because
neither would provide idempotency for an async remote operation.
The versioned `mem0` adapter also sends exactly one request, using only the
selected preset's fixed path, authentication, and scope placement. Platform V3
uses its asynchronous add response. The initial OSS REST and PolarDB presets
claim `stored` only for a valid `results[].id`; a bare `event_id`, alternate
root object, or root array is `unknown`. The client never treats request
acceptance as proof of persistence.
Result mapping is conservative:
| Provider outcome | Tool result |
| ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| Valid `SUCCEEDED` | `stored` |
| Valid `PENDING` with UUID `event_id` | `accepted` with operation ID |
| Explicit `FAILED`, or HTTP 400, 401, 403, or 404 | `failed` with stable MCP error |
| Valid asynchronous `PENDING` with a UUID `event_id` | `accepted` with operation ID |
| Valid synchronous direct-import `results[].id` for a preset that defines it | `stored` with memory ID |
| Explicit `FAILED` in an asynchronous status response, or HTTP 400, 401, 403, or 404 | `failed` with stable MCP error |
| Timeout, cancellation, redirect, other HTTP status, broken or oversized response, invalid JSON, unknown status, or invalid identifier | `unknown` with MCP error |
Mem0 Add normally returns `PENDING`, so `accepted` is the expected successful
result and means queued, not persisted. `stored` is retained only for a valid
synchronous `SUCCEEDED` response. `failed` is a definitive rejection and tells
the model not to retry without changing the content or configuration.
Mem0 Platform Add normally returns `PENDING`, so `accepted` is the expected
successful result and means queued, not persisted. `stored` is retained only
for a valid synchronous `SUCCEEDED` response or a preset-defined synchronous
direct-import result. `failed` is a definitive rejection and tells the model
not to retry without changing the content or configuration.
`unknown` states that the Provider may already have accepted the write and
tells the model not to retry automatically. The integration never polls the
event or retries. User cancellation likewise does not prove that no record was
@@ -19,10 +19,14 @@ The optional Phase 4 Mem0 variant adds an administrator-only
`context_remember({ content })` surface with a separate content-visible
confirmation Hook. Its detailed design is in
[Direct External Context Mem0 Write](./direct-external-context-mem0-write.md).
Versioned Mem0-compatible request mappings are defined in
[Direct External Context Mem0 Presets](./direct-external-context-mem0-presets.md).
The extension supports two explicit read adapters:
The extension supports three explicit read paths:
- Mem0 Platform V3 Search for repository-shared agent memory.
- The legacy fixed-endpoint Mem0 Platform V3 adapter.
- A bounded `mem0` adapter whose reviewed built-in preset selects Mem0
Platform V3, stock Mem0 OSS REST, or Aliyun PolarDB Mem0.
- Generic HTTP Search V1 for an existing knowledge base, RAG service, or
enterprise search endpoint.
@@ -144,9 +148,11 @@ The interface deliberately contains no tenant, user, repository, namespace,
application ID, or arbitrary filter. The explicit provider factory binds those
values from administrator-controlled configuration before a tool call.
Neither interface is a public package API. Adding another provider requires a
reviewed adapter and an explicit factory case. Generic HTTP remains
search-only; write semantics are deliberately provider-specific.
Neither interface is a public package API. Adding another provider family
requires a reviewed adapter and an explicit factory case. A Mem0-family wire
contract that fits the bounded preset grammar instead adds one immutable
built-in preset with contract tests. Generic HTTP remains search-only; write
semantics are deliberately provider-specific.
## Runtime behavior
@@ -245,6 +251,12 @@ and a shorter provider timeout.
}
```
New Mem0-compatible deployments use `type: "mem0"` with a versioned preset,
an `endpoint` containing separately validated `origin` and `basePath`, a
`credentialEnv`, and a fixed `scope`. The complete schema and compatibility
policy are defined in the Mem0 preset design. The legacy
`mem0-platform-v3` shape above remains accepted.
The managed launcher must control the configuration path and credential. An
MCP subprocess does not reload either value, but Qwen can restart the
subprocess after a disconnect or explicit MCP restart. The configuration path,
@@ -323,6 +335,21 @@ provider-side state change must verify that Memory Decay remains disabled.
Provider audit or access logs may still be retained. See
[Mem0 Memory Decay](https://docs.mem0.ai/platform/features/memory-decay).
### Versioned Mem0-compatible Search
The `mem0` adapter selects one immutable built-in preset at startup. The
initial presets cover Mem0 Platform V3, the stock Mem0 OSS REST server, and
Aliyun PolarDB Mem0. Each preset fixes authentication, search and direct-import
paths, scope placement, limit field, response fields, and write outcome
semantics. Instance configuration cannot override those fields.
The endpoint uses a separately validated origin and static base path. Search
always sends at most five results, never follows redirects, never retries, and
does not probe or fall back between protocol versions. Scope remains fixed
outside model input. See
[Direct External Context Mem0 Presets](./direct-external-context-mem0-presets.md)
for the exact mappings and extension boundary.
### Generic HTTP Search V1
The configured `baseUrl` must be an origin with no path, query, credentials, or
@@ -445,7 +472,8 @@ The optional auto-recall profile is implemented separately in
The optional Mem0 write variant is implemented separately in
[Direct External Context Mem0 Write](./direct-external-context-mem0-write.md).
The broader proposal in #7585 retains possible additional provider-specific
adapters where the Generic HTTP contract is not sufficient.
Extensions where neither Generic HTTP nor the bounded Mem0 preset contract is
sufficient.
The remaining items are not latent switches in either direct profile. They
require separate review and implementation.
@@ -473,3 +501,4 @@ require separate review and implementation.
- [Mem0 Organizations & Projects](https://docs.mem0.ai/api-reference/organizations-projects)
- [Mem0 Search Memories](https://docs.mem0.ai/api-reference/memory/search-memories)
- [Mem0 OSS server](https://github.com/mem0ai/mem0/tree/main/server)
@@ -222,6 +222,13 @@ result to its existing `context_search`; it does not change Provider HTTP
requests, result ranking, write behavior, configuration schemas, or Auto
Recall output.
That private integration may carry a bounded set of maintainer-owned,
versioned Mem0 presets as documented in
[Direct External Context Mem0 Presets](./direct-external-context-mem0-presets.md).
Those built-ins are a compatibility surface for verified Mem0-family
contracts, not a public registry or an alternative distribution path for
third-party providers.
The reference MCP now rejects unrecognized `context_search` arguments instead
of silently ignoring them. Existing query-only calls are unchanged. A client
that sent undeclared selector or metadata fields must remove those fields; the
+107 -14
View File
@@ -12,9 +12,11 @@ three managed deployment variants:
- **Auto-recall:** version 2 configuration and an administrator-installed
`UserPromptSubmit` Hook, with no external-context MCP server.
The built-in adapters support Mem0 Platform V3 search and a small Generic HTTP
Search V1 contract for existing knowledge or RAG services. Only Mem0 has an
optional write path. There is no generic ingestion protocol, personal memory,
The built-in adapters support Mem0 Platform V3, the stock self-hosted Mem0
REST server, the Aliyun PolarDB Mem0 management API, and a small Generic HTTP
Search V1 contract for existing knowledge or RAG services. Only Mem0 providers
with a verified direct-import operation have an optional write path.
There is no generic ingestion protocol, personal memory,
trusted user identity, per-document ACL, or tamper-resistant audit.
Provider teams that need a separately owned and released integration should
@@ -35,8 +37,8 @@ query. In the Mem0 write variant, it can additionally provide only the exact
content to store. In the auto-recall profile, the query is derived only from
Qwen's optional `submitted_prompt` provenance, captured before model-bound
expansions.
Provider type, endpoint, credential, Mem0 `app_id`, and all other corpus
selectors are fixed before either process starts.
Provider type, endpoint, credential, Mem0 `app_id`, `user_id`, `agent_id`, and
all other corpus selectors are fixed before either process starts.
The actual corpus-isolation boundary is the provider-side credential, project,
index, or corpus. A Mem0 `app_id` or any other client-supplied filter is
@@ -95,9 +97,9 @@ confirmation; use the governed profile when that is required.
select another corpus.
2. Copy the applicable provider configuration from `examples/` to an
administrator-owned location outside the repository that the CLI user
cannot modify. Configure `apiKeyEnv` or `tokenEnv` if needed, then set the
referenced environment variable to the credential. `timeoutMs` defaults to
5000 and may be between 1 and 30000 milliseconds.
cannot modify. Configure `apiKeyEnv`, `credentialEnv`, or `tokenEnv` if
needed, then set the referenced environment variable to the credential.
`timeoutMs` defaults to 5000 and may be between 1 and 30000 milliseconds.
3. Have the managed launcher set `QWEN_EXTERNAL_CONTEXT_CONFIG` to the absolute
configuration path.
4. From the Qwen Code checkout, install dependencies and build this workspace:
@@ -127,7 +129,7 @@ confirmation; use the governed profile when that is required.
through an environment-aware dispatcher. If the provider requires an
egress proxy, the launcher must include the administrator-approved proxy
variables in that clean environment. Include `localhost`, `127.0.0.1`, and
`[::1]` in `NO_PROXY` when using the loopback Generic HTTP provider.
`[::1]` in `NO_PROXY` when using any loopback provider endpoint.
6. Point `QWEN_CODE_SYSTEM_SETTINGS_PATH` at an administrator-controlled copy
of `examples/managed-settings.json` only inside this managed launcher; do not
@@ -225,7 +227,7 @@ UTF-16. It never accepts a model-selected tenant, Project, `app_id`, filter, or
metadata. Each approval performs exactly one request; the integration does not
pre-search, retry, poll, cache, or deduplicate.
Mem0 V3 Add is asynchronous in normal operation. `PENDING` with a valid
Mem0 V3 Add is asynchronous in normal operation. `PENDING` with a UUID
`event_id` is therefore the expected successful response and returns
`accepted`, not `stored`. A valid synchronous `SUCCEEDED` response returns
`stored` as a defensive compatibility path. Explicit `FAILED` and HTTP 400,
@@ -375,16 +377,107 @@ it disabled when search must have no semantic provider-side state change.
Provider audit or access logs may still be retained. See
[Mem0 Memory Decay](https://docs.mem0.ai/platform/features/memory-decay).
## Versioned Mem0-compatible providers
New Mem0-compatible deployments use one `mem0` provider type and select a
reviewed built-in wire contract through `preset`. The endpoint is split into an
`origin` and optional static `basePath`, while the credential and fixed scope
remain administrator-owned:
```json
{
"type": "mem0",
"preset": "aliyun-polardb-mysql-2026-08",
"endpoint": {
"origin": "https://memory.example.com",
"basePath": ""
},
"credentialEnv": "MEM0_API_KEY",
"scope": {
"userId": "repository-memory",
"agentId": "qwen-code"
}
}
```
The built-in presets are:
- `mem0-platform-v3`: `/v3` Platform API, `Authorization: Token`, fixed
`appId`.
- `mem0-oss-rest-2026-08`: stock self-hosted Mem0 `/search` and `/memories`,
`X-API-Key`, fixed `userId`, and optional `agentId`.
- `aliyun-polardb-mysql-2026-08`: PolarDB `/v2/memories/search` and
`/v1/memories`, `Authorization: Token`, fixed `userId`, and optional
`agentId`.
#### Connecting a PolarDB Mem0 instance
After the instance finishes initializing, obtain its private or public address
and `secret.access.apikey` from the console. Public access must be requested
separately, and the application whitelist must allow the client independently
of any PolarDB cluster whitelist. The console endpoint is plain HTTP with an IP
address and port rather than a TLS name, so pasting it into the HTTPS example
fails the URL policy: the credential, queries, and returned memories would
otherwise cross the network in cleartext.
`examples/polardb-mem0-loopback.json` is the path that needs no policy opt-in.
Use a jump host that can reach the endpoint and is allowed by the application
whitelist, then relay the endpoint to loopback:
```bash
ssh -N -L 127.0.0.1:8080:<endpoint-host>:<endpoint-port> <jump-host>
export MEM0_API_KEY=<the console's secret.access.apikey>
```
Then copy that example to an administrator-owned path, replace
`<your-user-id>`, and point `QWEN_EXTERNAL_CONTEXT_CONFIG` at it. Loopback
HTTP needs no `allowInsecureHttp` opt-in. A new instance has no searchable
memories; seed it through the PolarDB API or the explicitly enabled managed
write profile before validating search.
Two alternatives, in preference order: bind a domain to the instance and
terminate TLS, then use `examples/polardb-mem0.json`; or, only when the operator
explicitly accepts cleartext traffic on an isolated trusted network, keep the
console endpoint and add `"allowInsecureHttp": true`.
Put the API key in the environment variable named by `credentialEnv`, never in
the configuration file. PolarDB documents optional `agentId` at the request
root; use a stable value when memories must be isolated by application.
The preset records the whole protocol, not just one API version. Search always
sends a maximum of five through the preset's `top_k` field. The engine does not
probe versions or fall back to a different preset. See
`examples/polardb-mem0.json`, `examples/polardb-mem0-loopback.json`,
`examples/mem0-oss.json`, and the
[preset design](../../docs/design/direct-external-context-mem0-presets.md).
Non-loopback plain HTTP remains an explicit `allowInsecureHttp` endpoint
opt-in. Because it sends credentials, queries, and memory content in cleartext,
prefer HTTPS or a loopback relay such as `ssh -L`. The HTTPS example and the
loopback HTTP example both omit the non-loopback insecure-HTTP opt-in.
Preset scope is fixed in configuration and never supplied by the model. A
missing required scope or an unused configured scope fails startup. The
examples are read-only as shipped: `context_remember` is registered only when
the version 1 configuration carries `"write": { "enabled": true }` and the
selected preset defines verified direct import. Enabling writes must reuse the
managed write-confirmation Hook and permissions above.
## Rollout and rollback
Start with the pinned read-only on-demand MCP for one workspace and validate
search quality and provenance. For writes, first run the repository's
interactive fake-Mem0 test harness, then progress through an isolated temporary
Mem0 Project, one trusted repository, and a small team. The shipped Mem0
configuration always targets Mem0 Platform; only the test harness injects a
local endpoint. Enable auto-recall only after the administrator accepts
automatic query forwarding. Do not run auto-recall and an on-demand MCP in one
process.
Platform configuration always targets Mem0 Platform, and only the test harness
injects a local endpoint. A versioned `mem0` configuration instead points at
the operator-selected endpoint — plain HTTP included when
`allowInsecureHttp` is on — so treat that endpoint, credential, preset, scope,
and network policy as one immutable access boundary and validate search
read-only before enabling writes. Enable auto-recall only after the
administrator accepts automatic query forwarding. Do not run auto-recall and
an on-demand MCP in one process, and do not expose this server alongside a
second Mem0 Extension for the same corpus.
Removing the pinned MCP or auto-recall Hook from the managed launcher rolls
back the Qwen integration; local on-demand trials can instead disable or remove
@@ -0,0 +1,17 @@
{
"version": 1,
"timeoutMs": 5000,
"provider": {
"type": "mem0",
"preset": "mem0-oss-rest-2026-08",
"endpoint": {
"origin": "https://<your-mem0-endpoint>",
"basePath": ""
},
"credentialEnv": "MEM0_API_KEY",
"scope": {
"userId": "<your-user-id>",
"agentId": "qwen-code"
}
}
}
@@ -0,0 +1,17 @@
{
"version": 1,
"timeoutMs": 5000,
"provider": {
"type": "mem0",
"preset": "aliyun-polardb-mysql-2026-08",
"endpoint": {
"origin": "http://127.0.0.1:8080",
"basePath": ""
},
"credentialEnv": "MEM0_API_KEY",
"scope": {
"userId": "<your-user-id>",
"agentId": "qwen-code"
}
}
}
@@ -0,0 +1,17 @@
{
"version": 1,
"timeoutMs": 5000,
"provider": {
"type": "mem0",
"preset": "aliyun-polardb-mysql-2026-08",
"endpoint": {
"origin": "https://<your-polardb-mem0-endpoint>",
"basePath": ""
},
"credentialEnv": "MEM0_API_KEY",
"scope": {
"userId": "<your-user-id>",
"agentId": "qwen-code"
}
}
}
@@ -244,6 +244,37 @@ describe('runAutoRecall', () => {
});
});
it('redacts a versioned Mem0 credential before search', async () => {
const fixture = await createRepositoryFixture();
loadConfig.mockResolvedValue({
version: 2,
timeoutMs: 5000,
autoRecall: { repositoryRoot: fixture.root, timeoutMs: 1500 },
provider: {
type: 'mem0',
preset: 'mem0-oss-rest-2026-08',
endpoint: { origin: 'https://mem0.example.com', basePath: '' },
credentialEnv: 'MEM0_API_KEY',
credential: 'provider-secret-value',
scope: { userId: 'fixed-user' },
},
} satisfies ExternalContextConfigV2);
search.mockResolvedValue([]);
const { runAutoRecall } = await import('./auto-recall.js');
await runAutoRecall({
hook_event_name: 'UserPromptSubmit',
submitted_prompt: 'compare provider-secret-value with deployment policy',
cwd: fixture.root,
});
expect(search).toHaveBeenCalledWith({
query: 'compare with deployment policy',
limit: 5,
signal: expect.any(AbortSignal),
});
});
it('does not inspect the legacy prompt field', async () => {
const fixture = await createRepositoryFixture();
loadConfig.mockResolvedValue(config(fixture.root));
@@ -231,6 +231,8 @@ function providerCredential(config: ProviderConfig): string {
switch (config.type) {
case 'mem0-platform-v3':
return config.apiKey;
case 'mem0':
return config.credential;
case 'generic-http-search-v1':
return config.token;
// no default
@@ -79,6 +79,133 @@ describe('loadConfig', () => {
});
});
describe('versioned Mem0 provider', () => {
const provider = {
type: 'mem0',
preset: 'aliyun-polardb-mysql-2026-08',
endpoint: {
origin: 'https://mem0.example.com:8443',
basePath: '',
},
credentialEnv: 'MEM0_API_KEY',
scope: { userId: 'fixed-user' },
};
it('resolves the credential, preset, endpoint, and fixed scope', async () => {
const fixture = await createFixture();
await writeConfig(fixture, { version: 1, provider });
await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).resolves.toMatchObject({
version: 1,
provider: {
type: 'mem0',
preset: 'aliyun-polardb-mysql-2026-08',
endpoint: {
origin: 'https://mem0.example.com:8443',
basePath: '',
},
credential: 'secret-value',
scope: { userId: 'fixed-user' },
},
});
});
it('enables writes for a version 1 Mem0 preset with direct import', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
version: 1,
write: { enabled: true },
provider,
});
await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).resolves.toMatchObject({
version: 1,
write: { enabled: true },
provider: { type: 'mem0' },
});
});
it('loads a non-loopback HTTP origin only with allowInsecureHttp', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
version: 1,
provider: {
...provider,
endpoint: {
origin: 'http://192.0.2.1:8080',
basePath: '/mem0',
allowInsecureHttp: true,
},
},
});
await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).resolves.toMatchObject({
provider: {
type: 'mem0',
endpoint: { basePath: '/mem0', allowInsecureHttp: true },
},
});
});
it('rejects missing required and unused scope values for a preset', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
version: 1,
provider: {
...provider,
scope: { appId: 'unused-app' },
},
});
await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).rejects.toThrow('External context Mem0 scope is invalid.');
});
it('requires only appId for the Platform V3 preset', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
version: 1,
provider: {
...provider,
preset: 'mem0-platform-v3',
scope: { appId: 'fixed-app' },
},
});
await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
MEM0_API_KEY: 'secret-value',
}),
).resolves.toMatchObject({
provider: {
type: 'mem0',
preset: 'mem0-platform-v3',
scope: { appId: 'fixed-app' },
},
});
});
});
it('rejects writes for Generic HTTP and v2 configurations', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
@@ -411,6 +538,25 @@ describe('loadConfig', () => {
);
});
it('rejects an unresolved credential placeholder', async () => {
const fixture = await createFixture();
await writeConfig(fixture, {
version: 1,
provider: {
type: 'generic-http-search-v1',
baseUrl: 'https://context.example.com',
tokenEnv: 'CONTEXT_TOKEN',
},
});
await expect(
loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: fixture.config,
CONTEXT_TOKEN: '${CONTEXT_TOKEN}',
}),
).rejects.toThrow('Configured external context credential is unavailable.');
});
it.each(['__proto__', 'constructor', 'toString'])(
'does not treat inherited %s as a credential',
async (tokenEnv) => {
+47 -3
View File
@@ -7,11 +7,14 @@
import { readFile, realpath, stat } from 'node:fs/promises';
import { isAbsolute, parse } from 'node:path';
import { z } from 'zod';
import { getMem0Preset, isValidMem0Scope } from './mem0-presets.js';
import type {
ExternalContextConfig,
GenericHttpProviderConfig,
Mem0CompatibleProviderConfig,
Mem0ProviderConfig,
} from './types.js';
import { MEM0_PRESET_IDS } from './types.js';
const MAX_CONFIG_BYTES = 64 * 1024;
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -24,6 +27,27 @@ const providerSchema = z.discriminatedUnion('type', [
appId: z.string().trim().min(1).max(256),
})
.strict(),
z
.object({
type: z.literal('mem0'),
preset: z.enum(MEM0_PRESET_IDS),
endpoint: z
.object({
origin: z.string().url(),
basePath: z.string().default(''),
allowInsecureHttp: z.boolean().optional(),
})
.strict(),
credentialEnv: z.string().regex(ENV_NAME),
scope: z
.object({
userId: z.string().trim().min(1).max(256).optional(),
agentId: z.string().trim().min(1).max(256).optional(),
appId: z.string().trim().min(1).max(256).optional(),
})
.strict(),
})
.strict(),
z
.object({
type: z.literal('generic-http-search-v1'),
@@ -111,10 +135,19 @@ export async function loadConfig(
throw new ConfigurationError('External context config is invalid.');
}
if (
result.data.provider.type === 'mem0' &&
!isValidMem0Scope(result.data.provider)
) {
throw new ConfigurationError('External context Mem0 scope is invalid.');
}
if (result.data.version === 1) {
if (
result.data.write !== undefined &&
result.data.provider.type !== 'mem0-platform-v3'
result.data.provider.type !== 'mem0-platform-v3' &&
(result.data.provider.type !== 'mem0' ||
getMem0Preset(result.data.provider.preset).write === undefined)
) {
throw new ConfigurationError(
'External context memory writes require a Mem0 provider.',
@@ -146,12 +179,19 @@ export async function loadConfig(
function resolveProvider(
provider: z.infer<typeof providerSchema>,
env: NodeJS.ProcessEnv,
): Mem0ProviderConfig | GenericHttpProviderConfig {
):
| Mem0ProviderConfig
| Mem0CompatibleProviderConfig
| GenericHttpProviderConfig {
switch (provider.type) {
case 'mem0-platform-v3': {
const apiKey = readCredential(env, provider.apiKeyEnv);
return { ...provider, apiKey };
}
case 'mem0': {
const credential = readCredential(env, provider.credentialEnv);
return { ...provider, credential };
}
case 'generic-http-search-v1': {
const token = readCredential(env, provider.tokenEnv);
return { ...provider, token };
@@ -192,7 +232,11 @@ function isFilesystemRoot(value: string): boolean {
function readCredential(env: NodeJS.ProcessEnv, name: string): string {
const value = Object.hasOwn(env, name) ? env[name] : undefined;
if (typeof value !== 'string' || value.length === 0) {
if (
typeof value !== 'string' ||
value.length === 0 ||
value === '${' + name + '}'
) {
throw new ConfigurationError(
'Configured external context credential is unavailable.',
);
@@ -5,7 +5,11 @@
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { MAX_RESPONSE_BYTES, postJson } from './http-client.js';
import {
MAX_RESPONSE_BYTES,
postJson,
validateProviderBaseUrl,
} from './http-client.js';
// Behavioral net for readBoundedBody's reader loop. The loop was rewritten
// from `for await (const chunk of response.body)` to an explicit getReader()
@@ -44,7 +48,10 @@ function streamingResponse(
function requestArgs() {
return {
url: new URL('https://provider.example/'),
authorization: 'Bearer test',
credentialHeader: {
name: 'authorization' as const,
value: 'Bearer test',
},
body: { q: 'x' },
signal: new AbortController().signal,
};
@@ -220,3 +227,58 @@ describe('postJson bounded body reading', () => {
);
});
});
describe('validateProviderBaseUrl', () => {
it('accepts HTTPS and loopback HTTP by default', () => {
expect(validateProviderBaseUrl('https://mem0.example.com').protocol).toBe(
'https:',
);
expect(validateProviderBaseUrl('http://127.0.0.1:8080').hostname).toBe(
'127.0.0.1',
);
expect(validateProviderBaseUrl('http://localhost:8080').hostname).toBe(
'localhost',
);
expect(validateProviderBaseUrl('http://[::1]:8080').hostname).toBe('[::1]');
});
it('points at the allowInsecureHttp opt-in when the provider offers one', () => {
expect(() =>
validateProviderBaseUrl('http://192.0.2.1:8080', {
allowInsecureHttpHint: true,
}),
).toThrow('set "allowInsecureHttp": true');
});
it('does not suggest the HTTP opt-in for non-HTTP schemes', () => {
expect(() =>
validateProviderBaseUrl('wss://mem0.internal:8443', {
allowInsecureHttpHint: true,
}),
).toThrow('Provider URL must use HTTPS or loopback HTTP.');
});
it('rejects non-loopback HTTP unless explicitly allowed', () => {
expect(() => validateProviderBaseUrl('http://192.0.2.1:8080')).toThrow(
'Provider URL must use HTTPS or loopback HTTP.',
);
expect(
validateProviderBaseUrl('http://192.0.2.1:8080', {
allowInsecureHttp: true,
}).hostname,
).toBe('192.0.2.1');
});
it('still rejects credentialed and pathed URLs when HTTP is allowed', () => {
expect(() =>
validateProviderBaseUrl('http://[email protected]:8080', {
allowInsecureHttp: true,
}),
).toThrow('Provider URL must not contain credentials');
expect(() =>
validateProviderBaseUrl('http://192.0.2.1:8080/prefix', {
allowInsecureHttp: true,
}),
).toThrow('Provider URL must not contain credentials, path');
});
});
@@ -20,7 +20,10 @@ export class ProviderHttpStatusError extends Error {
}
}
export function validateProviderBaseUrl(value: string): URL {
export function validateProviderBaseUrl(
value: string,
options?: { allowInsecureHttp?: boolean; allowInsecureHttpHint?: boolean },
): URL {
let url: URL;
try {
url = new URL(value);
@@ -38,23 +41,28 @@ export function validateProviderBaseUrl(value: string): URL {
'Provider URL must not contain credentials, path, query, or fragment.',
);
}
if (url.protocol === 'https:') {
return url;
}
if (
url.protocol === 'http:' &&
(url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.hostname === '[::1]')
url.protocol === 'https:' ||
(url.protocol === 'http:' &&
(url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.hostname === '[::1]' ||
options?.allowInsecureHttp === true))
) {
return url;
}
throw new Error('Provider URL must use HTTPS or loopback HTTP.');
throw new Error(
options?.allowInsecureHttpHint === true && url.protocol === 'http:'
? 'Provider URL must use HTTPS or loopback HTTP; set "allowInsecureHttp": true to permit plain HTTP for this provider.'
: 'Provider URL must use HTTPS or loopback HTTP.',
);
}
export async function postJson(input: {
url: URL;
authorization: string;
credentialHeader: {
name: 'authorization' | 'x-api-key';
value: string;
};
body: unknown;
signal: AbortSignal;
}): Promise<unknown> {
@@ -64,7 +72,7 @@ export async function postJson(input: {
method: 'POST',
headers: {
accept: 'application/json',
authorization: input.authorization,
[input.credentialHeader.name]: input.credentialHeader.value,
'content-type': 'application/json',
},
body: JSON.stringify(input.body),
@@ -4,8 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { readFile } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { loadConfig } from './config.js';
import { createProvider } from './providers.js';
describe('extension manifest', () => {
it('exposes only the retrieval tool', async () => {
@@ -233,6 +237,125 @@ describe('extension manifest', () => {
},
});
});
it('keeps the PolarDB Mem0 example loadable after placeholder substitution', async () => {
const config = await readJson('../examples/polardb-mem0.json');
expect(config).toEqual({
version: 1,
timeoutMs: 5000,
provider: {
type: 'mem0',
preset: 'aliyun-polardb-mysql-2026-08',
endpoint: {
origin: 'https://<your-polardb-mem0-endpoint>',
basePath: '',
},
credentialEnv: 'MEM0_API_KEY',
scope: {
userId: '<your-user-id>',
agentId: 'qwen-code',
},
},
});
const substituted = JSON.parse(
JSON.stringify(config)
.replaceAll('<your-polardb-mem0-endpoint>', 'mem0.example.internal')
.replaceAll('<your-user-id>', 'example-user'),
);
const directory = await mkdtemp(
join(tmpdir(), 'external-context-example-'),
);
try {
const configPath = join(directory, 'config.json');
await writeFile(configPath, JSON.stringify(substituted));
const loaded = await loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: configPath,
MEM0_API_KEY: 'example-key',
});
expect(loaded.provider).toMatchObject({
type: 'mem0',
preset: 'aliyun-polardb-mysql-2026-08',
scope: { userId: 'example-user', agentId: 'qwen-code' },
});
expect(() => createProvider(loaded.provider)).not.toThrow();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
// The console hands out a plain-HTTP address and port, which the URL policy
// rejects. This example is the one path that needs no policy opt-in: the
// relay carries the credential and the memories, and loopback HTTP loads
// without enabling non-loopback insecure HTTP.
it('keeps the PolarDB loopback example loadable with no policy opt-in', async () => {
const config = await readJson('../examples/polardb-mem0-loopback.json');
expect(config).toEqual({
version: 1,
timeoutMs: 5000,
provider: {
type: 'mem0',
preset: 'aliyun-polardb-mysql-2026-08',
endpoint: { origin: 'http://127.0.0.1:8080', basePath: '' },
credentialEnv: 'MEM0_API_KEY',
scope: { userId: '<your-user-id>', agentId: 'qwen-code' },
},
});
const substituted = JSON.parse(
JSON.stringify(config).replaceAll('<your-user-id>', 'example-user'),
);
const directory = await mkdtemp(
join(tmpdir(), 'external-context-example-'),
);
try {
const configPath = join(directory, 'config.json');
await writeFile(configPath, JSON.stringify(substituted));
const loaded = await loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: configPath,
MEM0_API_KEY: 'example-key',
});
expect(loaded.provider).toMatchObject({
type: 'mem0',
endpoint: { origin: 'http://127.0.0.1:8080' },
scope: { userId: 'example-user', agentId: 'qwen-code' },
});
expect(() => createProvider(loaded.provider)).not.toThrow();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('keeps the stock Mem0 REST example loadable', async () => {
const config = await readJson('../examples/mem0-oss.json');
const substituted = JSON.parse(
JSON.stringify(config)
.replaceAll('<your-mem0-endpoint>', 'mem0.example.internal')
.replaceAll('<your-user-id>', 'example-user'),
);
const directory = await mkdtemp(
join(tmpdir(), 'external-context-example-'),
);
try {
const configPath = join(directory, 'config.json');
await writeFile(configPath, JSON.stringify(substituted));
const loaded = await loadConfig({
QWEN_EXTERNAL_CONTEXT_CONFIG: configPath,
MEM0_API_KEY: 'example-key',
});
expect(loaded.provider).toMatchObject({
type: 'mem0',
preset: 'mem0-oss-rest-2026-08',
});
expect(() => createProvider(loaded.provider)).not.toThrow();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('pins write confirmation and disables local persistence', async () => {
const settings = await readJson(
@@ -0,0 +1,133 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Mem0CompatibleProviderConfig, Mem0PresetId } from './types.js';
export type Mem0Authentication =
| 'authorization-token'
| 'authorization-bearer'
| 'x-api-key';
export type Mem0ScopePlacement = 'body' | 'filters' | 'omit';
interface Mem0ScopeRule {
required: boolean;
search: Mem0ScopePlacement;
write: Mem0ScopePlacement;
}
export interface Mem0Preset {
authentication: Mem0Authentication;
scope: {
userId: Mem0ScopeRule;
agentId: Mem0ScopeRule;
appId: Mem0ScopeRule;
};
search: {
path: string;
limitField: 'top_k' | 'limit';
fixedBody?: Readonly<Record<string, number | boolean>>;
idField: 'id' | 'memory_id';
contentFields: ReadonlyArray<'memory' | 'content' | 'text'>;
};
write?:
| {
path: string;
response: 'async-status';
}
| {
path: string;
response: 'results-id';
idField: 'id' | 'memory_id';
};
}
const omittedScope: Mem0ScopeRule = {
required: false,
search: 'omit',
write: 'omit',
};
export const MEM0_PRESETS: Readonly<Record<Mem0PresetId, Mem0Preset>> = {
'mem0-platform-v3': {
authentication: 'authorization-token',
scope: {
userId: omittedScope,
agentId: omittedScope,
appId: { required: true, search: 'filters', write: 'body' },
},
search: {
path: '/v3/memories/search/',
limitField: 'top_k',
fixedBody: { threshold: 0.1, rerank: false },
idField: 'id',
contentFields: ['memory'],
},
write: {
path: '/v3/memories/add/',
response: 'async-status',
},
},
'mem0-oss-rest-2026-08': {
authentication: 'x-api-key',
scope: {
userId: { required: true, search: 'filters', write: 'body' },
agentId: { required: false, search: 'filters', write: 'body' },
appId: omittedScope,
},
search: {
path: '/search',
limitField: 'top_k',
idField: 'id',
contentFields: ['memory', 'content'],
},
write: {
path: '/memories',
response: 'results-id',
idField: 'id',
},
},
'aliyun-polardb-mysql-2026-08': {
authentication: 'authorization-token',
scope: {
userId: { required: true, search: 'filters', write: 'body' },
agentId: { required: false, search: 'body', write: 'body' },
appId: omittedScope,
},
search: {
path: '/v2/memories/search',
limitField: 'top_k',
idField: 'id',
contentFields: ['memory'],
},
write: {
path: '/v1/memories',
response: 'results-id',
idField: 'id',
},
},
};
export function getMem0Preset(id: Mem0PresetId): Mem0Preset {
return MEM0_PRESETS[id];
}
export function isValidMem0Scope(
config: Pick<Mem0CompatibleProviderConfig, 'preset' | 'scope'>,
): boolean {
const rules = getMem0Preset(config.preset).scope;
return (['userId', 'agentId', 'appId'] as const).every((key) => {
const rule = rules[key];
const value = config.scope[key];
if (rule.required) {
return value !== undefined;
}
if (rule.search === 'omit' && rule.write === 'omit') {
return value === undefined;
}
return true;
});
}
@@ -15,8 +15,10 @@ import { renderExternalContext } from './context.js';
import {
createMemoryWriter,
GenericHttpSearchV1Adapter,
Mem0CompatibleAdapter,
Mem0PlatformV3Adapter,
} from './providers.js';
import type { Mem0CompatibleProviderConfig, Mem0PresetId } from './types.js';
const closeServers: Array<() => Promise<void>> = [];
@@ -593,6 +595,293 @@ describe('Mem0PlatformV3Adapter', () => {
});
});
describe('Mem0CompatibleAdapter', () => {
it('creates a writer for a versioned Mem0 preset', () => {
expect(
createMemoryWriter(
mem0CompatibleConfig(
'https://mem0.example.com',
'aliyun-polardb-mysql-2026-08',
{ userId: 'fixed-user' },
),
),
).toBeInstanceOf(Mem0CompatibleAdapter);
});
it('validates endpoint authority, basePath, and preset scope', () => {
const config = mem0CompatibleConfig(
'http://192.0.2.1:8080',
'aliyun-polardb-mysql-2026-08',
{ userId: 'fixed-user' },
);
expect(() => new Mem0CompatibleAdapter(config)).toThrow(
'Provider URL must use HTTPS or loopback HTTP; set "allowInsecureHttp": true',
);
expect(
() =>
new Mem0CompatibleAdapter({
...config,
endpoint: { ...config.endpoint, allowInsecureHttp: true },
}),
).not.toThrow();
expect(
() =>
new Mem0CompatibleAdapter({
...config,
endpoint: {
origin: 'https://mem0.example.com',
basePath: '/../other',
},
}),
).toThrow('External context Mem0 base path is invalid.');
expect(
() =>
new Mem0CompatibleAdapter({
...config,
endpoint: {
origin: 'https://mem0.example.com',
basePath: '/memory service',
},
}),
).toThrow('External context Mem0 base path is invalid.');
expect(
() =>
new Mem0CompatibleAdapter({
...config,
endpoint: {
origin: 'https://[email protected]',
basePath: '',
},
}),
).toThrow('Provider URL must not contain credentials');
expect(
() =>
new Mem0CompatibleAdapter({
...config,
scope: { appId: 'unused' },
}),
).toThrow('External context Mem0 scope is invalid.');
});
it.each([
{
preset: 'mem0-platform-v3' as const,
scope: { appId: 'fixed-app' },
path: '/proxy/v3/memories/search/',
authorization: 'Token project-key',
apiKey: undefined,
responseItem: { id: 'memory-1', memory: 'platform memory' },
expectedBody: {
query: 'deployment',
top_k: 5,
threshold: 0.1,
rerank: false,
filters: { app_id: 'fixed-app' },
},
expectedContent: 'platform memory',
},
{
preset: 'mem0-oss-rest-2026-08' as const,
scope: { userId: 'fixed-user', agentId: 'fixed-agent' },
path: '/proxy/search',
authorization: undefined,
apiKey: 'project-key',
responseItem: { id: 'memory-1', content: 'oss memory' },
expectedBody: {
query: 'deployment',
top_k: 5,
filters: { user_id: 'fixed-user', agent_id: 'fixed-agent' },
},
expectedContent: 'oss memory',
},
{
preset: 'aliyun-polardb-mysql-2026-08' as const,
scope: { userId: 'fixed-user', agentId: 'fixed-agent' },
path: '/proxy/v2/memories/search',
authorization: 'Token project-key',
apiKey: undefined,
responseItem: { id: 'memory-1', memory: 'polardb memory' },
expectedBody: {
query: 'deployment',
top_k: 5,
agent_id: 'fixed-agent',
filters: { user_id: 'fixed-user' },
},
expectedContent: 'polardb memory',
},
])(
'maps the $preset search contract',
async ({
preset,
scope,
path,
authorization,
apiKey,
responseItem,
expectedBody,
expectedContent,
}) => {
let requestBody: unknown;
let requestPath: string | undefined;
let requestAuthorization: string | undefined;
let requestApiKey: string | string[] | undefined;
const origin = await startServer(async (request, response) => {
requestPath = request.url;
requestBody = JSON.parse(await readBody(request));
requestAuthorization = request.headers.authorization;
requestApiKey = request.headers['x-api-key'];
json(response, { results: [responseItem, {}, responseItem] });
});
const items = await new Mem0CompatibleAdapter(
mem0CompatibleConfig(origin, preset, scope, '/proxy'),
).search({
query: 'deployment',
limit: 99,
signal: AbortSignal.timeout(1000),
});
expect(requestPath).toBe(path);
expect(requestAuthorization).toBe(authorization);
expect(requestApiKey).toBe(apiKey);
expect(requestBody).toEqual(expectedBody);
expect(items).toEqual([
{ id: 'memory-1', content: expectedContent },
{ id: 'memory-1', content: expectedContent },
]);
},
);
it.each([
{
preset: 'mem0-oss-rest-2026-08' as const,
path: '/memories',
scope: { userId: 'fixed-user', agentId: 'fixed-agent' },
authorization: undefined,
apiKey: 'project-key',
},
{
preset: 'aliyun-polardb-mysql-2026-08' as const,
path: '/v1/memories',
scope: { userId: 'fixed-user', agentId: 'fixed-agent' },
authorization: 'Token project-key',
apiKey: undefined,
},
])(
'maps the $preset direct-import contract conservatively',
async ({ preset, path, scope, authorization, apiKey }) => {
const content = ' Keep 🙂 this\nexactly. ';
let requestCount = 0;
let requestBody: unknown;
let requestPath: string | undefined;
let requestAuthorization: string | undefined;
let requestApiKey: string | string[] | undefined;
const origin = await startServer(async (request, response) => {
requestCount += 1;
requestPath = request.url;
requestBody = JSON.parse(await readBody(request));
requestAuthorization = request.headers.authorization;
requestApiKey = request.headers['x-api-key'];
json(response, { results: [{ id: 'memory-1', event: 'ADD' }] });
});
await expect(
new Mem0CompatibleAdapter(
mem0CompatibleConfig(origin, preset, scope),
).remember({ content, signal: AbortSignal.timeout(1000) }),
).resolves.toEqual({
status: 'stored',
providerOperationId: 'memory-1',
});
expect(requestCount).toBe(1);
expect(requestPath).toBe(path);
expect(requestAuthorization).toBe(authorization);
expect(requestApiKey).toBe(apiKey);
expect(requestBody).toEqual({
messages: [{ role: 'user', content }],
user_id: 'fixed-user',
agent_id: 'fixed-agent',
infer: false,
});
},
);
it('keeps Platform V3 event responses asynchronous', async () => {
const eventId = '123e4567-e89b-12d3-a456-426614174000';
const origin = await startServer((_request, response) => {
json(response, { status: 'PENDING', event_id: eventId });
});
await expect(
new Mem0CompatibleAdapter(
mem0CompatibleConfig(origin, 'mem0-platform-v3', {
appId: 'fixed-app',
}),
).remember({
content: 'repository policy',
signal: AbortSignal.timeout(1000),
}),
).resolves.toEqual({
status: 'accepted',
providerOperationId: eventId,
});
});
it('does not treat a direct-import event_id as proof of storage', async () => {
const origin = await startServer((_request, response) => {
json(response, { results: [{ event_id: 'event-1' }] });
});
await expect(
new Mem0CompatibleAdapter(
mem0CompatibleConfig(origin, 'aliyun-polardb-mysql-2026-08', {
userId: 'fixed-user',
}),
).remember({
content: 'repository policy',
signal: AbortSignal.timeout(1000),
}),
).resolves.toEqual({ status: 'unknown' });
});
it('does not retry an ambiguous direct-import failure', async () => {
let requestCount = 0;
const origin = await startServer((_request, response) => {
requestCount += 1;
response.writeHead(500);
response.end('private upstream detail');
});
await expect(
new Mem0CompatibleAdapter(
mem0CompatibleConfig(origin, 'mem0-oss-rest-2026-08', {
userId: 'fixed-user',
}),
).remember({
content: 'repository policy',
signal: AbortSignal.timeout(1000),
}),
).resolves.toEqual({ status: 'unknown' });
expect(requestCount).toBe(1);
});
});
function mem0CompatibleConfig(
origin: string,
preset: Mem0PresetId,
scope: Mem0CompatibleProviderConfig['scope'],
basePath = '',
): Mem0CompatibleProviderConfig {
return {
type: 'mem0',
preset,
endpoint: { origin, basePath },
credentialEnv: 'MEM0_API_KEY',
credential: 'project-key',
scope,
};
}
function mem0Adapter(baseUrl: string) {
return new Mem0PlatformV3Adapter(
{
+260 -58
View File
@@ -10,11 +10,18 @@ import {
ProviderHttpStatusError,
validateProviderBaseUrl,
} from './http-client.js';
import {
getMem0Preset,
isValidMem0Scope,
type Mem0Preset,
type Mem0ScopePlacement,
} from './mem0-presets.js';
import type {
ExternalContextItem,
ExternalContextProvider,
ExternalMemoryWriter,
GenericHttpProviderConfig,
Mem0CompatibleProviderConfig,
Mem0ProviderConfig,
ProviderConfig,
RememberResult,
@@ -32,6 +39,8 @@ export function createProvider(
switch (config.type) {
case 'mem0-platform-v3':
return new Mem0PlatformV3Adapter(config);
case 'mem0':
return new Mem0CompatibleAdapter(config);
case 'generic-http-search-v1':
return new GenericHttpSearchV1Adapter(config);
// no default
@@ -44,6 +53,10 @@ export function createMemoryWriter(
switch (config.type) {
case 'mem0-platform-v3':
return new Mem0PlatformV3Adapter(config);
case 'mem0':
return getMem0Preset(config.preset).write === undefined
? undefined
: new Mem0CompatibleAdapter(config);
case 'generic-http-search-v1':
return undefined;
// no default
@@ -65,7 +78,10 @@ export class GenericHttpSearchV1Adapter implements ExternalContextProvider {
}): Promise<readonly ExternalContextItem[]> {
const response = await postJson({
url: this.searchUrl,
authorization: `Bearer ${this.config.token}`,
credentialHeader: {
name: 'authorization',
value: `Bearer ${this.config.token}`,
},
body: { query: input.query, limit: input.limit },
signal: input.signal,
});
@@ -76,13 +92,18 @@ export class GenericHttpSearchV1Adapter implements ExternalContextProvider {
export class Mem0PlatformV3Adapter
implements ExternalContextProvider, ExternalMemoryWriter
{
private readonly baseUrl: URL;
private readonly delegate: Mem0CompatibleAdapter;
constructor(
private readonly config: Mem0ProviderConfig,
baseUrl: URL = MEM0_BASE_URL,
) {
this.baseUrl = validateConfiguredBaseUrl(baseUrl.toString());
constructor(config: Mem0ProviderConfig, baseUrl: URL = MEM0_BASE_URL) {
const origin = validateConfiguredBaseUrl(baseUrl.toString());
this.delegate = new Mem0CompatibleAdapter({
type: 'mem0',
preset: 'mem0-platform-v3',
endpoint: { origin: origin.toString(), basePath: '' },
credentialEnv: config.apiKeyEnv,
credential: config.apiKey,
scope: { appId: config.appId },
});
}
async search(input: {
@@ -90,57 +111,34 @@ export class Mem0PlatformV3Adapter
limit: number;
signal: AbortSignal;
}): Promise<readonly ExternalContextItem[]> {
const response = await postJson({
url: new URL('/v3/memories/search/', this.baseUrl),
authorization: `Token ${this.config.apiKey}`,
body: {
query: input.query,
filters: { app_id: this.config.appId },
top_k: Math.min(input.limit, MAX_PROVIDER_ITEMS),
threshold: 0.1,
rerank: false,
},
signal: input.signal,
});
return parseMem0Items(response);
return this.delegate.search(input);
}
async remember(input: {
content: string;
signal: AbortSignal;
}): Promise<RememberResult> {
let response: unknown;
try {
response = await postJson({
url: new URL('/v3/memories/add/', this.baseUrl),
authorization: `Token ${this.config.apiKey}`,
body: {
messages: [{ role: 'user', content: input.content }],
app_id: this.config.appId,
infer: false,
},
signal: input.signal,
});
} catch (error) {
if (
error instanceof ProviderHttpStatusError &&
DEFINITIVE_WRITE_REJECTION_STATUSES.has(error.status)
) {
return { status: 'failed' };
}
return { status: 'unknown' };
}
return parseMem0RememberResult(response);
return this.delegate.remember(input);
}
}
function classifyWriteRejection(error: unknown): RememberResult {
if (
error instanceof ProviderHttpStatusError &&
DEFINITIVE_WRITE_REJECTION_STATUSES.has(error.status)
) {
return { status: 'failed' };
}
return { status: 'unknown' };
}
function parseMem0RememberResult(response: unknown): RememberResult {
if (!isRecord(response)) {
return { status: 'unknown' };
}
const status = response['status'];
const operationId = parseOperationId(response['event_id']);
const operationId = parsePlatformEventId(response['event_id']);
if (status === 'FAILED') {
return { status: 'failed' };
}
@@ -160,12 +158,206 @@ function parseMem0RememberResult(response: unknown): RememberResult {
return { status: 'unknown' };
}
function parseOperationId(value: unknown): string | undefined {
function parsePlatformEventId(value: unknown): string | undefined {
return typeof value === 'string' && UUID_PATTERN.test(value)
? value
: undefined;
}
function parseProviderId(value: unknown): string | undefined {
if (
typeof value !== 'string' ||
value.trim().length === 0 ||
Array.from(value).length > 256 ||
Array.from(value).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code <= 0x1f || code === 0x7f;
})
) {
return undefined;
}
return value;
}
export class Mem0CompatibleAdapter
implements ExternalContextProvider, ExternalMemoryWriter
{
private readonly origin: URL;
private readonly preset: Mem0Preset;
constructor(private readonly config: Mem0CompatibleProviderConfig) {
if (!isValidMem0Scope(config)) {
throw new ConfigurationError('External context Mem0 scope is invalid.');
}
this.preset = getMem0Preset(config.preset);
this.origin = validateConfiguredBaseUrl(config.endpoint.origin, {
allowInsecureHttp: config.endpoint.allowInsecureHttp,
allowInsecureHttpHint: true,
});
validateMem0BasePath(config.endpoint.basePath);
}
async search(input: {
query: string;
limit: number;
signal: AbortSignal;
}): Promise<readonly ExternalContextItem[]> {
const body: Record<string, unknown> = {
query: input.query,
[this.preset.search.limitField]: Math.min(
input.limit,
MAX_PROVIDER_ITEMS,
),
...this.preset.search.fixedBody,
};
applyMem0Scope(body, this.config, this.preset, 'search');
const response = await postJson({
url: this.buildUrl(this.preset.search.path),
credentialHeader: mem0CredentialHeader(
this.preset,
this.config.credential,
),
body,
signal: input.signal,
});
return parseMem0Items(
response,
this.preset.search.idField,
this.preset.search.contentFields,
);
}
async remember(input: {
content: string;
signal: AbortSignal;
}): Promise<RememberResult> {
const write = this.preset.write;
if (write === undefined) {
return { status: 'unknown' };
}
const body: Record<string, unknown> = {
messages: [{ role: 'user', content: input.content }],
infer: false,
};
applyMem0Scope(body, this.config, this.preset, 'write');
let response: unknown;
try {
response = await postJson({
url: this.buildUrl(write.path),
credentialHeader: mem0CredentialHeader(
this.preset,
this.config.credential,
),
body,
signal: input.signal,
});
} catch (error) {
return classifyWriteRejection(error);
}
return write.response === 'async-status'
? parseMem0RememberResult(response)
: parseDirectWriteResult(response, write.idField);
}
private buildUrl(path: string): URL {
const url = new URL(this.origin);
const basePath = this.config.endpoint.basePath.replace(/\/$/u, '');
url.pathname = `${basePath}${path}`;
return url;
}
}
function parseDirectWriteResult(
response: unknown,
idField: 'id' | 'memory_id',
): RememberResult {
if (!isRecord(response) || !Array.isArray(response['results'])) {
return { status: 'unknown' };
}
for (const item of response['results']) {
if (!isRecord(item)) {
continue;
}
const id = parseProviderId(item[idField]);
if (id !== undefined) {
return { status: 'stored', providerOperationId: id };
}
}
return { status: 'unknown' };
}
function mem0CredentialHeader(
preset: Mem0Preset,
credential: string,
): { name: 'authorization' | 'x-api-key'; value: string } {
switch (preset.authentication) {
case 'authorization-token':
return { name: 'authorization', value: `Token ${credential}` };
case 'authorization-bearer':
return { name: 'authorization', value: `Bearer ${credential}` };
case 'x-api-key':
return { name: 'x-api-key', value: credential };
// no default
}
}
function applyMem0Scope(
body: Record<string, unknown>,
config: Mem0CompatibleProviderConfig,
preset: Mem0Preset,
operation: 'search' | 'write',
): void {
const fields = [
['userId', 'user_id'],
['agentId', 'agent_id'],
['appId', 'app_id'],
] as const;
for (const [configField, requestField] of fields) {
const value = config.scope[configField];
const placement = preset.scope[configField][operation];
placeMem0Scope(body, placement, requestField, value);
}
}
function placeMem0Scope(
body: Record<string, unknown>,
placement: Mem0ScopePlacement,
field: 'user_id' | 'agent_id' | 'app_id',
value: string | undefined,
): void {
if (placement === 'omit' || value === undefined) {
return;
}
if (placement === 'body') {
body[field] = value;
return;
}
const filters = (body['filters'] ??= {}) as Record<string, unknown>;
filters[field] = value;
}
function validateMem0BasePath(value: string): void {
if (value === '') {
return;
}
if (
!value.startsWith('/') ||
value.includes('//') ||
value.includes('?') ||
value.includes('#') ||
value.includes('\\') ||
value.includes('%') ||
/\s/u.test(value) ||
Array.from(value).some((character) => {
const code = character.codePointAt(0) ?? 0;
return code <= 0x1f || code === 0x7f;
}) ||
value.split('/').some((segment) => segment === '.' || segment === '..')
) {
throw new ConfigurationError('External context Mem0 base path is invalid.');
}
}
function parseGenericItems(response: unknown): readonly ExternalContextItem[] {
if (!isRecord(response) || !Array.isArray(response['items'])) {
throw new Error('External context provider returned an invalid response.');
@@ -180,10 +372,14 @@ function parseGenericItem(value: unknown): ExternalContextItem | undefined {
if (!isRecord(value)) {
return undefined;
}
return parseItemFields(value, 'content');
return parseItemFields(value, 'id', ['content']);
}
function parseMem0Items(response: unknown): readonly ExternalContextItem[] {
function parseMem0Items(
response: unknown,
idField: 'id' | 'memory_id' = 'id',
contentFields: ReadonlyArray<'memory' | 'content' | 'text'> = ['memory'],
): readonly ExternalContextItem[] {
const values =
isRecord(response) && Array.isArray(response['results'])
? response['results']
@@ -193,7 +389,9 @@ function parseMem0Items(response: unknown): readonly ExternalContextItem[] {
}
return values
.map((value) =>
isRecord(value) ? parseItemFields(value, 'memory') : undefined,
isRecord(value)
? parseItemFields(value, idField, contentFields)
: undefined,
)
.filter((item): item is ExternalContextItem => item !== undefined)
.slice(0, MAX_PROVIDER_ITEMS);
@@ -201,16 +399,17 @@ function parseMem0Items(response: unknown): readonly ExternalContextItem[] {
function parseItemFields(
value: Record<string, unknown>,
contentKey: 'content' | 'memory',
idKey: 'id' | 'memory_id',
contentKeys: ReadonlyArray<'content' | 'memory' | 'text'>,
): ExternalContextItem | undefined {
const id = value['id'];
const content = value[contentKey];
if (
typeof id !== 'string' ||
id.length === 0 ||
typeof content !== 'string' ||
content.length === 0
) {
const id = value[idKey];
const content = contentKeys
.map((key) => value[key])
.find(
(candidate): candidate is string =>
typeof candidate === 'string' && candidate.length > 0,
);
if (typeof id !== 'string' || id.length === 0 || content === undefined) {
return undefined;
}
@@ -248,9 +447,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function validateConfiguredBaseUrl(value: string): URL {
function validateConfiguredBaseUrl(
value: string,
options?: { allowInsecureHttp?: boolean; allowInsecureHttpHint?: boolean },
): URL {
try {
return validateProviderBaseUrl(value);
return validateProviderBaseUrl(value, options);
} catch (error) {
throw new ConfigurationError(
error instanceof Error
+29 -1
View File
@@ -59,7 +59,10 @@ export interface AutoRecallConfig {
timeoutMs: number;
}
export type ProviderConfig = Mem0ProviderConfig | GenericHttpProviderConfig;
export type ProviderConfig =
| Mem0ProviderConfig
| Mem0CompatibleProviderConfig
| GenericHttpProviderConfig;
export interface Mem0ProviderConfig {
type: 'mem0-platform-v3';
@@ -68,6 +71,31 @@ export interface Mem0ProviderConfig {
appId: string;
}
export const MEM0_PRESET_IDS = [
'mem0-platform-v3',
'mem0-oss-rest-2026-08',
'aliyun-polardb-mysql-2026-08',
] as const;
export type Mem0PresetId = (typeof MEM0_PRESET_IDS)[number];
export interface Mem0CompatibleProviderConfig {
type: 'mem0';
preset: Mem0PresetId;
endpoint: {
origin: string;
basePath: string;
allowInsecureHttp?: boolean;
};
credentialEnv: string;
credential: string;
scope: {
userId?: string;
agentId?: string;
appId?: string;
};
}
export interface GenericHttpProviderConfig {
type: 'generic-http-search-v1';
baseUrl: string;