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
@@ -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