mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
2821abb120
commit
abcf23a3d9
35 files changed
+1961
-101
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user