feat(memory): bundle Mem0 with the main CLI (#12891)

* feat(memory): bundle Mem0 with the main CLI

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

* feat(memory): support settings-defined Mem0 credentials

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

* fix(memory): preserve bundled Mem0 trust and configuration boundaries

Ignore repository-scoped MCP conflicts, require the genuine bundled runtime for confirmation hooks, and use native artifact paths in packaging assertions. Improve field diagnostics and document existing precedence, scope, and verification limits.

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

* fix(mem0): close three review Criticals on the bundled Mem0 binding

R1-1 (scripts/tests/package-assets.test.js): the two new multi-segment
`missingArtifact` entries are compared against a message built with
`path.join`, so `mem0/main.js` never matches the `\`-joined Windows path.
Normalise the separator in the assertion; single-segment entries and POSIX
behaviour are unchanged.

R1-2 (packages/cli/src/config/config.ts): the mem0/external-context conflict
guard read the merged all-scopes map, so a `external-context` entry that a
trusted repository contributes through its own `.qwen/settings.json`
(`scope: 'workspace'`) aborted `loadCliConfig` for every operator who
configured `memory.mem0` — before `assembleMcpServers` applied the approval
gate that would have held that server pending anyway. Limit the settings leg
to operator scopes via `isGatedMcpScope`; a gated-scope entry is now
overridden by the built-in binding (topTierMcpServers spreads last), exactly
as a `.mcp.json` entry of the same name always has been, matching the design
doc this PR adds.

R1-28 (packages/cli/src/config/mem0-settings.ts): `bundledMem0Hooks`
identified the bundled server by duck-typing an env-var name plus
`includeTools`, so a repository-authored `external-context` entry reaching
the `/hooks` reload path through `getMcpServers()` was filed under
`systemHooks` — the one hook source folder trust does not gate — rendered as
`[System]` configuration and pointed at a different script than the one the
MCP approval dialog showed. Require identity instead: every file-sourced
entry carries a provenance `scope`, the bundled one never does.

Tests: workspace-scoped override + operator-scoped conflict still throws
(config.test.ts), three scoped-entry rejection rows + an unscoped positive
control (mem0-settings.test.ts), and a control/negative pair on the real
reload path (hooksCommand.test.ts). Each fix was mutation-proved red.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmultfgjtdh

* test(mem0): pin the spawn interpreter, timeout slack and both bound copies

R1-23: nothing in the repo observed `server.command` or `server.timeout`, and
the `memory.mem0.timeoutMs` bounds are declared independently in
mem0-settings.ts (zod, the runtime contract) and settingsSchema.ts (the
settings-dialog copy). Replacing `command: process.execPath` with `'node'`, or
dropping the `+ 5000` slack, left every suite green — a packaged or
GUI-launched install routinely has no `node` on PATH, and the same value gates
`bundledMem0Hooks` and is interpolated as the hook interpreter.

Adds, in mem0-settings.test.ts: `command === process.execPath`,
`config.timeoutMs === 5000` / `server.timeout === 10_000` for the default, an
accept row at the documented ceiling asserting `35_000`, and reject rows for
`0` / `30001` / `1.5`. Adds, in settingsSchema.test.ts, the bound test mirroring
the existing `tools.webSearch.timeoutMs` precedent so the JSON-schema copy is
pinned to the same documented numbers (docs/users/features/mem0.md: "defaults to
5000, between 1 and 30000").

Mutation-proved individually: `process.execPath` -> `'node'` red, `+ 5000`
removed red, `maximum: 30000` -> `60000` red; intact tree green (27 + 61).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmultfgjtdh

* fix(mem0): restore PolarDB direct-import text on retrieval

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

* test(mem0): use the real bundled binding in hook reload control

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

* fix(memory): Preserve Mem0 operator and legacy preset semantics

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

* test(memory): Pin Windows Mem0 package artifact errors

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

* fix(memory): Retain Mem0 safeguards across reloads

---------

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-09-30 07:57:11 +00:00
1 parent 2821abb120
commit abcf23a3d9
35 files changed
+1961 -101

No files matched your search

+8 -3
View File
@@ -1,5 +1,7 @@
# External Context extension
For normal Mem0 use, configure `memory.mem0` in the main CLI's user settings instead. This private direct integration is the advanced, separately managed profile; it is not required for the bundled path and does not require a separate npm package release.
This private Qwen Code integration connects one interactive CLI process to one
administrator-bound external context corpus without changing Qwen Core. It has
three managed deployment variants:
@@ -402,13 +404,16 @@ remain administrator-owned:
The built-in presets are:
- `mem0-v2`: PolarDB-style V2 search with `limit`, V1 direct import, and single-user direct-import message decoding.
- `mem0-v3`: the same contract as `mem0-platform-v3`.
- `mem0-oss-2026-08`: the same contract as `mem0-oss-rest-2026-08`.
- `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`.
`/v1/memories`, `Authorization: Token`, fixed `userId`, optional
`agentId`, historical `top_k`, and raw search content.
#### Connecting a PolarDB Mem0 instance
@@ -445,7 +450,7 @@ 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
sends a maximum of five through the preset's `top_k` or `limit` 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
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { ConfigurationError, resolveConfig } from './config.js';
import { runMcp } from './mcp.js';
import { installEnvironmentProxy } from './proxy.js';
try {
const source = process.env['QWEN_BUNDLED_MEM0_CONFIG'];
if (!source || Buffer.byteLength(source) > 64 * 1024) {
throw new ConfigurationError(
'Bundled Mem0 configuration is missing or invalid.',
);
}
const config = await resolveConfig(JSON.parse(source));
if (config.version !== 1 || config.provider.type !== 'mem0') {
throw new ConfigurationError('Bundled Mem0 requires a Mem0 configuration.');
}
installEnvironmentProxy();
await runMcp(config);
} catch (error) {
process.stderr.write(
'[mem0] ' +
(error instanceof ConfigurationError
? error.message
: 'Mem0 startup failed.') +
'\n',
);
process.exitCode = 1;
}
@@ -130,6 +130,13 @@ export async function loadConfig(
throw new ConfigurationError('External context config is not valid JSON.');
}
return resolveConfig(parsed, env);
}
export async function resolveConfig(
parsed: unknown,
env: NodeJS.ProcessEnv = process.env,
): Promise<ExternalContextConfig> {
const result = configSchema.safeParse(parsed);
if (!result.success) {
throw new ConfigurationError('External context config is invalid.');
@@ -24,6 +24,14 @@ export function validateProviderBaseUrl(
value: string,
options?: { allowInsecureHttp?: boolean; allowInsecureHttpHint?: boolean },
): URL {
if (
[...value].some(
(char) =>
char.charCodeAt(0) <= 32 || char.charCodeAt(0) === 127 || char === '\\',
)
) {
throw new Error('Provider URL is invalid.');
}
let url: URL;
try {
url = new URL(value);
+3 -2
View File
@@ -20,6 +20,7 @@ import {
} from './provider-profile.js';
import type {
ExternalContextConfigV1,
ExternalContextConfig,
ExternalContextProvider,
ExternalMemoryWriter,
RememberResult,
@@ -125,8 +126,8 @@ export function createExternalContextMcpServer(
return server;
}
export async function runMcp(): Promise<void> {
const config = await loadConfig();
export async function runMcp(config?: ExternalContextConfig): Promise<void> {
config ??= await loadConfig();
if (config.version !== 1) {
throw new ConfigurationError(
'External context MCP server requires a version 1 configuration.',
@@ -32,6 +32,7 @@ export interface Mem0Preset {
fixedBody?: Readonly<Record<string, number | boolean>>;
idField: 'id' | 'memory_id';
contentFields: ReadonlyArray<'memory' | 'content' | 'text'>;
directImportMessages?: boolean;
};
write?:
| {
@@ -51,7 +52,7 @@ const omittedScope: Mem0ScopeRule = {
write: 'omit',
};
export const MEM0_PRESETS: Readonly<Record<Mem0PresetId, Mem0Preset>> = {
const legacyPresets = {
'mem0-platform-v3': {
authentication: 'authorization-token',
scope: {
@@ -109,6 +110,20 @@ export const MEM0_PRESETS: Readonly<Record<Mem0PresetId, Mem0Preset>> = {
idField: 'id',
},
},
} satisfies Record<string, Mem0Preset>;
export const MEM0_PRESETS: Readonly<Record<Mem0PresetId, Mem0Preset>> = {
...legacyPresets,
'mem0-v2': {
...legacyPresets['aliyun-polardb-mysql-2026-08'],
search: {
...legacyPresets['aliyun-polardb-mysql-2026-08'].search,
limitField: 'limit',
directImportMessages: true,
},
},
'mem0-v3': legacyPresets['mem0-platform-v3'],
'mem0-oss-2026-08': legacyPresets['mem0-oss-rest-2026-08'],
};
export function getMem0Preset(id: Mem0PresetId): Mem0Preset {
@@ -664,6 +664,21 @@ describe('Mem0CompatibleAdapter', () => {
});
it.each([
{
preset: 'mem0-v2' 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',
limit: 5,
agent_id: 'fixed-agent',
filters: { user_id: 'fixed-user' },
},
expectedContent: 'polardb memory',
},
{
preset: 'mem0-platform-v3' as const,
scope: { appId: 'fixed-app' },
@@ -752,6 +767,60 @@ describe('Mem0CompatibleAdapter', () => {
},
);
it.each([
{
preset: 'mem0-v2' as const,
scope: { userId: 'fixed-user' },
unwrap: true,
},
{
preset: 'aliyun-polardb-mysql-2026-08' as const,
scope: { userId: 'fixed-user' },
unwrap: false,
},
{
preset: 'mem0-platform-v3' as const,
scope: { appId: 'fixed-app' },
unwrap: false,
},
])(
'preserves direct-import text for $preset without decoding unrelated JSON',
async ({ preset, scope, unwrap }) => {
const content =
' Keep 🙂 this\nexactly. [{"role":"user","content":"literal JSON"}] ';
const wrapped = JSON.stringify([
{ role: 'user', content, created_at: null, timestamp: null },
]);
const unrelated = JSON.stringify([
{ role: 'assistant', content: 'not our direct import' },
]);
const origin = await startServer(async (request, response) => {
await readBody(request);
json(response, {
results: [
{ id: 'imported', memory: wrapped, infer: false, score: 0.9 },
{ id: 'literal-json', memory: wrapped, infer: true },
{ id: 'other-message', memory: unrelated, infer: false },
{ id: 'plain', memory: 'older plain text', infer: false },
],
});
});
const items = await new Mem0CompatibleAdapter(
mem0CompatibleConfig(origin, preset, scope),
).search({
query: 'deployment',
limit: 5,
signal: AbortSignal.timeout(1000),
});
expect(items).toEqual([
{ id: 'imported', content: unwrap ? content : wrapped, score: 0.9 },
{ id: 'literal-json', content: wrapped },
{ id: 'other-message', content: unrelated },
{ id: 'plain', content: 'older plain text' },
]);
},
);
it.each([
{
preset: 'mem0-oss-rest-2026-08' as const,
+29 -12
View File
@@ -220,11 +220,7 @@ export class Mem0CompatibleAdapter
body,
signal: input.signal,
});
return parseMem0Items(
response,
this.preset.search.idField,
this.preset.search.contentFields,
);
return parseMem0Items(response, this.preset.search);
}
async remember(input: {
@@ -377,8 +373,7 @@ function parseGenericItem(value: unknown): ExternalContextItem | undefined {
function parseMem0Items(
response: unknown,
idField: 'id' | 'memory_id' = 'id',
contentFields: ReadonlyArray<'memory' | 'content' | 'text'> = ['memory'],
search: Mem0Preset['search'],
): readonly ExternalContextItem[] {
const values =
isRecord(response) && Array.isArray(response['results'])
@@ -388,15 +383,37 @@ function parseMem0Items(
throw new Error('External context provider returned an invalid response.');
}
return values
.map((value) =>
isRecord(value)
? parseItemFields(value, idField, contentFields)
: undefined,
)
.map((value) => {
if (!isRecord(value)) return undefined;
const item = parseItemFields(value, search.idField, search.contentFields);
if (item && search.directImportMessages && value['infer'] === false) {
item.content = parseDirectImportContent(item.content);
}
return item;
})
.filter((item): item is ExternalContextItem => item !== undefined)
.slice(0, MAX_PROVIDER_ITEMS);
}
function parseDirectImportContent(content: string): string {
try {
const messages: unknown = JSON.parse(content);
if (
Array.isArray(messages) &&
messages.length === 1 &&
isRecord(messages[0]) &&
messages[0]['role'] === 'user' &&
typeof messages[0]['content'] === 'string' &&
messages[0]['content'].length > 0
) {
return messages[0]['content'];
}
} catch {
// Older PolarDB direct imports can return plain text.
}
return content;
}
function parseItemFields(
value: Record<string, unknown>,
idKey: 'id' | 'memory_id',
@@ -72,6 +72,9 @@ export interface Mem0ProviderConfig {
}
export const MEM0_PRESET_IDS = [
'mem0-v2',
'mem0-v3',
'mem0-oss-2026-08',
'mem0-platform-v3',
'mem0-oss-rest-2026-08',
'aliyun-polardb-mysql-2026-08',