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

+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;