mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user